如何在 PostgreSQL 中使用语言相关数据进行分层查询

How to hierarchical query in PostgreSQL with language-dependent data

我正在尝试从对自动引用 table 的查询中检索分层排序结果,如下所示:

create table category (
  id          serial,
  
  -- parent category, 
  parent_id   integer default null, -- null for root category
  
  -- tree control
  tree_depth  smallint not null, -- calculated

  primary key (id),
  unique (parent_id, id),
  foreign key (parent_id) references category (id)
);

除了需要支持多种语言外,这是存储类别树的常用方法。为此,我们加入了一个依赖于语言的 table,如下所示:

create table category_lang (
  id            serial,
  
  -- natural primary key
  category_id   integer not null,
  lang_code     char(2) not null,
  
  -- language-dependent data
  title         varchar(128) not null,
  
  primary key (id),
  unique (category_id, lang_code)
);

tree_depth 列在 before insert 触发器中计算如下:

create or replace function fn_category__bins () returns trigger as $$
begin
  -- calculate tree_depth as parent tree_depth + 1
  if new.parent_id is null then
    new.tree_depth = 0;
  else
    new.tree_depth = (select tree_depth from category where id = new.parent_id limit 1) + 1;
  end if;

  return new;
end;
$$ language plpgsql;

create trigger tg_category__bins before insert on category for each row
execute procedure fn_category__bins();

我们用两种语言的易于阅读的文本填充 table:

insert into category (parent_id, id) values 
(null, 1),
(null, 2),
(null, 3),

(1, 11),
(1, 12),
(1, 13),

(2, 21),
(2, 22),
(3, 31),

(21, 211),
(21, 212),
(21, 213);

-- lang_code = 'EN'
insert into category_lang (category_id, title, lang_code) values 
(1, 'One',   'EN'),
(2, 'Two',   'EN'),
(3, 'Three', 'EN'),

(11, 'One.One',   'EN'),
(12, 'One.Two',   'EN'),
(13, 'One.Three', 'EN'),

(21, 'Two.One',   'EN'),
(22, 'Two.Two',   'EN'),
(31, 'Three.One', 'EN'),

(211, 'Two.One.One',   'EN'),
(212, 'Two.One.Two',   'EN'),
(213, 'Two.One.Three', 'EN');

-- lang_code = 'ES'
insert into category_lang (category_id, title, lang_code) values 
(1, 'Uno',  'ES'),
(2, 'Dos',  'ES'),
(3, 'Tres', 'ES'),

(11, 'Uno.Uno',  'ES'),
(12, 'Uno.Dos',  'ES'),
(13, 'Uno.Tres', 'ES'),

(21, 'Dos.Uno',  'ES'),
(22, 'Dos.Dos',  'ES'),
(31, 'Tres.Uno', 'ES'),

(211, 'Dos.Uno.Uno',  'ES'),
(212, 'Dos.Uno.Dos',  'ES'),
(213, 'Dos.Uno.Tres', 'ES');

一个简单的查询会产生这样的自然结果:

select * from category tc 
left outer join category_lang tl on tl.category_id = tc.id and tl.lang_code = 'EN';

id |parent_id|tree_depth|id|category_id|lang_code|title        |
---|---------|----------|--|-----------|---------|-------------|
  1|         |         0| 1|          1|EN       |One          |
  2|         |         0| 2|          2|EN       |Two          |
  3|         |         0| 3|          3|EN       |Three        |
 11|        1|         1| 4|         11|EN       |One.One      |
 12|        1|         1| 5|         12|EN       |One.Two      |
 13|        1|         1| 6|         13|EN       |One.Three    |
 21|        2|         1| 7|         21|EN       |Two.One      |
 22|        2|         1| 8|         22|EN       |Two.Two      |
 31|        3|         1| 9|         31|EN       |Three.One    |
211|       21|         2|10|        211|EN       |Two.One.One  |
212|       21|         2|11|        212|EN       |Two.One.Two  |
213|       21|         2|12|        213|EN       |Two.One.Three|

当预期顺序应符合树状层次结构和英文字母顺序(在每个深度级别)时,如下所示:
[编辑以修复 Erwin 发现的错误]

id |parent_id|tree_depth|id|category_id|lang_code|title        |
---|---------|----------|--|-----------|---------|-------------|
  1|         |         0| 1|          1|EN       |One          |
 11|        1|         1| 4|         11|EN       |One.One      |
 13|        1|         1| 6|         13|EN       |One.Three    |
 12|        1|         1| 5|         12|EN       |One.Two      |
  3|         |         0| 3|          3|EN       |Three        |
 31|        3|         1| 9|         31|EN       |Three.One    |
  2|         |         0| 2|          2|EN       |Two          |
 21|        2|         1| 7|         21|EN       |Two.One      |
