混合的字数和字符串的人性化或自然数字排序

时间:2012-10-18 23:51:42

标签: sql postgresql sorting collation natural-sort

通过this question跟进Sivaram Chintalapudi,我感兴趣的是PostgreSQL中是否可以执行包含多位数字和单词混合的字符串natural - or "humanized" - sorting字母。字符串中没有固定的单词和数字模式,字符串中可能有多个多位数字。

我经常看到的这个例子就是在Mac OS的Finder中,它自然地对包含混合数字和单词的文件名进行排序,在“3”之后放置“20”,而不是在它之前。

所需的归类顺序将由算法生成,该算法将每个字符串拆分为字母数字边界的块,然后对每个部分进行排序,将正常整理的字母块和数字块作为整数处理整数。所以:

'AAA2fred'将成为('AAA',2,'fred')'AAA10bob'将成为('AAA',10,'bob')。然后可以根据需要对它们进行分类:

regress=# WITH dat AS ( VALUES ('AAA',2,'fred'), ('AAA',10,'bob') )
regress-# SELECT dat FROM dat ORDER BY dat;
     dat      
--------------
 (AAA,2,fred)
 (AAA,10,bob)
(2 rows)

与通常的字符串排序规则相比:

regress=# WITH dat AS ( VALUES ('AAA2fred'), ('AAA10bob') )
regress-# SELECT dat FROM dat ORDER BY dat;
    dat     
------------
 (AAA10bob)
 (AAA2fred)
(2 rows)

然而,记录比较方法没有概括,因为Pg不会比较ROW(..)构造或不等数量条目的记录。

给定样本数据in this SQLFiddle,默认的en_AU.UTF-8排序规则产生排序:

1A, 10A, 2A, AAA10B, AAA11B, AAA1BB, AAA20B, AAA21B, X10C10, X10C2, X1C1, X1C10, X1C3, X1C30, X1C4, X2C1

但我想:

1A, 2A, 10A, AAA1BB, AAA10B, AAA11B, AAA20B, AAA21B, X1C1, X1C3, X1C4, X1C10, X1C30, X2C1, X10C10, X10C2

我目前正在使用PostgreSQL 9.1,但只有9.2的建议会没问题。我对如何实现有效的字符串分割方法的建议感兴趣,以及如何在所描述的交替的字符串然后数字校对中比较得到的分割数据。或者,当然,对于不需要拆分字符串的完全不同且更好的方法。

PostgreSQL似乎不支持比较器函数,否则使用递归比较器和ORDER USING comparator_fn以及comparator(text,text)函数可以很容易地完成。唉,这种语法是虚构的。

更新: Blog post on the topic

7 个答案:

答案 0 :(得分:14)

以您的测试数据为基础,但这适用于任意数据。这适用于字符串中的任意数量的元素。

为每个数据库注册一个由text和一个integer值组成的复合类型。我称之为ai

CREATE TYPE ai AS (a text, i int);

诀窍是从列中的每个值形成一个ai数组。

具有模式regexp_matches()

(\D*)(\d*)g选项为每个字母和数字组合返回一行。加上一个不相关的悬空行和两个空字符串'{"",""}'过滤或抑制它只会增加成本。在将''组件中的0替换为空integer之后,将此聚合为数组(因为''无法转换为integer)。< / p>

NULL值首先排序 - 或者您需要特殊情况 - 或者使用STRICT函数中的整个shebang,例如@Craig建议。

Postgres 9.4或更高版本

SELECT data
FROM   alnum
ORDER  BY ARRAY(SELECT ROW(x[1], CASE x[2] WHEN '' THEN '0' ELSE x[2] END)::ai
                FROM regexp_matches(data, '(\D*)(\d*)', 'g') x)
        , data;

db&lt;&gt;小提琴here

Postgres 9.1(原始答案)

使用PostgreSQL 9.1.5进行测试,其中regexp_replace()的行为略有不同。

SELECT data
FROM  (
    SELECT ctid, data, regexp_matches(data, '(\D*)(\d*)', 'g') AS x
    FROM   alnum
    ) x
GROUP  BY ctid, data   -- ctid as stand-in for a missing pk
ORDER  BY regexp_replace (left(data, 1), '[0-9]', '0')
        , array_agg(ROW(x[1], CASE x[2] WHEN '' THEN '0' ELSE x[2] END)::ai)
        , data         -- for special case of trailing 0

添加regexp_replace (left(data, 1), '[1-9]', '0')作为第一个ORDER BY项,以处理前导数字和空字符串。

