使用 Unicode 排序规则算法在 Ruby 中进行排序



Ruby和Postgres的排序略有不同,这在我的项目中造成了微妙的问题。有两个问题:重音字符和空格。看起来Ruby正在对ASCII进行排序,Postgres正在使用正确的Unicode排序算法进行排序。

Heroku Postgres 11.2.数据库排序规则en_US.UTF-8

psql (11.3, server 11.2 (Ubuntu 11.2-1.pgdg16.04+1))
...
=> select 'quia et' > 'qui qui';
?column? 
----------
f
(1 row)
=> select 'quib' > 'qüia';
?column? 
----------
t
(1 row)

Ruby 2.4.4 on Heroku.

Loading production environment (Rails 5.2.2.1)
[1] pry(main)> 'quia et' > 'qui qui'
=> true
[2] pry(main)> 'quib' > 'qüia'
=> false
[3] pry(main)> ENV['LANG']
=> "en_US.UTF-8"

我可以修复重音字符的处理,但我无法让 Ruby 正确做空格。例如,以下是他们对同一列表进行排序的方式。

Postgres: ["hic et illum", "quia et ipsa", "qui qui non"]
Ruby:     ["hic et illum", "qui qui non", "quia et ipsa"]

我尝试了icunicode宝石:

array.sort_by {|s| s.unicode_sort_key}

这将处理重音字符,但不能正确使用空格。

如何让 Ruby 使用 Unicode 排序算法进行排序?

更新在 Unicode® 技术标准 #10 中找到更全面的示例。这些顺序正确。

[
"di Silva   Fred",
"diSilva    Fred",
"disílva    Fred",
"di Silva   John",
"diSilva    John",
"disílva    John"
]

您的用例是否允许简单地将排序委托给 Postgres,而不是尝试在 Ruby 中重新创建它?

这里的部分困难在于没有单一的正确排序方法,但任何变量元素都可能导致最终排序顺序中相当大的差异,例如,请参阅变量权重部分。

例如,像 twitter-cldr-rb 这样的 gem 具有相当健壮的 UCA 实现,并且由一个全面的测试套件提供支持 - 但针对不可忽略的测试用例,这与 Postgres 实现不同(Postgres 似乎使用移位修剪变体)。

测试用例的绝对数量意味着您无法保证一个有效的解决方案在所有情况下都与 Postgres 排序顺序匹配。 例如,它会正确处理 en/em 破折号,甚至是表情符号吗?您可以分叉和修改twitter-cldr-rb宝石,但我怀疑这不是一件小事!

如果需要处理数据库中不存在的值,可以要求 Postgres 使用VALUES列表以轻量级方式对它们进行排序:

sql = "SELECT * FROM (VALUES ('de luge'),('de Luge'),('de-luge'),('de-Luge'),('de-luge'),('de-Luge'),('death'),('deluge'),('deLuge'),('demark')) AS t(term) ORDER BY term ASC"
ActiveRecord::Base.connection.execute(sql).values.flatten

这显然会导致往返Postgres,但应该非常快。

我非常接近使用这个算法和icunicode gem。

require 'icunicode'
def database_sort_key(key)
key.gsub(/s+/,'').unicode_sort_key
end
array.sort_by { |v|
[database_sort_key(v), v.unicode_sort_key]
}

首先,我们使用删除空格的 unicode 排序键进行排序。然后,如果它们相同,我们按原始值的 unicode 排序键进行排序。

这解决了unicode_sort_key的一个弱点:它不认为空间是弱的。

2.4.4 :007 > "fo p".unicode_sort_key.bytes.map { |b| b.to_s(16) }
=> ["33", "45", "4", "47", "1", "8", "1", "8"] 
2.4.4 :008 > "foo".unicode_sort_key.bytes.map { |b| b.to_s(16) }
=> ["33", "45", "45", "1", "7", "1", "7"] 

请注意,fo p中的空格与任何其他字符一样重要。这会导致'fo p' < 'foo'不正确。我们通过在生成密钥之前首先去除空格来解决此问题。

2.4.4 :011 > "fo p".gsub(/s+/, '').unicode_sort_key.bytes.map { |b| b.to_s(16) }
=> ["33", "45", "47", "1", "7", "1", "7"] 
2.4.4 :012 > "foo".gsub(/s+/, '').unicode_sort_key.bytes.map { |b| b.to_s(16) }
=> ["33", "45", "45", "1", "7", "1", "7"] 

现在'foo' < 'fo p'哪个是正确的。

但是由于规范化,我们可能会在去除空格后具有看起来相同的值,fo o应该小于foo。因此,如果database_sort_key相同,我们将比较它们的普通unicode_sort_key

有一些边缘情况是错误的。foo应该小于fo o,但这会使它倒退。

这是Enumerable方法。

module Enumerable
# Just like `sort`, but tries to sort the same as the database does
# using the proper Unicode collation algorithm. It's close.
#
# Differences in spacing, cases, and accents are less important than
# character differences.
#
# "foo" < "fo p" o vs p is more important than the space difference
# "Foo" < "fop" o vs p is more important than is case difference
# "föo" < "fop" o vs p is more important than the accent difference
#
# It does not take a block.
def sort_like_database(&block)
if block_given?
raise ArgumentError, "Does not accept a block"
else
# Sort by the database sort key. Two different strings can have the
# same keys, if so sort just by its unicode sort key.
sort_by { |v| [database_sort_key(v), v.unicode_sort_key] }
end
end
# Just like `sort_by`, but it sorts like `sort_like_database`.
def sort_by_like_database(&block)
sort_by { |v|
field = block.call(v)
[database_sort_key(field), field.unicode_sort_key]
}
end
# Sort by the unicode sort key after stripping out all spaces. This provides
# a decent simulation of the Unicode collation algorithm and how it handles
# spaces.
private def database_sort_key(key)
key.gsub(/s+/,'').unicode_sort_key
end
end

如果有机会将Ruby更新到2.5.0,它附带String#unicode_normalize。后者将使任务更容易:您所需要的只是在摆脱非字母之前将字符串规范化为分解形式。在输入中,我们有 4 个字符串。在qüia中有一个组合的变音符号,在'qü ic'中有一个组合字符:

['quid', 'qüia', 'qu ib', 'qü ic'].map &:length
#⇒ [4, 5, 5, 5]

瞧:

['quid', 'qüia', 'qu ib', 'qü ic'].sort_by do |s|
s.unicode_normalize(:nfd).gsub(/P{L}+/, '')
end
#⇒ ["qüia", "qu ib", "qü ic", "quid"]

要排序不区分大小写,请在排序器中String#downcase它:

["di Silva Fred", "diSilva Fred", "disílva Fred",
"di Silva John", "diSilva John", "disílva John"].sort_by do |s|
s.downcase.unicode_normalize(:nfd).gsub(/P{L}+/, '')
end
#⇒ ["di Silva Fred", "diSilva Fred", "disílva Fred",
#   "di Silva John", "diSilva John", "disílva John"]

最新更新