211|       21|         2|10|        211|EN       |Two.One.One  |
213|       21|         2|12|        213|EN       |Two.One.Three|
212|       21|         2|11|        212|EN       |Two.One.Two  |
 22|        2|         1| 8|         22|EN       |Two.Two      |

请注意,每个深度的字母顺序都会对西班牙语产生不同的结果:
[编辑以修复 Erwin 发现的错误]

id |parent_id|tree_depth|id|category_id|lang_code|title       |
---|---------|----------|--|-----------|---------|------------|
  2|         |         0|14|          2|ES       |Dos         |
 22|        2|         1|20|         22|ES       |Dos.Dos     |
 21|        2|         1|19|         21|ES       |Dos.Uno     |
212|       21|         2|23|        212|ES       |Dos.Uno.Dos |
213|       21|         2|24|        213|ES       |Dos.Uno.Tres|
211|       21|         2|22|        211|ES       |Dos.Uno.Uno |
  1|         |         0|13|          1|ES       |Uno         |
 12|        1|         1|17|         12|ES       |Uno.Dos     |
 13|        1|         1|18|         13|ES       |Uno.Tres    |
 11|        1|         1|16|         11|ES       |Uno.Uno     |
  3|         |         0|15|          3|ES       |Tres        |
 31|        3|         1|21|         31|ES       |Tres.Uno    |

我尝试了很多方法,包括 https://www.postgresql.org/docs/12/queries-with.html 中的递归 CTE,但 none 似乎可以解决不同语言的不同顺序问题。

有什么想法吗?

... the expected order should be compliant with tree hierarchy and alphabetical order in English (at every depth level),

额外的困难category_lang(title, lang_code)没有定义UNIQUE,所以我们需要按title和[=16=排序](作为决胜局)在每个级别上 - 这对于动态数量的级别很难实现。 复合类型的数组可以解决这个难题。

您显示的结果目前不符合您的要求。根据英文排序规则,'Three' 应该排在 'Two' 之前。以下查询的结果实现了您的要求:

为每个数据库创建一次

CREATE TYPE title_id AS (title varchar(128), id int);

然后用一个递归的CTE根据路径生成这个复合类型的数组

WITH RECURSIVE tree AS (
   SELECT c.id AS cat_id, c.parent_id, c.tree_depth
        , l.id AS lang_id, l.title, l.lang_code
        , ARRAY[(l.title, l.category_id)::title_id] AS sort_arr
   FROM   category      c 
   JOIN   category_lang l ON l.category_id = c.id
                         AND l.lang_code = 'EN'
   WHERE  c.parent_id IS NULL  -- root cat

   UNION ALL
   SELECT c.id AS cat_id, c.parent_id, c.tree_depth
        , l.id AS lang_id, l.title, l.lang_code
        , sort_arr || (l.title, l.category_id)::title_id
   FROM   tree          t
   JOIN   category      c ON c.parent_id = t.cat_id
   JOIN   category_lang l ON l.category_id = c.id
                         AND l.lang_code = t.lang_code
    )
SELECT cat_id, parent_id, tree_depth, lang_id, title 
FROM   tree
ORDER  BY sort_arr;

db<>fiddle here

与更多解释和细节密切相关:

  • Retrieving full hierarchy sorted by a column under PostgreSQL's Ltree module

COLLATE?

但这还不是全部。简单的解决方案按数据库的默认排序规则排序,这似乎不适合不同的语言。

每种语言都有自己的 排序规则 规则,或者通常有多个规则,具体取决于世界区域和其他政治/文化规则。 “语言”不足以指定准确的排序规则。精确的 locale 很重要。 Postgres 使用 COLLATE 关键字实现排序规则感知排序。除了语言之外,您还必须存储实际的精确排序规则,并使用它来正确排序。

此外,索引取决于确切的 COLLATION。您可能会考虑使用不同排序规则的多个部分索引。许多棘手的事情超出了这个问题的范围。参见:

旁白

  • 您的触发器在并发写入之间的竞争条件下不安全。出于此查询的目的,我们根本不需要持久化的 tree_depth。我们可以在 rCTE 中轻松生成它。考虑删除列 tree_depth 和触发器。

  • table中似乎缺少 FK 约束 category_lang:

     , FOREIGN KEY (category_id) REFERENCES category (id)
    
  • 考虑 text 而不是 varchar(n)char(n)。参见:

  • 考虑 IDENTITY 列而不是 serial:

    • Auto increment table column

