Skip to content

Commit 2763bc2

Browse files
terry1purcellclaude
andcommitted
collate: add a latin1_swedish_ci collator
Implement latin1_swedish_ci as a byte-oriented collator. MySQL defines it as a 256-entry weight table indexed by the cp1252 byte value and drives it through my_collation_8bit_simple_ci_handler with strxfrm_multiply 1, and TiDB's latin1 charset stores raw client bytes (charset.EncodingLatin1Impl uses encoding.Nop with a no-op Transform), so staying byte-oriented matches both MySQL and the latin1_bin collator already available on the same column. One weight byte per input byte also means the sort key never outgrows the raw data. The weight table is transcribed from sort_order_latin1 in strings/ctype-latin1.cc and was diffed entry by entry against that source. Note that ß (0xDF) folds to nothing here and so equals neither "s" nor "ss"; the expansion belongs to latin1_german2_ci, which uses the separate sort_order_latin1_de table. stringutil gains DoMatchCustomizedBinary, the byte-oriented counterpart of DoMatchCustomized, for the LIKE path: DoMatchBinary only offers exact byte equality, which cannot express a case-insensitive match. The collation is registered so it can be exercised end to end, but it is kept out of GetSupportedCollations for now, following the utf8mb4_zh_pinyin_tidb_as_cs precedent. TiKV and TiFlash have no latin1_swedish_ci collator, so they cannot reproduce the sort key that TiDB writes into indexes, and nothing yet prevents a predicate on such a column from being pushed down. That has to be resolved before the collation is offered to users. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgAxo4cK4qCpuLrhroHWy1
1 parent a17d9ca commit 2763bc2

5 files changed

Lines changed: 262 additions & 2 deletions

File tree

