Skip to content

Commit 5ac7b4d

Browse files
authored
🐛 prevent non-unique indices from being quietly omitted (#109)
1 parent 2195670 commit 5ac7b4d

File tree

3 files changed

+68
-5
lines changed

3 files changed

+68
-5
lines changed

src/mysql_to_sqlite3/transporter.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ def __init__(self, **kwargs: Unpack[MySQLtoSQLiteParams]) -> None:
146146

147147
self._sqlite_json1_extension_enabled = not self._json_as_text and self._check_sqlite_json1_extension_enabled()
148148

149+
# Track seen SQLite index names to generate unique names when prefixing is disabled
150+
self._seen_sqlite_index_names: t.Set[str] = set()
151+
# Counter for duplicate index names to assign numeric suffixes (name_2, name_3, ...)
152+
self._sqlite_index_name_counters: t.Dict[str, int] = {}
153+
149154
try:
150155
_mysql_connection = mysql.connector.connect(
151156
user=self._mysql_user,
@@ -409,6 +414,33 @@ def _check_sqlite_json1_extension_enabled(self) -> bool:
409414
except sqlite3.Error:
410415
return False
411416

417+
def _get_unique_index_name(self, base_name: str) -> str:
418+
"""Return a unique SQLite index name based on base_name.
419+
420+
If base_name has not been used yet, it is returned as-is and recorded. If it has been
421+
used, a numeric suffix is appended starting from 2 (e.g., name_2, name_3, ...), and the
422+
chosen name is recorded as used. This behavior is only intended for cases where index
423+
prefixing is not enabled and SQLite requires global uniqueness for index names.
424+
"""
425+
if base_name not in self._seen_sqlite_index_names:
426+
self._seen_sqlite_index_names.add(base_name)
427+
return base_name
428+
# Base name already seen — assign next available counter
429+
next_num = self._sqlite_index_name_counters.get(base_name, 2)
430+
candidate = f"{base_name}_{next_num}"
431+
while candidate in self._seen_sqlite_index_names:
432+
next_num += 1
433+
candidate = f"{base_name}_{next_num}"
434+
# Record chosen candidate and bump counter for the base name
435+
self._seen_sqlite_index_names.add(candidate)
436+
self._sqlite_index_name_counters[base_name] = next_num + 1
437+
self._logger.info(
438+
'Index "%s" renamed to "%s" to ensure uniqueness across the SQLite database.',
439+
base_name,
440+
candidate,
441+
)
442+
return candidate
443+
412444
def _build_create_table_sql(self, table_name: str) -> str:
413445
sql: str = f'CREATE TABLE IF NOT EXISTS "{table_name}" ('
414446
primary: str = ""
@@ -523,13 +555,20 @@ def _build_create_table_sql(self, table_name: str) -> str:
523555
columns=", ".join(f'"{column}"' for column in columns.split(","))
524556
)
525557
else:
558+
# Determine the SQLite index name, considering table name collisions and prefix option
559+
proposed_index_name = (
560+
f"{table_name}_{index_name}"
561+
if (table_collisions > 0 or self._prefix_indices)
562+
else index_name
563+
)
564+
# Ensure index name is unique across the whole SQLite database when prefixing is disabled
565+
if not self._prefix_indices:
566+
unique_index_name = self._get_unique_index_name(proposed_index_name)
567+
else:
568+
unique_index_name = proposed_index_name
526569
indices += """CREATE {unique} INDEX IF NOT EXISTS "{name}" ON "{table}" ({columns});""".format(
527570
unique="UNIQUE" if index["unique"] in {1, "1"} else "",
528-
name=(
529-
f"{table_name}_{index_name}"
530-
if (table_collisions > 0 or self._prefix_indices)
531-
else index_name
532-
),
571+
name=unique_index_name,
533572
table=table_name,
534573
columns=", ".join(f'"{column}"' for column in columns.split(",")),
535574
)

src/mysql_to_sqlite3/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,6 @@ class MySQLtoSQLiteAttributes:
8181
_vacuum: bool
8282
_without_data: bool
8383
_without_foreign_keys: bool
84+
# Tracking of SQLite index names and counters to ensure uniqueness when prefixing is disabled
85+
_seen_sqlite_index_names: t.Set[str]
86+
_sqlite_index_name_counters: t.Dict[str, int]

tests/unit/test_transporter.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ def test_decode_column_type_with_non_string_non_bytes(self) -> None:
4444
assert MySQLtoSQLite._decode_column_type(None) == "None"
4545
assert MySQLtoSQLite._decode_column_type(True) == "True"
4646

47+
def test_get_unique_index_name_suffixing_sequence(self) -> None:
48+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
49+
instance = MySQLtoSQLite()
50+
# minimal attributes required by the helper
51+
instance._seen_sqlite_index_names = set()
52+
instance._sqlite_index_name_counters = {}
53+
instance._prefix_indices = False
54+
instance._logger = MagicMock()
55+
56+
# First occurrence: no suffix
57+
assert instance._get_unique_index_name("idx_page_id") == "idx_page_id"
58+
# Second occurrence: _2
59+
assert instance._get_unique_index_name("idx_page_id") == "idx_page_id_2"
60+
# Third occurrence: _3
61+
assert instance._get_unique_index_name("idx_page_id") == "idx_page_id_3"
62+
63+
# A different base name should start without suffix
64+
assert instance._get_unique_index_name("idx_user_id") == "idx_user_id"
65+
# And then suffix from 2
66+
assert instance._get_unique_index_name("idx_user_id") == "idx_user_id_2"
67+
4768
@patch("sqlite3.connect")
4869
def test_check_sqlite_json1_extension_enabled_success(self, mock_connect: MagicMock) -> None:
4970
"""Test _check_sqlite_json1_extension_enabled when JSON1 is enabled."""

0 commit comments

Comments
 (0)