在 Erwin 用更简单的解决方案回答之前,我自己做了这个递归方法。插入一个虚拟 root 类别效果更好,它允许我们从单个入口点检索整个树。

create or replace function list_category_tree (
    _category_id integer,
    _lang_code char(2)
)
returns setof category
as $$
declare
    _child_category category;
begin
    -- return the passed category
    return query
    select * from category where id = _category_id;

    -- loop over the passed category children
    for _child_category in 
        select tc.* 
        from category tc
        join category_lang tl on tl.category_id = tc.id and tl.lang_code = _lang_code
        where tc.parent_id = _category_id
        order by tl.title asc
    loop
        -- recursively look for every children childrens
        return query 
        select * from list_category_tree(_child_category.id, _lang_code);
    end loop;
end;
$$ language plpgsql;

一个简单的测试可以是

select * 
from list_category_tree (0, 'EN') as tc
join category_lang tl on tl.category_id = tc.id and tl.lang_code = 'EN';

id |parent_id|tree_depth|id|category_id|lang_code|title        |
---|---------|----------|--|-----------|---------|-------------|
  1|         |         0| 1|          1|EN       |One          |
 11|        1|         1| 4|         11|EN       |One.One      |
 13|        1|         1| 6|         13|EN       |One.Three    |
 12|        1|         1| 5|         12|EN       |One.Two      |
  2|         |         0| 2|          2|EN       |Two          |
 21|        2|         1| 7|         21|EN       |Two.One      |
211|       21|         2|10|        211|EN       |Two.One.One  |
213|       21|         2|12|        213|EN       |Two.One.Three|
212|       21|         2|11|        212|EN       |Two.One.Two  |
 22|        2|         1| 8|         22|EN       |Two.Two      |
  3|         |         0| 3|          3|EN       |Three        |
 31|        3|         1| 9|         31|EN       |Three.One    |


select * 
from list_category_tree (0, 'ES') as tc
join of_category_lang tl on tl.category_id = tc.id and tl.lang_code = 'ES';

id |parent_id|tree_depth|id|category_id|lang_code|title       |
---|---------|----------|--|-----------|---------|------------|
  2|        0|         1|14|          2|ES       |Dos         |
 22|        2|         2|20|         22|ES       |Dos.Dos     |
 21|        2|         2|19|         21|ES       |Dos.Uno     |
212|       21|         3|23|        212|ES       |Dos.Uno.Dos |
213|       21|         3|24|        213|ES       |Dos.Uno.Tres|
211|       21|         3|22|        211|ES       |Dos.Uno.Uno |
  3|        0|         1|15|          3|ES       |Tres        |
 31|        3|         2|21|         31|ES       |Tres.Uno    |
  1|        0|         1|13|          1|ES       |Uno         |
 12|        1|         2|17|         12|ES       |Uno.Dos     |
 13|        1|         2|18|         13|ES       |Uno.Tres    |
 11|        1|         2|16|         11|ES       |Uno.Uno     |

已将根节点插入为

insert into of_category (parent_id, id) values 
(null, 0),

(null, 1),
(null, 2),
(null, 3),

(1, 11),
(1, 12),
(1, 13),

(2, 21),
(2, 22),
(3, 31),

(21, 211),
(21, 212),
(21, 213);

-- lang_code = 'EN'
insert into of_category_lang (category_id, title, lang_code) values 
(0, 'Root', 'EN'),

(1, 'One',   'EN'),
(2, 'Two',   'EN'),
(3, 'Three', 'EN'),

(11, 'One.One',   'EN'),
(12, 'One.Two',   'EN'),
(13, 'One.Three', 'EN'),

(21, 'Two.One',   'EN'),
(22, 'Two.Two',   'EN'),
(31, 'Three.One', 'EN'),

(211, 'Two.One.One',   'EN'),
(212, 'Two.One.Two',   'EN'),
(213, 'Two.One.Three', 'EN');

-- lang_code = 'ES'
insert into of_category_lang (category_id, title, lang_code) values 
(0, 'Raíz', 'ES'),

(1, 'Uno',  'ES'),
(2, 'Dos',  'ES'),
(3, 'Tres', 'ES'),

(11, 'Uno.Uno',  'ES'),
(12, 'Uno.Dos',  'ES'),
(13, 'Uno.Tres', 'ES'),

(21, 'Dos.Uno',  'ES'),
(22, 'Dos.Dos',  'ES'),
(31, 'Tres.Uno', 'ES'),

(211, 'Dos.Uno.Uno',  'ES'),
(212, 'Dos.Uno.Dos',  'ES'),
(213, 'Dos.Uno.Tres', 'ES');