pkg/util/collate/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ go_library(
1212
"gbk_chinese_ci.go",
1313
"gbk_chinese_ci_data.go",
1414
"general_ci.go",
15+
"latin1_swedish_ci.go",
1516
"pinyin_tidb_as_cs.go",
1617
"unicode_0400_ci_generated.go",
1718
"unicode_0400_ci_impl.go",

pkg/util/collate/collate.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,9 @@ func GetSupportedCollations() []*charset.Collation {
251251
newSupportedCollations := make([]*charset.Collation, 0, len(newCollatorMap))
252252
for name := range newCollatorMap {
253253
// utf8mb4_zh_pinyin_tidb_as_cs is under developing, should not be shown to user.
254-
if name == "utf8mb4_zh_pinyin_tidb_as_cs" {
254+
// latin1_swedish_ci is under developing too: its sort key has no counterpart in
255+
// TiKV/TiFlash yet, so pushed-down comparisons cannot reproduce it.
256+
if name == "utf8mb4_zh_pinyin_tidb_as_cs" || name == "latin1_swedish_ci" {
255257
continue
256258
}
257259
if coll, err := charset.GetCollationByName(name); err != nil {
@@ -311,7 +313,8 @@ func IsDefaultCollationForUTF8MB4(collate string) bool {
311313
func IsCICollation(collate string) bool {
312314
return collate == "utf8_general_ci" || collate == "utf8mb4_general_ci" ||
313315
collate == "utf8_unicode_ci" || collate == "utf8mb4_unicode_ci" || collate == "gbk_chinese_ci" ||
314-
collate == "utf8mb4_0900_ai_ci" || collate == "gb18030_chinese_ci"
316+
collate == "utf8mb4_0900_ai_ci" || collate == "gb18030_chinese_ci" ||
317+
collate == "latin1_swedish_ci"
315318
}
316319

317320
// ConvertAndGetBinCollation converts collation to binary collation
@@ -331,6 +334,8 @@ func ConvertAndGetBinCollation(collate string) string {
331334
return "gbk_bin"
332335
case "gb18030_chinese_ci":
333336
return "gb18030_bin"
337+
case "latin1_swedish_ci":
338+
return "latin1_bin"
334339
}
335340

336341
return collate
@@ -449,6 +454,8 @@ func init() {
449454
newCollatorIDMap[CollationName2ID("ascii_bin")] = &binPaddingCollator{}
450455
newCollatorMap["latin1_bin"] = &binPaddingCollator{}
451456
newCollatorIDMap[CollationName2ID("latin1_bin")] = &binPaddingCollator{}
457+
newCollatorMap["latin1_swedish_ci"] = &latin1SwedishCICollator{}
458+
newCollatorIDMap[CollationName2ID("latin1_swedish_ci")] = &latin1SwedishCICollator{}
452459
newCollatorMap["utf8mb4_bin"] = &binPaddingCollator{}
453460
newCollatorIDMap[CollationName2ID("utf8mb4_bin")] = &binPaddingCollator{}
454461
newCollatorMap["utf8_bin"] = &binPaddingCollator{}

pkg/util/collate/collate_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,100 @@ func TestUTF8CollatorKey(t *testing.T) {
145145
testKeyTable(t, collations, tests)
146146
}
147147

148+
// The latin1 collators are byte-oriented, so the test data below spells the
149+
// interesting characters as explicit cp1252 bytes ("\xC4" for Ä) rather than as Go
150+
// source literals, which the compiler would encode as UTF-8.
151+
func TestLatin1CollatorCompare(t *testing.T) {
152+
SetNewCollationEnabledForTest(true)
153+
defer SetNewCollationEnabledForTest(false)
154+
collations := []string{"latin1_bin", "latin1_swedish_ci"}
155+
tests := []compareTable{
156+
{"a", "b", []int{-1, -1}},
157+
{"a", "A", []int{1, 0}},
158+
{"abc", "abc", []int{0, 0}},
159+
{"abc", "ab", []int{1, 1}},
160+
// Both latin1 collations are PAD SPACE.
161+
{"a", "a ", []int{0, 0}},
162+
{"a\t", "a", []int{1, 1}},
163+
{"\xE9", "E", []int{1, 0}}, // é folds to E
164+
{"\xE0\xE9", "AE", []int{1, 0}},
165+
// Swedish letters sort after Z in the order Å, Ä, Ö instead of folding to A and O.
166+
{"\xC5", "A", []int{1, 1}}, // Å
167+
{"\xC5", "Z", []int{1, 1}},
168+
{"\xC4", "\xC5", []int{-1, 1}}, // Ä after Å, opposite of the byte order
169+
{"\xD6", "\xC4", []int{1, 1}}, // Ö after Ä
170+
{"\xC6", "\xC4", []int{1, 0}}, // Æ collates as Ä
171+
// MySQL quirks worth pinning down: Ü and Ý fold to Y but ÿ does not; ß folds to
172+
// nothing, so it is equal to neither "s" nor "ss" (that is latin1_german2_ci);
173+
// and Ø/ø fold to each other but not to O.
174+
{"\xDC", "Y", []int{1, 0}},
175+
{"\xDD", "y", []int{1, 0}},
176+
{"\xFF", "Y", []int{1, 1}},
177+
{"\xDF", "s", []int{1, 1}},
178+
{"\xDF", "ss", []int{1, 1}},
179+
{"\xDF", "\xDF", []int{0, 0}},
180+
{"\xF8", "\xD8", []int{1, 0}},
181+
{"\xD8", "O", []int{1, 1}},
182+
// Bytes without a letter weight keep their own value, so they still compare
183+
// equal to themselves.
184+
{"\x80", "\x80", []int{0, 0}},
185+
// UTF-8 data stored in a latin1 column is weighted byte by byte: "Ä" encoded as
186+
// UTF-8 is 0xC3 0x84, whose leading byte folds to A. This documents the known
187+
// limitation rather than endorsing it.
188+
{"\xC3\x84", "a\x84", []int{1, 0}},
189+
}
190+
testCompareTable(t, collations, tests)
191+
}
192+
193+
func TestLatin1CollatorKey(t *testing.T) {
194+
SetNewCollationEnabledForTest(true)
195+
defer SetNewCollationEnabledForTest(false)
196+
collations := []string{"latin1_bin", "latin1_swedish_ci"}
197+
tests := []keyTable{
198+
{"a", [][]byte{{0x61}, {0x41}}},
199+
{"A", [][]byte{{0x41}, {0x41}}},
200+
{"a ", [][]byte{{0x61}, {0x41}}},
201+
{"Hello", [][]byte{{0x48, 0x65, 0x6C, 0x6C, 0x6F}, {0x48, 0x45, 0x4C, 0x4C, 0x4F}}},
202+
{"\xC4\xC5\xD6", [][]byte{{0xC4, 0xC5, 0xD6}, {0x5C, 0x5B, 0x5D}}},
203+
{"\xDF", [][]byte{{0xDF}, {0xDF}}},
204+
{"\xFF\xDD", [][]byte{{0xFF, 0xDD}, {0xFF, 0x59}}},
205+
{"", [][]byte{{}, {}}},
206+
}
207+
testKeyTable(t, collations, tests)
208+
209+
// One weight byte per input byte, so the sort key never outgrows the raw data.
210+
collator := GetCollator("latin1_swedish_ci")
211+
for _, test := range tests {
212+
require.LessOrEqual(t, len(collator.Key(test.Str)), collator.MaxKeyLen(test.Str), test.Str)
213+
}
214+
}
215+
216+
func TestLatin1SwedishCIPattern(t *testing.T) {
217+
SetNewCollationEnabledForTest(true)
218+
defer SetNewCollationEnabledForTest(false)
219+
tests := []struct {
220+
pattern string
221+
escape byte
222+
str string
223+
match bool
224+
}{
225+
{"a%", '\\', "ABC", true},
226+
{"A_C", '\\', "abc", true},
227+
{"A_C", '\\', "abcd", false},
228+
{"%\xC4%", '\\', "x\xE4y", true}, // Ä matches ä
229+
{"%\xC4%", '\\', "x\xC5y", false}, // but not Å
230+
{"\xDC", '\\', "y", true}, // Ü matches y, same as Compare
231+
{"a\\%b", '\\', "A%B", true}, // the escaped wildcard is still a literal
232+
{"a\\%b", '\\', "AxB", false},
233+
}
234+
for _, test := range tests {
235+
pattern := GetCollator("latin1_swedish_ci").Pattern()
236+
pattern.Compile(test.pattern, test.escape)
237+
require.Equal(t, test.match, pattern.DoMatch(test.str),
238+
fmt.Sprintf("pattern %q, str %q", test.pattern, test.str))
239+
}
240+
}
241+
148242
func TestSetNewCollateEnabled(t *testing.T) {
149243
defer SetNewCollationEnabledForTest(false)
150244

@@ -189,6 +283,8 @@ func TestGetCollator(t *testing.T) {
189283
require.IsType(t, &unicodeCICollator{}, GetCollatorByID(192))
190284
require.IsType(t, &unicode0900AICICollator{}, GetCollatorByID(255))
191285
require.IsType(t, &zhPinyinTiDBASCSCollator{}, GetCollatorByID(2048))
286+
require.IsType(t, &latin1SwedishCICollator{}, GetCollator("latin1_swedish_ci"))
287+
require.IsType(t, &latin1SwedishCICollator{}, GetCollatorByID(8))
192288
require.IsType(t, &binPaddingCollator{}, GetCollatorByID(9999))
193289

194290
SetNewCollationEnabledForTest(false)
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2026 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package collate
16+
17+
import (
18+
"cmp"
19+
20+
"github.com/pingcap/tidb/pkg/util/stringutil"
21+
)
22+
23+
// latin1SwedishCICollator is the collator for latin1_swedish_ci.
24+
//
25+
// Unlike the utf8mb4 collators this one is byte-oriented rather than rune-oriented.
26+
// MySQL defines latin1_swedish_ci as a 256-entry weight table indexed by the cp1252
27+
// byte value (strings/ctype-latin1.c, sort_order_latin1), and TiDB's latin1 charset
28+
// stores whatever bytes the client sent: charset.EncodingLatin1Impl uses encoding.Nop
29+
// and a no-op Transform. Staying byte-oriented therefore matches both MySQL and the
30+
// existing latin1_bin collator for the same column.
31+
//
32+
// The consequence is that a multi-byte UTF-8 sequence stored in a latin1 column is
33+
// weighted byte by byte rather than as one character. That is the same treatment
34+
// latin1_bin already gives it, so it is not a new divergence, but it does mean the
35+
// collation is only MySQL-faithful for genuinely cp1252-encoded data.
36+
//
37+
// Because every input byte maps to exactly one weight byte, the sort key has the same
38+
// length as the (space-trimmed) input and index key sizes are unchanged relative to
39+
// latin1_bin. The sort key is not the raw data though, so latin1_swedish_ci columns
40+
// need restored data in indexes - see types.NeedRestoredDataWithCollate.
41+
type latin1SwedishCICollator struct{}
42+
43+
// Compare implements Collator interface.
44+
func (*latin1SwedishCICollator) Compare(a, b string) int {
45+
a = truncateTailingSpace(a)
46+
b = truncateTailingSpace(b)
47+
for i := 0; i < len(a) && i < len(b); i++ {
48+
if c := cmp.Compare(latin1SwedishCISortOrder[a[i]], latin1SwedishCISortOrder[b[i]]); c != 0 {
49+
return c
50+
}
51+
}
52+
return cmp.Compare(len(a), len(b))
53+
}
54+
55+
// Key implements Collator interface.
56+
func (c *latin1SwedishCICollator) Key(str string) []byte {
57+
return c.KeyWithoutTrimRightSpace(truncateTailingSpace(str))
58+
}
59+
60+
// ImmutableKey implements Collator interface.
61+
func (c *latin1SwedishCICollator) ImmutableKey(str string) []byte {
62+
return c.KeyWithoutTrimRightSpace(truncateTailingSpace(str))
63+
}
64+
65+
// KeyWithoutTrimRightSpace implements Collator interface.
66+
func (*latin1SwedishCICollator) KeyWithoutTrimRightSpace(str string) []byte {
67+
buf := make([]byte, 0, len(str))
68+
for i := range len(str) {
69+
buf = append(buf, latin1SwedishCISortOrder[str[i]])
70+
}
71+
return buf
72+
}
73+
74+
// MaxKeyLen implements Collator interface.
75+
func (*latin1SwedishCICollator) MaxKeyLen(s string) int {
76+
return len(s)
77+
}
78+
79+
// Pattern implements Collator interface.
80+
func (*latin1SwedishCICollator) Pattern() WildcardPattern {
81+
return &latin1SwedishCIPattern{}
82+
}
83+
84+
// Clone implements Collator interface.
85+
func (*latin1SwedishCICollator) Clone() Collator {
86+
return new(latin1SwedishCICollator)
87+
}
88+
89+
type latin1SwedishCIPattern struct {
90+
patChars []byte
91+
patTypes []byte
92+
}
93+
94+
// Compile implements WildcardPattern interface.
95+
func (p *latin1SwedishCIPattern) Compile(patternStr string, escape byte) {
96+
p.patChars, p.patTypes = stringutil.CompilePatternBinary(patternStr, escape)
97+
}
98+
99+
// DoMatch implements WildcardPattern interface.
100+
func (p *latin1SwedishCIPattern) DoMatch(str string) bool {
101+
return stringutil.DoMatchCustomizedBinary(str, p.patChars, p.patTypes, func(a, b byte) bool {
102+
return latin1SwedishCISortOrder[a] == latin1SwedishCISortOrder[b]
103+
})
104+
}
105+
106+
// latin1SwedishCISortOrder is MySQL's sort_order_latin1, transcribed verbatim from
107+
// strings/ctype-latin1.cc in mysql-server. That is the table my_charset_latin1
108+
// (collation id 8, latin1_swedish_ci) hands to my_collation_8bit_simple_ci_handler.
109+
//
110+
// The entries carrying the actual Swedish semantics, and the ones to check first if a
111+
// comparison looks wrong: Å (0xC5) -> 0x5B, Ä/Æ (0xC4/0xC6) -> 0x5C, Ö (0xD6) -> 0x5D.
112+
// These sit just above 'Z' (0x5A), so the Swedish letters sort after the ASCII
113+
// alphabet rather than folding into A and O.
114+
//
115+
// Several accented characters deliberately do not fold to a base letter, which is
116+
// easy to get wrong by reasoning from Unicode case folding instead of from this table:
117+
// Ü (0xDC) and Ý (0xDD) both fold to 'Y', but ÿ (0xFF) keeps weight 0xFF; ß (0xDF)
118+
// keeps weight 0xDF and is therefore NOT equal to 's' or "ss" (that expansion belongs
119+
// to latin1_german2_ci, which uses the separate sort_order_latin1_de table); and
120+
// Þ/þ and Ø/ø fold to each other but to no letter.
121+
var latin1SwedishCISortOrder = [256]byte{
122+
// 0x00 - 0x3F: control characters, punctuation and digits keep their own value.
123+
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
124+
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
125+
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F,
126+
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F,
127+
// 0x40 - 0x5F: uppercase ASCII, unchanged.
128+
0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
129+
0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F,
130+
// 0x60 - 0x7F: lowercase ASCII folds onto uppercase.
131+
0x60, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
132+
0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F,
133+
// 0x80 - 0xBF: cp1252 extras and symbols keep their own value.
134+
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F,
135+
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F,
136+
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF,
137+
0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF,
138+
// 0xC0 - 0xDF: accented uppercase letters.
139+
// À Á Â Ã fold to A; Ä Å Æ take the Swedish weights; Ç -> C; È..Ë -> E; Ì..Ï -> I.
140+
0x41, 0x41, 0x41, 0x41, 0x5C, 0x5B, 0x5C, 0x43, 0x45, 0x45, 0x45, 0x45, 0x49, 0x49, 0x49, 0x49,
141+
// Ð -> D; Ñ -> N; Ò..Õ -> O; Ö -> 0x5D; × and Ø keep their own value;
142+
// Ù..Û -> U; Ü and Ý -> Y; Þ and ß keep their own value.
143+
0x44, 0x4E, 0x4F, 0x4F, 0x4F, 0x4F, 0x5D, 0xD7, 0xD8, 0x55, 0x55, 0x55, 0x59, 0x59, 0xDE, 0xDF,
144+
// 0xE0 - 0xFF: accented lowercase letters, mirroring 0xC0 - 0xDF except for
145+
// ÷ (0xF7) and ÿ (0xFF), which keep their own value.
146+
0x41, 0x41, 0x41, 0x41, 0x5C, 0x5B, 0x5C, 0x43, 0x45, 0x45, 0x45, 0x45, 0x49, 0x49, 0x49, 0x49,
147+
0x44, 0x4E, 0x4F, 0x4F, 0x4F, 0x4F, 0x5D, 0xF7, 0xD8, 0x55, 0x55, 0x55, 0x59, 0x59, 0xDE, 0xFF,
148+
}

pkg/util/stringutil/string_util.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,14 @@ func DoMatchBinary(str string, patChars, patTypes []byte) bool {
286286
return doMatchInner(lenPatWeights, lenBytes, patTypes, func(a, b int) bool { return bytes[a] == patChars[b] })
287287
}
288288

289+
// DoMatchCustomizedBinary is the byte-oriented counterpart of `DoMatchCustomized`:
290+
// it matches `str` one byte at a time using a caller-supplied comparator. It exists
291+
// for collations whose weights are defined per byte rather than per rune, such as
292+
// latin1_swedish_ci.
293+
func DoMatchCustomizedBinary(str string, patWeights, patTypes []byte, matcher func(a, b byte) bool) bool {
294+
return doMatchInner(len(patWeights), len(str), patTypes, func(a, b int) bool { return matcher(str[a], patWeights[b]) })
295+
}
296+
289297
// DoMatch is an adapter for `DoMatchCustomized`, `str` can be any unicode string.
290298
func DoMatch(str string, patChars []rune, patTypes []byte) bool {
291299
return DoMatchCustomized(str, patChars, patTypes, matchRune)

0 commit comments

Comments
 (0)