34

跟进Sivaram Chintalapudi提出的这个问题,我感兴趣的是在 PostgreSQL 中对包含多位数字和单词/字母混合的字符串进行自然或“人性化”排序是否可行。没有固定的字符串中的单词和数字的模式,并且字符串中可能有多个多位数字。

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

所需的整理顺序将由一种算法产生,该算法将每个字符串在字母数字边界处分成块,然后对每个部分进行排序,将具有正常整理的字母块和数字块视为整数以进行整理。所以:

'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(..) 构造或不等数量条目的记录。

鉴于此 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)。唉,这种语法是虚构的。

更新: 关于该主题的博客文章

4

7 回答 7

19

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

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

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

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

regexp_matches()使用模式(\D*)(\d*)g选项为每个字母和数字组合返回一行。加上一个带有两个空字符串的不相关的悬空行'{"",""}'过滤或抑制它只会增加成本。将组件中的空字符串 ( '')替换为( 因为不能转换为)后,将其聚合到一个数组中。0integer''integer

NULLSTRICT值首先排序-或者您必须对它们进行特殊处理-或者在@Craig建议的函数中使用整个shebang 。

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<>在这里摆弄

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

于 2012-10-19T01:22:27.217 回答
12

我遇到了同样的问题,我想将解决方案包装在一个函数中,以便我可以轻松地重用它。我创建了以下函数以在 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   |
-----------------------------
于 2013-12-18T19:49:11.177 回答
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) :-)

于 2012-10-19T09:52:29.167 回答
5

以下函数将字符串拆分为任意长度的 (word,number) 对数组。如果字符串以数字开头,则第一个条目将包含一个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);

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

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

那很有趣!

于 2012-10-19T01:08:06.973 回答
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 到达了一个类似的地方。遇到与@Clodoaldo 相同的“找不到数据类型文本 [] 的数组类型”的问题。在我想到按 ctid 分组之前,让清理练习不聚合所有行有很多麻烦(这感觉真的像是在作弊——并且在 OP 示例中的伪表上不起作用WITH dat AS ( VALUES ('AAA2fred'), ('AAA10bob') ) ...)。如果 array_agg 可以接受产生集合的子选择,那就更好了。

于 2012-10-19T02:08:53.633 回答
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

于 2012-10-19T00:14:52.987 回答
0

以下解决方案是其他答案中提出的各种想法的组合,以及经典解决方案中的一些想法:

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函数中硬编码的最大长度替换为合适的更大长度。请注意,这将直接影响结果字符串的长度,因此不要使该值大于所需值。

于 2021-05-12T18:25:04.403 回答