Classification des caractères avec ICU
La version 15 de PostgreSQL a rendu possible les collations ICU
au niveau de la base (options locale_provider='icu' icu_locale='...'
de CREATE DATABASE
), et au niveau du cluster (initdb --locale-provider=icu --icu-locale=...
)
Avec PostgreSQL 16 actuellement en version beta1, ICU devient le fournisseur par défaut, c’est-à-dire que sans indiquer de fournisseur de collation, c’est ICU qui va être privilégié. (Mise à jour: la beta2 est revenue en arrière sur ce choix: libc est le fournisseur de collation par défaut, comme dans la 15 et les versions précédentes.).
Quelles sont les conséquences de passer à ICU? Quand on parle
de collations ICU, on pense généralement aux tris (la
clause ORDER BY
) susceptibles de produire des résultats différents.
Mais elles ont aussi une influence parfois ignorée sur les fonctions qui
utilisent la classification des caractères, notamment
les expressions régulières et la mise en majuscule et minuscules.
Sur la majorité des chaînes de caractères, les résultats entre les collations libc et ICU sont les mêmes. Mais voyons deux exemples où les résultats diffèrent, non seulement entre libc et ICU sur Linux mais entre les libc de différents systèmes d’exploitation: 1
Reconnaissance des chiffres
Traditionnellement dans les expressions régulières on utilise pour reconnaître les chiffres
[0-9]
\d
[[::digit::]]
(la classe de caractères)
Avec la libc
de Linux (GNU libc), ça revient au même, car seuls les caractères ASCII de
base '0123456789'
(codes entre 0x30 et 0x39) sont reconnus par ces sélecteurs. On peut le
vérifier avec la requête suivante, qui teste tous les points de code
valides du répertoire Unicode:
SELECT
to_hex(cp),
chr(cp)
FROM
-- blocs de 0 à 0xd7ff et 0xf900-0xeffff
(select * from generate_series(1, 55295) cp
union all
select * from generate_series(63744, 983039) cp) AS s
WHERE
chr(cp) COLLATE "fr_FR.utf8" ~ '\d';
Résultat:
to_hex | chr
--------+-----
30 | 0
31 | 1
32 | 2
33 | 3
34 | 4
35 | 5
36 | 6
37 | 7
38 | 8
39 | 9
(10 lignes)
Mais si on remplace la collation "fr_FR.utf8"
par la collation ICU
"fr-x-icu"
, surprise, pas moins de 660 points de code différents
correspondent.
Voici un extrait des résultats:
to_hex | chr
--------+-----
30 | 0
31 | 1
32 | 2
33 | 3
34 | 4
35 | 5
36 | 6
37 | 7
38 | 8
39 | 9
660 | ٠
661 | ١
662 | ٢
663 | ٣
664 | ٤
...
ff10 | 0
ff11 | 1
ff12 | 2
ff13 | 3
ff14 | 4
ff15 | 5
ff16 | 6
ff17 | 7
ff18 | 8
ff19 | 9
104a0 | 𐒠
104a1 | 𐒡
104a2 | 𐒢
104a3 | 𐒣
104a4 | 𐒤
104a5 | 𐒥
104a6 | 𐒦
104a7 | 𐒧
104a8 | 𐒨
104a9 | 𐒩
...
1d7ce | 𝟎
1d7cf | 𝟏
1d7d0 | 𝟐
1d7d1 | 𝟑
1d7d2 | 𝟒
1d7d3 | 𝟓
1d7d4 | 𝟔
On voit qu’on a d’une part des chiffres hors alphabet latin, dans des blocs Unicode comme Tamil, Telugu, Thai, etc. ainsi que des variantes calligraphiques des chiffres en alphabet latin:
- FULLWIDTH DIGIT ZERO à NINE: 0123456789
- MATHEMATICAL BOLD DIGIT ZERO à NINE: 𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗
- MATHEMATICAL DOUBLE-STRUCK DIGIT ZERO à NINE: 𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡
- MATHEMATICAL SANS-SERIF DIGIT ZERO à NINE: 𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫
- MATHEMATICAL SANS-SERIF BOLD DIGIT ZERO à NINE: 𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵
- MATHEMATICAL MONOSPACE DIGIT ZERO à NINE: 𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿
La conséquence est que si dans une base de données on utilisait \d
ou [[:digit:]]
pour filtrer les chiffres, passer à ICU laissera passer beaucoup plus de caractères.
Suivant les applications, cela peut être une bonne chose ou non.
Si on ne veut pas de ces caractères en tant que chiffres, on
peut régler le problème en remplaçant \d
et [[:digit:]]
par
[0-9]
. Mais le vrai problème ne sera peut-être pas tant de
faire le changement que de savoir qu’il est nécessaire…
Mise à jour: comme on me l’a fait remarquer sur Twitter 2, d’autres libc commme celle de FreeBSD donnent des résultats très différents de Linux. Pour illustrer cela, voici le tableau détaillé des résultats avec ICU et les libc de trois systèmes distincts.
Codepoints | Character names | ICU | glibc | FreeBSD | Win10 |
---|---|---|---|---|---|
00030→00039 | DIGIT ZERO → NINE | ✓ | ✓ | ✓ | ✓ |
000b2 | SUPERSCRIPT TWO | ✓ | |||
000b3 | SUPERSCRIPT THREE | ✓ | |||
000b9 | SUPERSCRIPT ONE | ✓ | |||
00660→00669 | ARABIC-INDIC DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
006f0→006f9 | EXTENDED ARABIC-INDIC DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
007c0→007c9 | NKO DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00966→0096f | DEVANAGARI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
009e6→009ef | BENGALI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00a66→00a6f | GURMUKHI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00ae6→00aef | GUJARATI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00b66→00b6f | ORIYA DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00be6→00bef | TAMIL DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00c66→00c6f | TELUGU DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00ce6→00cef | KANNADA DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00d66→00d6f | MALAYALAM DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00de6→00def | SINHALA LITH DIGIT ZERO → NINE | ✓ | ✓ | ||
00e50→00e59 | THAI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00ed0→00ed9 | LAO DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
00f20→00f29 | TIBETAN DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
01040→01049 | MYANMAR DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
01090→01099 | MYANMAR SHAN DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
017e0→017e9 | KHMER DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
01810→01819 | MONGOLIAN DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
01946→0194f | LIMBU DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
019d0→019d9 | NEW TAI LUE DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
01a80→01a89 | TAI THAM HORA DIGIT ZERO → NINE | ✓ | ✓ | ||
01a90→01a99 | TAI THAM THAM DIGIT ZERO → NINE | ✓ | ✓ | ||
01b50→01b59 | BALINESE DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
1bb0→1bb9 | SUNDANESE DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
1c40→1c49 | LEPCHA DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
1c50→1c59 | OL CHIKI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
a620→a629 | VAI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
a8d0→a8d9 | SAURASHTRA DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
a900→a909 | KAYAH LI DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
a9d0→a9d9 | JAVANESE DIGIT ZERO → NINE | ✓ | ✓ | ||
a9f0→a9f9 | MYANMAR TAI LAING DIGIT ZERO → NINE | ✓ | ✓ | ||
aa50→aa59 | CHAM DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
abf0→abf9 | MEETEI MAYEK DIGIT ZERO → NINE | ✓ | ✓ | ||
ff10→ff19 | FULLWIDTH DIGIT ZERO → NINE | ✓ | ✓ | ✓ | |
104a0→104a9 | OSMANYA DIGIT ZERO → NINE | ✓ | ✓ | ||
10d30→10d39 | HANIFI ROHINGYA DIGIT ZERO → NINE | ✓ | ✓ | ||
11066→1106f | BRAHMI DIGIT ZERO → NINE | ✓ | ✓ | ||
110f0→110f9 | SORA SOMPENG DIGIT ZERO → NINE | ✓ | ✓ | ||
11136→1113f | CHAKMA DIGIT ZERO → NINE | ✓ | ✓ | ||
111d0→111d9 | SHARADA DIGIT ZERO → NINE | ✓ | ✓ | ||
112f0→112f9 | KHUDAWADI DIGIT ZERO → NINE | ✓ | ✓ | ||
11450→11459 | NEWA DIGIT ZERO → NINE | ✓ | ✓ | ||
114d0→114d9 | TIRHUTA DIGIT ZERO → NINE | ✓ | ✓ | ||
11650→11659 | MODI DIGIT ZERO → NINE | ✓ | ✓ | ||
116c0→116c9 | TAKRI DIGIT ZERO → NINE | ✓ | ✓ | ||
11730→11739 | AHOM DIGIT ZERO → NINE | ✓ | ✓ | ||
118e0→118e9 | WARANG CITI DIGIT ZERO → NINE | ✓ | ✓ | ||
11950→11959 | DIVES AKURU DIGIT ZERO → NINE | ✓ | |||
11c50→11c59 | BHAIKSUKI DIGIT ZERO → NINE | ✓ | ✓ | ||
11d50→11d59 | MASARAM GONDI DIGIT ZERO → NINE | ✓ | ✓ | ||
11da0→11da9 | GUNJALA GONDI DIGIT ZERO → NINE | ✓ | ✓ | ||
16a60→16a69 | MRO DIGIT ZERO → NINE | ✓ | ✓ | ||
16ac0→16ac9 | TANGSA DIGIT ZERO → NINE | ✓ | |||
16b50→16b59 | PAHAWH HMONG DIGIT ZERO → NINE | ✓ | ✓ | ||
1e140→1e149 | NYIAKENG PUACHUE HMONG DIGIT ZERO → NINE | ✓ | |||
1e2f0→1e2f9 | WANCHO DIGIT ZERO → NINE | ✓ | |||
1e950→1e959 | ADLAM DIGIT ZERO → NINE | ✓ | ✓ | ||
1d7ce→1d7d7 | MATHEMATICAL BOLD DIGIT ZERO → NINE | ✓ | ✓ | ||
1d7d8→1d7e1 | MATHEMATICAL DOUBLE-STRUCK DIGIT ZERO → NINE | ✓ | ✓ | ||
1d7e2→1d7eb | MATHEMATICAL SANS-SERIF DIGIT ZERO → NINE | ✓ | ✓ | ||
1d7ec→1d7f5 | MATHEMATICAL SANS-SERIF BOLD DIGIT ZERO → NINE | ✓ | ✓ | ||
1d7f6→1d7ff | MATHEMATICAL MONOSPACE DIGIT ZERO → NINE | ✓ | ✓ | ||
1fbf0→1fbf9 | SEGMENTED DIGIT ZERO → NINE | ✓ |
Reconnaissance des espaces
Pour les caractères d’espacement il est pratique d’utiliser dans les expressions régulières:
\s
[[::space::]]
(la classe de caractères)
A nouveau il y a des différences entre libc et ICU, montrées par la requête suivante,
qui teste tous les points de code Unicode avec les deux fournisseurs (dans
la requête la fonction icu_char_name()
vient de l’extension icu_ext).
SELECT
lpad(to_hex(cp), 4, '0') as code,
icu_char_name(chr(cp)),
case when chr(cp) collate "fr_FR.utf8" ~ '\s' then E'\u2713' end as "LIBC",
case when chr(cp) collate "fr-x-icu" ~ '\s' then E'\u2713' end as "ICU"
FROM (select * from generate_series(1, 55295) cp
union all
select * from generate_series(63744,1000000) cp) AS s
WHERE chr(cp) collate "fr-x-icu" ~ '\s'
OR chr(cp) collate "fr_FR.utf8" ~ '\s'
;
Résultats sur différents systèmes 1:
Codepoint | Character name | ICU | glibc | FreeBSD | Win10 |
---|---|---|---|---|---|
0009 | <control-0009> | ✓ | ✓ | ✓ | ✓ |
000a | <control-000A> | ✓ | ✓ | ✓ | ✓ |
000b | <control-000B> | ✓ | ✓ | ✓ | ✓ |
000c | <control-000C> | ✓ | ✓ | ✓ | ✓ |
000d | <control-000D> | ✓ | ✓ | ✓ | ✓ |
001c | <control-001C> | ✓ | |||
001d | <control-001D> | ✓ | |||
001e | <control-001E> | ✓ | |||
001f | <control-001F> | ✓ | |||
0020 | SPACE | ✓ | ✓ | ✓ | ✓ |
0085 | <control-0085> | ✓ | ✓ | ||
00a0 | NO-BREAK SPACE | ✓ | ✓ | ✓ | |
1680 | OGHAM SPACE MARK | ✓ | ✓ | ✓ | ✓ |
180e | MONGOLIAN VOWEL SEPARATOR | ✓ | |||
2000 | EN QUAD | ✓ | ✓ | ✓ | ✓ |
2001 | EM QUAD | ✓ | ✓ | ✓ | ✓ |
2002 | EN SPACE | ✓ | ✓ | ✓ | ✓ |
2003 | EM SPACE | ✓ | ✓ | ✓ | ✓ |
2004 | THREE-PER-EM SPACE | ✓ | ✓ | ✓ | ✓ |
2005 | FOUR-PER-EM SPACE | ✓ | ✓ | ✓ | ✓ |
2006 | SIX-PER-EM SPACE | ✓ | ✓ | ✓ | ✓ |
2007 | FIGURE SPACE | ✓ | ✓ | ✓ | |
2008 | PUNCTUATION SPACE | ✓ | ✓ | ✓ | ✓ |
2009 | THIN SPACE | ✓ | ✓ | ✓ | ✓ |
200a | HAIR SPACE | ✓ | ✓ | ✓ | ✓ |
2028 | LINE SEPARATOR | ✓ | ✓ | ✓ | ✓ |
2029 | PARAGRAPH SEPARATOR | ✓ | ✓ | ✓ | ✓ |
202f | NARROW NO-BREAK SPACE | ✓ | ✓ | ✓ | |
205f | MEDIUM MATHEMATICAL SPACE | ✓ | ✓ | ✓ | ✓ |
3000 | IDEOGRAPHIC SPACE | ✓ | ✓ | ✓ | ✓ |
Conclusion: en-dehors de la tabulation (code 9) et de l’espace ASCII (code 0x20)
qui sont très connus, il y a 28 autres points de codes qui sont reconnus
en tant qu’espace suivant les systèmes. Sur ceux-là, une partie est reconnue par
les collations ICU et non par glibc, notamment le NO-BREAK-SPACE
(0xa0) qui correspond à l’entité
du HTML, qui peut être
présente dans beaucoup de textes. Le passage à ICU pourra donc
occasionner avec ces caractères des différences dans l’extraction via
expressions régulières (regexp_match[es]
) de termes séparés par des
espaces.
Mise en majuscules
Les fonctions upper()
passent aussi par le fournisseur de collation
pour obtenir un résultat. Pour libc, chaque caractère donnera un seul
caractère en sortie, ne serait-ce que parce que l’API C est conçue
comme cela. Côté ICU en revanche, des exceptions existent. Par exemple,
pour les langues européennes:
- le caractère
ß
(Eszett) est mis en majuscules sous la formeSS
. - le caractère
fi
(Ligature minuscule latine fi) est mis en majuscules enFI
.
Illustration par la requête suivante:
WITH words(w) as (values('muß'),('final'))
SELECT
w as mot,
length(w) as longueur,
upper(w collate "fr_FR.utf8") as "en majuscules (libc)",
length(upper(w collate "fr_FR.utf8")),
upper(w collate "en-x-icu") as "en majuscules (ICU)",
length(upper(w collate "en-x-icu"))
FROM words;
Résultat:
mot | longueur | en majuscules (libc) | length | en majuscules (ICU) | length
------+----------+----------------------+--------+---------------------+--------
muß | 3 | MUß | 3 | MUSS | 4
final | 4 | fiNAL | 4 | FINAL | 5
En fait libc laisse les caractères minuscules ß
et fi
inchangés alors qu’ICU
les convertit en deux caractères majuscules.
PostgreSQL et Unicode, un tour des nouveautés
Vous voulez en savoir plus sur les nouveautés Unicode et les collations ICU depuis PostgreSQL 10? Je ferai une présentation sur ce thème le lundi 19 juin au PgDay 2023 à Strasbourg.