Skip to content

Commit 6011334

Browse files
kthistlewoodclaude
andcommitted
fix(db): pin explicit collation in virtual_collections view for MariaDB 11.6+
On MariaDB 11.6+ the default character_set_collations maps utf8mb4 expressions to uca1400_ai_ci, which clashes with the view's general_ci string literals and JSON_TABLE columns: every /api/collections/virtual request fails with 'Illegal mix of collations ... for operation ='. Recreate the view (new migration, 0095 pattern) with an explicit COLLATE utf8mb4_general_ci on each string output column so comparisons resolve deterministically regardless of server collation defaults. PostgreSQL is unaffected (no-op). Fixes #3758 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 96abd8b commit 6011334

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Pin explicit collation in virtual_collections view (MariaDB 11.6+ compatibility)
2+
3+
On MariaDB 11.6+ the default ``character_set_collations`` maps utf8mb4
4+
expressions to ``utf8mb4_uca1400_ai_ci``, while the view's string literals and
5+
JSON_TABLE columns resolve under the connection collation the view was created
6+
with (commonly ``utf8mb4_general_ci``). Mixing the two in comparisons raises
7+
``Illegal mix of collations (...) for operation '='`` and every
8+
``/api/collections/virtual`` request fails with HTTP 500 (issue #3758).
9+
10+
Recreate the view with an explicit ``COLLATE utf8mb4_general_ci`` on each
11+
string output column: an explicit collation has the strongest coercibility, so
12+
comparisons resolve deterministically regardless of the server's
13+
``character_set_collations`` setting. No behavior change on PostgreSQL.
14+
15+
Revision ID: 0096_fix_virtual_collections
16+
Revises: 0095_virtual_collections_source
17+
Create Date: 2026-07-15 12:00:00.000000
18+
19+
"""
20+
21+
import sqlalchemy as sa
22+
from alembic import op
23+
24+
from utils.database import is_postgresql
25+
26+
# revision identifiers, used by Alembic.
27+
revision = "0096_fix_virtual_collections"
28+
down_revision = "0095_virtual_collections_source"
29+
branch_labels = None
30+
depends_on = None
31+
32+
33+
def upgrade() -> None:
34+
connection = op.get_bind()
35+
36+
if is_postgresql(connection):
37+
# PostgreSQL is unaffected; keep the view from 0095 as-is.
38+
return
39+
40+
connection.execute(
41+
sa.text("""
42+
CREATE OR REPLACE VIEW virtual_collections AS
43+
WITH base AS (
44+
SELECT
45+
r.id as rom_id,
46+
r.path_cover_s as path_cover_s,
47+
r.path_cover_l as path_cover_l,
48+
rm.genres as genres,
49+
rm.franchises as franchises,
50+
rm.collections as collections,
51+
rm.game_modes as game_modes,
52+
rm.companies as companies
53+
FROM
54+
roms r
55+
JOIN roms_metadata rm ON rm.rom_id = r.id
56+
),
57+
genres AS (
58+
SELECT
59+
base.rom_id as rom_id,
60+
base.path_cover_s as path_cover_s,
61+
base.path_cover_l as path_cover_l,
62+
CONCAT(j.genre) COLLATE utf8mb4_general_ci as collection_name,
63+
'genre' COLLATE utf8mb4_general_ci as collection_type
64+
FROM
65+
base
66+
CROSS JOIN JSON_TABLE(
67+
base.genres,
68+
'$[*]' COLUMNS (genre VARCHAR(255) PATH '$')
69+
) j
70+
),
71+
franchises AS (
72+
SELECT
73+
base.rom_id as rom_id,
74+
base.path_cover_s as path_cover_s,
75+
base.path_cover_l as path_cover_l,
76+
CONCAT(j.franchise) COLLATE utf8mb4_general_ci as collection_name,
77+
'franchise' COLLATE utf8mb4_general_ci as collection_type
78+
FROM
79+
base
80+
CROSS JOIN JSON_TABLE(
81+
base.franchises,
82+
'$[*]' COLUMNS (franchise VARCHAR(255) PATH '$')
83+
) j
84+
),
85+
collections AS (
86+
SELECT
87+
base.rom_id as rom_id,
88+
base.path_cover_s as path_cover_s,
89+
base.path_cover_l as path_cover_l,
90+
CONCAT(j.collection) COLLATE utf8mb4_general_ci as collection_name,
91+
'collection' COLLATE utf8mb4_general_ci as collection_type
92+
FROM
93+
base
94+
CROSS JOIN JSON_TABLE(
95+
base.collections,
96+
'$[*]' COLUMNS (collection VARCHAR(255) PATH '$')
97+
) j
98+
),
99+
modes AS (
100+
SELECT
101+
base.rom_id as rom_id,
102+
base.path_cover_s as path_cover_s,
103+
base.path_cover_l as path_cover_l,
104+
CONCAT(j.mode) COLLATE utf8mb4_general_ci as collection_name,
105+
'mode' COLLATE utf8mb4_general_ci as collection_type
106+
FROM
107+
base
108+
CROSS JOIN JSON_TABLE(
109+
base.game_modes,
110+
'$[*]' COLUMNS (mode VARCHAR(255) PATH '$')
111+
) j
112+
),
113+
companies AS (
114+
SELECT
115+
base.rom_id as rom_id,
116+
base.path_cover_s as path_cover_s,
117+
base.path_cover_l as path_cover_l,
118+
CONCAT(j.company) COLLATE utf8mb4_general_ci as collection_name,
119+
'company' COLLATE utf8mb4_general_ci as collection_type
120+
FROM
121+
base
122+
CROSS JOIN JSON_TABLE(
123+
base.companies,
124+
'$[*]' COLUMNS (company VARCHAR(255) PATH '$')
125+
) j
126+
)
127+
SELECT
128+
collection_name as name,
129+
collection_type as type,
130+
CONCAT('Autogenerated ', collection_name, ' collection') COLLATE utf8mb4_general_ci AS description,
131+
NOW() AS created_at,
132+
NOW() AS updated_at,
133+
JSON_ARRAYAGG(rom_id) as rom_ids,
134+
JSON_ARRAYAGG(path_cover_s) as path_covers_s,
135+
JSON_ARRAYAGG(path_cover_l) as path_covers_l
136+
FROM
137+
(
138+
SELECT * FROM genres
139+
UNION ALL
140+
SELECT * FROM franchises
141+
UNION ALL
142+
SELECT * FROM collections
143+
UNION ALL
144+
SELECT * FROM modes
145+
UNION ALL
146+
SELECT * FROM companies
147+
) combined
148+
GROUP BY collection_type, collection_name
149+
HAVING COUNT(DISTINCT rom_id) > 2
150+
ORDER BY collection_type, collection_name;
151+
"""),
152+
)
153+
154+
155+
def downgrade() -> None:
156+
connection = op.get_bind()
157+
158+
if is_postgresql(connection):
159+
return
160+
161+
connection.execute(
162+
sa.text("""
163+
CREATE OR REPLACE VIEW virtual_collections AS
164+
WITH base AS (
165+
SELECT
166+
r.id as rom_id,
167+
r.path_cover_s as path_cover_s,
168+
r.path_cover_l as path_cover_l,
169+
rm.genres as genres,
170+
rm.franchises as franchises,
171+
rm.collections as collections,
172+
rm.game_modes as game_modes,
173+
rm.companies as companies
174+
FROM
175+
roms r
176+
JOIN roms_metadata rm ON rm.rom_id = r.id
177+
),
178+
genres AS (
179+
SELECT
180+
base.rom_id as rom_id,
181+
base.path_cover_s as path_cover_s,
182+
base.path_cover_l as path_cover_l,
183+
CONCAT(j.genre) as collection_name,
184+
'genre' as collection_type
185+
FROM
186+
base
187+
CROSS JOIN JSON_TABLE(
188+
base.genres,
189+
'$[*]' COLUMNS (genre VARCHAR(255) PATH '$')
190+
) j
191+
),
192+
franchises AS (
193+
SELECT
194+
base.rom_id as rom_id,
195+
base.path_cover_s as path_cover_s,
196+
base.path_cover_l as path_cover_l,
197+
CONCAT(j.franchise) as collection_name,
198+
'franchise' as collection_type
199+
FROM
200+
base
201+
CROSS JOIN JSON_TABLE(
202+
base.franchises,
203+
'$[*]' COLUMNS (franchise VARCHAR(255) PATH '$')
204+
) j
205+
),
206+
collections AS (
207+
SELECT
208+
base.rom_id as rom_id,
209+
base.path_cover_s as path_cover_s,
210+
base.path_cover_l as path_cover_l,
211+
CONCAT(j.collection) as collection_name,
212+
'collection' as collection_type
213+
FROM
214+
base
215+
CROSS JOIN JSON_TABLE(
216+
base.collections,
217+
'$[*]' COLUMNS (collection VARCHAR(255) PATH '$')
218+
) j
219+
),
220+
modes AS (
221+
SELECT
222+
base.rom_id as rom_id,
223+
base.path_cover_s as path_cover_s,
224+
base.path_cover_l as path_cover_l,
225+
CONCAT(j.mode) as collection_name,
226+
'mode' as collection_type
227+
FROM
228+
base
229+
CROSS JOIN JSON_TABLE(
230+
base.game_modes,
231+
'$[*]' COLUMNS (mode VARCHAR(255) PATH '$')
232+
) j
233+
),
234+
companies AS (
235+
SELECT
236+
base.rom_id as rom_id,
237+
base.path_cover_s as path_cover_s,
238+
base.path_cover_l as path_cover_l,
239+
CONCAT(j.company) as collection_name,
240+
'company' as collection_type
241+
FROM
242+
base
243+
CROSS JOIN JSON_TABLE(
244+
base.companies,
245+
'$[*]' COLUMNS (company VARCHAR(255) PATH '$')
246+
) j
247+
)
248+
SELECT
249+
collection_name as name,
250+
collection_type as type,
251+
CONCAT('Autogenerated ', collection_name, ' collection') AS description,
252+
NOW() AS created_at,
253+
NOW() AS updated_at,
254+
JSON_ARRAYAGG(rom_id) as rom_ids,
255+
JSON_ARRAYAGG(path_cover_s) as path_covers_s,
256+
JSON_ARRAYAGG(path_cover_l) as path_covers_l
257+
FROM
258+
(
259+
SELECT * FROM genres
260+
UNION ALL
261+
SELECT * FROM franchises
262+
UNION ALL
263+
SELECT * FROM collections
264+
UNION ALL
265+
SELECT * FROM modes
266+
UNION ALL
267+
SELECT * FROM companies
268+
) combined
269+
GROUP BY collection_type, collection_name
270+
HAVING COUNT(DISTINCT rom_id) > 2
271+
ORDER BY collection_type, collection_name;
272+
"""),
273+
)

0 commit comments

Comments
 (0)