如果可能出现{}()"',之类的特殊字符,则必须相应地转义它们。
  @Craig建议使用ROW表达式来处理这个问题。

<击> 顺便说一句,这不会在sqlfiddle中执行,但它在我的数据库集群中执行。 JDBC不能胜任它。 sqlfiddle抱怨:

<击>   

方法org.postgresql.jdbc3.Jdbc3Array.getArrayImpl(long,int,Map)是   尚未实现。   

此后已修复:http://sqlfiddle.com/#!17/fad6e/1

答案 1 :(得分:8)

迟到添加这个答案,因为看起来其他人都在展开数组或其他类似的东西。看似过度。

CREATE FUNCTION rr(text,int) RETURNS text AS $$
SELECT regexp_replace(
    regexp_replace($1, '[0-9]+', repeat('0',$2) || '\&', 'g'), 
    '[0-9]*([0-9]{' || $2 || '})', 
    '\1', 
    'g'
)
$$ LANGUAGE sql;

SELECT t,rr(t,9) FROM mixed ORDER BY t;
      t       |             rr              
--------------+-----------------------------
 AAA02free    | AAA000000002free
 AAA10bob     | AAA000000010bob
 AAA2bbb03boo | AAA000000002bbb000000003boo
 AAA2bbb3baa  | AAA000000002bbb000000003baa
 AAA2fred     | AAA000000002fred
(5 rows)

