fix(favorites): harden folder tag migration collisions
- commit
- 8077f20fb35eee004f311464fb51a948ff09d4fe
- author
- brice · · 2026-06-28 07:26 UTC
- parent
- ecab2f7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +105 -10
M internal/infrastructure/persistence/sqlite/favorites_migration_test.go +46 -0
| @@ -86,6 +86,52 @@ func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) { | |||
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) { | |
| 90 | + | db := openMigrationTestDB(t) | |
| 91 | + | runMigrationsUpTo(t, db, 10) | |
| 92 | + | ||
| 93 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`) | |
| 94 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`) | |
| 95 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`) | |
| 96 | + | ||
| 97 | + | runAllMigrations(t, db) | |
| 98 | + | ||
| 99 | + | var tagID int64 | |
| 100 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil { | |
| 101 | + | t.Fatalf("expected second-order suffixed collision tag: %v", err) | |
| 102 | + | } | |
| 103 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 { | |
| 104 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) { | |
| 109 | + | db := openMigrationTestDB(t) | |
| 110 | + | runMigrationsUpTo(t, db, 10) | |
| 111 | + | ||
| 112 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`) | |
| 113 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`) | |
| 114 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`) | |
| 115 | + | ||
| 116 | + | runAllMigrations(t, db) | |
| 117 | + | ||
| 118 | + | var folder3TagID int64 | |
| 119 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil { | |
| 120 | + | t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err) | |
| 121 | + | } | |
| 122 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 { | |
| 123 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | var folder4TagID int64 | |
| 127 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil { | |
| 128 | + | t.Fatalf("expected natural folder slug tag: %v", err) | |
| 129 | + | } | |
| 130 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 { | |
| 131 | + | t.Fatalf("expected assignment to natural slug tag, got %d", got) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 89 | 135 | func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) { | |
| 90 | 136 | db := openMigrationTestDB(t) | |
| 91 | 137 | runMigrationsUpTo(t, db, 10) | |
| @@ -86,6 +86,52 @@ func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) { | |||||
| 86 | } | 86 | } | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) { | |||
| 90 | + | db := openMigrationTestDB(t) | |||
| 91 | + | runMigrationsUpTo(t, db, 10) | |||
| 92 | + | ||||
| 93 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`) | |||
| 94 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`) | |||
| 95 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`) | |||
| 96 | + | ||||
| 97 | + | runAllMigrations(t, db) | |||
| 98 | + | ||||
| 99 | + | var tagID int64 | |||
| 100 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil { | |||
| 101 | + | t.Fatalf("expected second-order suffixed collision tag: %v", err) | |||
| 102 | + | } | |||
| 103 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 { | |||
| 104 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) { | |||
| 109 | + | db := openMigrationTestDB(t) | |||
| 110 | + | runMigrationsUpTo(t, db, 10) | |||
| 111 | + | ||||
| 112 | + | mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`) | |||
| 113 | + | mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`) | |||
| 114 | + | mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`) | |||
| 115 | + | ||||
| 116 | + | runAllMigrations(t, db) | |||
| 117 | + | ||||
| 118 | + | var folder3TagID int64 | |||
| 119 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil { | |||
| 120 | + | t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err) | |||
| 121 | + | } | |||
| 122 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 { | |||
| 123 | + | t.Fatalf("expected assignment to second-order suffixed tag, got %d", got) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | var folder4TagID int64 | |||
| 127 | + | if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil { | |||
| 128 | + | t.Fatalf("expected natural folder slug tag: %v", err) | |||
| 129 | + | } | |||
| 130 | + | if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 { | |||
| 131 | + | t.Fatalf("expected assignment to natural slug tag, got %d", got) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 89 | func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) { | 135 | func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) { | ||
| 90 | db := openMigrationTestDB(t) | 136 | db := openMigrationTestDB(t) | ||
| 91 | runMigrationsUpTo(t, db, 10) | 137 | runMigrationsUpTo(t, db, 10) | ||
M internal/infrastructure/persistence/sqlite/migrations/011_favorites_tags_first.sql +59 -10
| @@ -49,22 +49,71 @@ WITH RECURSIVE folder_paths(id, parent_id, path) AS ( | |||
| 49 | 49 | ) | |
| 50 | 50 | ) AS base_slug | |
| 51 | 51 | FROM folder_paths | |
| 52 | - | ), resolved AS ( | |
| 52 | + | ), folder_names AS ( | |
| 53 | 53 | SELECT | |
| 54 | 54 | id, | |
| 55 | 55 | CASE | |
| 56 | 56 | WHEN base_slug = '' THEN 'folder-' || id | |
| 57 | - | WHEN EXISTS ( | |
| 58 | - | SELECT 1 FROM favorite_tags t | |
| 59 | - | WHERE lower(trim(t.name)) = lower(trim(base_slug)) | |
| 60 | - | ) OR EXISTS ( | |
| 61 | - | SELECT 1 FROM folder_slugs earlier | |
| 62 | - | WHERE earlier.id < folder_slugs.id | |
| 63 | - | AND lower(trim(earlier.base_slug)) = lower(trim(folder_slugs.base_slug)) | |
| 64 | - | ) THEN base_slug || '-folder-' || id | |
| 65 | 57 | ELSE base_slug | |
| 66 | - | END AS tag_name | |
| 58 | + | END AS natural_name | |
| 67 | 59 | FROM folder_slugs | |
| 60 | + | ), base_assignments AS ( | |
| 61 | + | SELECT id, natural_name AS tag_name | |
| 62 | + | FROM folder_names current_folder | |
| 63 | + | WHERE NOT EXISTS ( | |
| 64 | + | SELECT 1 FROM favorite_tags t | |
| 65 | + | WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name)) | |
| 66 | + | ) | |
| 67 | + | AND NOT EXISTS ( | |
| 68 | + | SELECT 1 FROM folder_names earlier | |
| 69 | + | WHERE earlier.id < current_folder.id | |
| 70 | + | AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name)) | |
| 71 | + | ) | |
| 72 | + | ), unresolved_folders AS ( | |
| 73 | + | SELECT id, natural_name | |
| 74 | + | FROM folder_names | |
| 75 | + | WHERE id NOT IN (SELECT id FROM base_assignments) | |
| 76 | + | ), candidate_limit(max_attempt) AS ( | |
| 77 | + | SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3 | |
| 78 | + | ), candidate_numbers(attempt) AS ( | |
| 79 | + | SELECT 1 | |
| 80 | + | UNION ALL | |
| 81 | + | SELECT attempt + 1 | |
| 82 | + | FROM candidate_numbers, candidate_limit | |
| 83 | + | WHERE attempt < max_attempt | |
| 84 | + | ), suffix_candidates AS ( | |
| 85 | + | SELECT | |
| 86 | + | unresolved_folders.id, | |
| 87 | + | candidate_numbers.attempt, | |
| 88 | + | CASE | |
| 89 | + | WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id | |
| 90 | + | ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt | |
| 91 | + | END AS tag_name | |
| 92 | + | FROM unresolved_folders | |
| 93 | + | CROSS JOIN candidate_numbers | |
| 94 | + | ), available_suffix_candidates AS ( | |
| 95 | + | SELECT id, attempt, tag_name | |
| 96 | + | FROM suffix_candidates candidate | |
| 97 | + | WHERE NOT EXISTS ( | |
| 98 | + | SELECT 1 FROM favorite_tags t | |
| 99 | + | WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name)) | |
| 100 | + | ) | |
| 101 | + | AND NOT EXISTS ( | |
| 102 | + | SELECT 1 FROM base_assignments assigned | |
| 103 | + | WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name)) | |
| 104 | + | ) | |
| 105 | + | ), suffix_assignments AS ( | |
| 106 | + | SELECT id, tag_name | |
| 107 | + | FROM available_suffix_candidates candidate | |
| 108 | + | WHERE attempt = ( | |
| 109 | + | SELECT MIN(attempt) | |
| 110 | + | FROM available_suffix_candidates earlier | |
| 111 | + | WHERE earlier.id = candidate.id | |
| 112 | + | ) | |
| 113 | + | ), resolved AS ( | |
| 114 | + | SELECT id, tag_name FROM base_assignments | |
| 115 | + | UNION ALL | |
| 116 | + | SELECT id, tag_name FROM suffix_assignments | |
| 68 | 117 | ) | |
| 69 | 118 | INSERT INTO _folder_tag_map(folder_id, tag_name) | |
| 70 | 119 | SELECT id, tag_name FROM resolved; | |
| @@ -49,22 +49,71 @@ WITH RECURSIVE folder_paths(id, parent_id, path) AS ( | |||||
| 49 | ) | 49 | ) | ||
| 50 | ) AS base_slug | 50 | ) AS base_slug | ||
| 51 | FROM folder_paths | 51 | FROM folder_paths | ||
| 52 | - | ), resolved AS ( | 52 | + | ), folder_names AS ( |
| 53 | SELECT | 53 | SELECT | ||
| 54 | id, | 54 | id, | ||
| 55 | CASE | 55 | CASE | ||
| 56 | WHEN base_slug = '' THEN 'folder-' || id | 56 | WHEN base_slug = '' THEN 'folder-' || id | ||
| 57 | - | WHEN EXISTS ( | |||
| 58 | - | SELECT 1 FROM favorite_tags t | |||
| 59 | - | WHERE lower(trim(t.name)) = lower(trim(base_slug)) | |||
| 60 | - | ) OR EXISTS ( | |||
| 61 | - | SELECT 1 FROM folder_slugs earlier | |||
| 62 | - | WHERE earlier.id < folder_slugs.id | |||
| 63 | - | AND lower(trim(earlier.base_slug)) = lower(trim(folder_slugs.base_slug)) | |||
| 64 | - | ) THEN base_slug || '-folder-' || id | |||
| 65 | ELSE base_slug | 57 | ELSE base_slug | ||
| 66 | - | END AS tag_name | 58 | + | END AS natural_name |
| 67 | FROM folder_slugs | 59 | FROM folder_slugs | ||
| 60 | + | ), base_assignments AS ( | |||
| 61 | + | SELECT id, natural_name AS tag_name | |||
| 62 | + | FROM folder_names current_folder | |||
| 63 | + | WHERE NOT EXISTS ( | |||
| 64 | + | SELECT 1 FROM favorite_tags t | |||
| 65 | + | WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name)) | |||
| 66 | + | ) | |||
| 67 | + | AND NOT EXISTS ( | |||
| 68 | + | SELECT 1 FROM folder_names earlier | |||
| 69 | + | WHERE earlier.id < current_folder.id | |||
| 70 | + | AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name)) | |||
| 71 | + | ) | |||
| 72 | + | ), unresolved_folders AS ( | |||
| 73 | + | SELECT id, natural_name | |||
| 74 | + | FROM folder_names | |||
| 75 | + | WHERE id NOT IN (SELECT id FROM base_assignments) | |||
| 76 | + | ), candidate_limit(max_attempt) AS ( | |||
| 77 | + | SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3 | |||
| 78 | + | ), candidate_numbers(attempt) AS ( | |||
| 79 | + | SELECT 1 | |||
| 80 | + | UNION ALL | |||
| 81 | + | SELECT attempt + 1 | |||
| 82 | + | FROM candidate_numbers, candidate_limit | |||
| 83 | + | WHERE attempt < max_attempt | |||
| 84 | + | ), suffix_candidates AS ( | |||
| 85 | + | SELECT | |||
| 86 | + | unresolved_folders.id, | |||
| 87 | + | candidate_numbers.attempt, | |||
| 88 | + | CASE | |||
| 89 | + | WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id | |||
| 90 | + | ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt | |||
| 91 | + | END AS tag_name | |||
| 92 | + | FROM unresolved_folders | |||
| 93 | + | CROSS JOIN candidate_numbers | |||
| 94 | + | ), available_suffix_candidates AS ( | |||
| 95 | + | SELECT id, attempt, tag_name | |||
| 96 | + | FROM suffix_candidates candidate | |||
| 97 | + | WHERE NOT EXISTS ( | |||
| 98 | + | SELECT 1 FROM favorite_tags t | |||
| 99 | + | WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name)) | |||
| 100 | + | ) | |||
| 101 | + | AND NOT EXISTS ( | |||
| 102 | + | SELECT 1 FROM base_assignments assigned | |||
| 103 | + | WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name)) | |||
| 104 | + | ) | |||
| 105 | + | ), suffix_assignments AS ( | |||
| 106 | + | SELECT id, tag_name | |||
| 107 | + | FROM available_suffix_candidates candidate | |||
| 108 | + | WHERE attempt = ( | |||
| 109 | + | SELECT MIN(attempt) | |||
| 110 | + | FROM available_suffix_candidates earlier | |||
| 111 | + | WHERE earlier.id = candidate.id | |||
| 112 | + | ) | |||
| 113 | + | ), resolved AS ( | |||
| 114 | + | SELECT id, tag_name FROM base_assignments | |||
| 115 | + | UNION ALL | |||
| 116 | + | SELECT id, tag_name FROM suffix_assignments | |||
| 68 | ) | 117 | ) | ||
| 69 | INSERT INTO _folder_tag_map(folder_id, tag_name) | 118 | INSERT INTO _folder_tag_map(folder_id, tag_name) | ||
| 70 | SELECT id, tag_name FROM resolved; | 119 | SELECT id, tag_name FROM resolved; | ||