(reverse-i-search)`OD': SELECT crypt('richpass','$2$08$aJ9ko0uKa^C1krIbdValZ.dUH8D0R0dj8mqte0Xw2FjImP5B86ugC');
richardh=> 
richardh=> SELECT t,rr(t,9) FROM mixed ORDER BY rr(t,9);
      t       |             rr              
--------------+-----------------------------
 AAA2bbb3baa  | AAA000000002bbb000000003baa
 AAA2bbb03boo | AAA000000002bbb000000003boo
 AAA2fred     | AAA000000002fred
 AAA02free    | AAA000000002free
 AAA10bob     | AAA000000010bob
(5 rows)

我没有声称两个正则表达式是最有效的方法,但rr()是不可变的(对于固定长度),所以你可以索引它。哦 - 这是9.1

当然,使用plperl你可以一次性评估更换垫子/修剪它。但是使用perl你总是得到比其他方法更多的选项(TM): - )

答案 2 :(得分:7)

我遇到了同样的问题,我想将解决方案包装在一个函数中,以便我可以轻松地重复使用它。我创建了以下函数来实现Postgres中的“人性化”排序顺序。

CREATE OR REPLACE FUNCTION human_sort(text)
  RETURNS text[] AS
$BODY$   
  /* Split the input text into contiguous chunks where no numbers appear,
     and contiguous chunks of only numbers. For the numbers, add leading 
     zeros to 20 digits, so we can use one text array, but sort the 
     numbers as if they were big integers.

       For example, human_sort('Run 12 Miles') gives
            {'Run ', '00000000000000000012', ' Miles'}
  */
  select array_agg(
    case
      when a.match_array[1]::text is not null 
        then a.match_array[1]::text         
      else lpad(a.match_array[2]::text, 20::int, '0'::text)::text                                      
    end::text)
    from (
      select regexp_matches(
        case when $1 = '' then null else $1 end, E'(\\D+)|(\\d+)', 'g'
      ) AS match_array      
    ) AS a  
$BODY$
  LANGUAGE sql IMMUTABLE;

测试用于Postgres 8.3.18和9.3.5

  • 没有递归,应该比递归解决方案更快
  • 只能在order by子句中使用,不必处理主键或ctid
  • 适用于任何选择(甚至不需要PK或ctid)
  • 比其他解决方案更简单,应该更容易扩展和维护
  • 适合用于功能指标以提高性能
  • 适用于Postgres v8.3或更高版本
  • 允许输入中无限数量的文本/数字替换
  • 只使用一个正则表达式,应该比具有多个正则表达式的版本更快
  • 超过20位的数字按其前20位数排序

以下是一个示例用法:

select * from (values 
  ('Books 1', 9),
  ('Book 20 Chapter 1', 8),
  ('Book 3 Suffix 1', 7),
  ('Book 3 Chapter 20', 6),
  ('Book 3 Chapter 2', 5),
  ('Book 3 Chapter 1', 4),
  ('Book 1 Chapter 20', 3),
  ('Book 1 Chapter 3', 2),
  ('Book 1 Chapter 1', 1),
  ('', 0),
  (null::text, 0)
) as a(name, sort)
order by human_sort(a.name)
-----------------------------
|name               |  sort |
-----------------------------
|                   |   0   |
|                   |   0   |
|Book 1 Chapter 1   |   1   |
|Book 1 Chapter 3   |   2   |
|Book 1 Chapter 20  |   3   |
|Book 3 Chapter 1   |   4   |
|Book 3 Chapter 2   |   5   |
|Book 3 Chapter 20  |   6   |
|Book 3 Suffix 1    |   7   |
|Book 20 Chapter 1  |   8   |
|Books 1            |   9   |
-----------------------------

答案 3 :(得分:5)

以下函数将字符串拆分为任意长度的(字,数字)对数组。如果字符串以数字开头,那么第一个条目将有一个NULL字。

CREATE TYPE alnumpair AS (wordpart text,numpart integer);

CREATE OR REPLACE FUNCTION regexp_split_numstring_depth_pairs(instr text)
RETURNS alnumpair[] AS $$
WITH x(match) AS (SELECT regexp_matches($1, '(\D*)(\d+)(.*)'))
SELECT
  ARRAY[(CASE WHEN match[1] = '' THEN '0' ELSE match[1] END, match[2])::alnumpair] || (CASE 
  WHEN match[3] = '' THEN
    ARRAY[]::alnumpair[]
  ELSE 
    regexp_split_numstring_depth_pairs(match[3]) 
  END)
FROM x;$$ LANGUAGE 'sql' IMMUTABLE;

允许PostgreSQL的复合类型排序发挥作用:

SELECT data FROM alnum ORDER BY regexp_split_numstring_depth_pairs(data);

并根据this SQLFiddle生成预期结果。我已经采用Erwin的0替换所有以数字开头的字符串中的空字符串,以便数字先排序;它比使用ORDER BY left(data,1), regexp_split_numstring_depth_pairs(data)更清洁。

虽然该函数可能非常慢,但它至少可以用在表达式索引中。

这很有趣!

答案 4 :(得分:3)

create table dat(val text)
insert into dat ( VALUES ('BBB0adam'), ('AAA10fred'), ('AAA2fred'), ('AAA2bob') );

select 
  array_agg( case when z.x[1] ~ E'\\d' then lpad(z.x[1],10,'0') else z.x[1] end ) alnum_key
from (
  SELECT ctid, regexp_matches(dat.val, E'(\\D+|\\d+)','g') as x
  from dat
) z
group by z.ctid
order by alnum_key;

       alnum_key       
-----------------------
 {AAA,0000000002,bob}
 {AAA,0000000002,fred}
 {AAA,0000000010,fred}
 {BBB,0000000000,adam}

对此进行了近一个小时的研究并且没有看过 - 我看到Erwin来到了一个类似的地方。进入相同的#34;找不到数据类型text []&#34;的数组类型像@Clodoaldo一样麻烦。在我想到由ctid进行分组之前,让清理练习没有聚合所有的行很麻烦(这真的像作弊一样 - 并且不像在OP示例中那样在psuedo表上工作{{1 }})。如果array_agg可以接受产生集合的子选择,那就更好了。

答案 5 :(得分:2)

我不是RegEx大师,但我可以在某种程度上工作。足以产生这个答案。

它将在内容中处理最多2个数值。我不认为OSX比这更进一步,如果它甚至处理2。

WITH parted AS (
  select data,
         substring(data from '([A-Za-z]+).*') part1,
         substring('a'||data from '[A-Za-z]+([0-9]+).*') part2,
         substring('a'||data from '[A-Za-z]+[0-9]+([A-Za-z]+).*') part3,
         substring('a'||data from '[A-Za-z]+[0-9]+[A-Za-z]+([0-9]+).*') part4
    from alnum
)
  select data
    from parted
order by part1,
         cast(part2 as int),
         part3,
         cast(part4 as int),
         data;

SQLFiddle

答案 6 :(得分:0)

以下解决方案结合了其他答案中提出的各种想法,以及classic solution 中的一些想法:

create function natsort(s text) returns text immutable language sql as $$
  select string_agg(r[1] || E'\x01' || lpad(r[2], 20, '0'), '')
  from regexp_matches(s, '(\D*)(\d*)', 'g') r;
$$;

这个函数的设计目标是简单和纯字符串操作(没有自定义类型和数组),所以它可以很容易地用作一个嵌入式解决方案,并且很容易被索引。

注意:如果您希望数字超过 20 位,则必须将函数中的硬编码最大长度 20 替换为合适的更大的长度。请注意,这将直接影响结果字符串的长度,因此不要将该值设置为大于所需的值。