Skip to content

Commit 55e0198

Browse files
committed
✨ add MySQL table prefix option with validation in CLI
1 parent 0a876c7 commit 55e0198

File tree

3 files changed

+78
-32
lines changed

3 files changed

+78
-32
lines changed

src/sqlite3_to_mysql/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,24 @@
1414
from .click_utils import OptionEatAll, prompt_password
1515
from .debug_info import info
1616
from .mysql_utils import MYSQL_INSERT_METHOD, MYSQL_TEXT_COLUMN_TYPES, mysql_supported_character_sets
17+
from .transporter import MYSQL_TABLE_PREFIX_PATTERN
1718

1819

1920
_copyright_header: str = f"sqlite3mysql version {package_version} Copyright (c) 2018-{datetime.now().year} Klemen Tusar"
2021

2122

23+
def _validate_mysql_table_prefix(_: t.Any, __: t.Any, value: t.Optional[str]) -> str:
24+
"""Validate the optional MySQL table prefix supplied via CLI."""
25+
if not value:
26+
return ""
27+
if not MYSQL_TABLE_PREFIX_PATTERN.match(value):
28+
raise click.BadParameter(
29+
"Table prefix must start with a letter, contain only letters, numbers, or underscores, "
30+
"and be at most 32 characters long."
31+
)
32+
return value
33+
34+
2235
@click.command(
2336
name="sqlite3mysql",
2437
help=_copyright_header,
@@ -116,6 +129,13 @@
116129
default="TEXT",
117130
help="MySQL default text field type. Defaults to TEXT.",
118131
)
132+
@click.option(
133+
"-b",
134+
"--mysql-table-prefix",
135+
default="",
136+
callback=_validate_mysql_table_prefix,
137+
help="Prefix to prepend to every created MySQL table (letters/numbers/underscores, must start with a letter).",
138+
)
119139
@click.option(
120140
"--mysql-charset",
121141
metavar="TEXT",
@@ -169,6 +189,7 @@ def cli(
169189
mysql_integer_type: str,
170190
mysql_string_type: str,
171191
mysql_text_type: str,
192+
mysql_table_prefix: str,
172193
mysql_charset: str,
173194
mysql_collation: str,
174195
use_fulltext: bool,
@@ -224,6 +245,7 @@ def cli(
224245
mysql_integer_type=mysql_integer_type,
225246
mysql_string_type=mysql_string_type,
226247
mysql_text_type=mysql_text_type,
248+
mysql_table_prefix=mysql_table_prefix,
227249
mysql_charset=mysql_charset.lower() if mysql_charset else "utf8mb4",
228250
mysql_collation=mysql_collation.lower() if mysql_collation else None,
229251
ignore_duplicate_keys=ignore_duplicate_keys,

src/sqlite3_to_mysql/transporter.py

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
key: value for key, value in sqlglot_mysql.MySQL.INVERSE_TIME_MAPPING.items() if key != "%H:%M:%S"
6767
}
6868
SQLGLOT_MYSQL_INVERSE_TIME_TRIE: t.Dict[str, t.Any] = new_trie(SQLGLOT_MYSQL_INVERSE_TIME_MAPPING)
69+
MYSQL_TABLE_PREFIX_PATTERN: t.Pattern[str] = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$")
6970

7071

7172
class SQLite3toMySQL(SQLite3toMySQLAttributes):
@@ -91,6 +92,7 @@ class SQLite3toMySQL(SQLite3toMySQLAttributes):
9192
re.IGNORECASE,
9293
)
9394
NUMERIC_LITERAL_PATTERN: t.Pattern[str] = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
95+
TABLE_PREFIX_PATTERN: t.Pattern[str] = MYSQL_TABLE_PREFIX_PATTERN
9496

9597
MYSQL_CONNECTOR_VERSION: version.Version = version.parse(mysql_connector_version_string)
9698

@@ -174,6 +176,14 @@ def __init__(self, **kwargs: Unpack[SQLite3toMySQLParams]):
174176
if not kwargs.get("mysql_collation") and self._mysql_collation == "utf8mb4_0900_ai_ci":
175177
self._mysql_collation = "utf8mb4_unicode_ci"
176178

179+
mysql_table_prefix: str = str(kwargs.get("mysql_table_prefix", "") or "")
180+
if mysql_table_prefix and not self.TABLE_PREFIX_PATTERN.match(mysql_table_prefix):
181+
raise ValueError(
182+
"MySQL table prefix must start with a letter and contain only letters, numbers, or underscores "
183+
"with a maximum length of 32 characters."
184+
)
185+
self._mysql_table_prefix = mysql_table_prefix
186+
177187
self._ignore_duplicate_keys = kwargs.get("ignore_duplicate_keys", False) or False
178188

179189
self._use_fulltext = kwargs.get("use_fulltext", False) or False
@@ -339,6 +349,13 @@ def _sqlite_table_has_rowid(self, table: str) -> bool:
339349
except sqlite3.OperationalError:
340350
return False
341351

352+
def _mysql_table_name(self, table_name: str) -> str:
353+
"""Return the MySQL table name with any configured prefix applied."""
354+
prefix: str = getattr(self, "_mysql_table_prefix", "")
355+
if prefix:
356+
return safe_identifier_length(f"{prefix}{table_name}")
357+
return safe_identifier_length(table_name)
358+
342359
def _create_database(self) -> None:
343360
try:
344361
self._mysql_cur.execute(
@@ -881,8 +898,9 @@ def _create_mysql_view(self, view_name: str, view_sql: str) -> None:
881898

882899
def _create_table(self, table_name: str, transfer_rowid: bool = False, skip_default: bool = False) -> None:
883900
primary_keys: t.List[t.Dict[str, str]] = []
901+
mysql_table_name: str = self._mysql_table_name(table_name)
884902

885-
sql: str = f"CREATE TABLE IF NOT EXISTS `{safe_identifier_length(table_name)}` ( "
903+
sql: str = f"CREATE TABLE IF NOT EXISTS `{mysql_table_name}` ( "
886904

887905
if transfer_rowid:
888906
sql += " `rowid` BIGINT NOT NULL, "
@@ -960,7 +978,7 @@ def _create_table(self, table_name: str, transfer_rowid: bool = False, skip_defa
960978
)
961979

962980
if transfer_rowid:
963-
sql += f", CONSTRAINT `{safe_identifier_length(table_name)}_rowid` UNIQUE (`rowid`)"
981+
sql += f", CONSTRAINT `{mysql_table_name}_rowid` UNIQUE (`rowid`)"
964982

965983
sql += f" ) ENGINE=InnoDB DEFAULT CHARSET={self._mysql_charset} COLLATE={self._mysql_collation}"
966984

@@ -971,19 +989,20 @@ def _create_table(self, table_name: str, transfer_rowid: bool = False, skip_defa
971989
if err.errno == errorcode.ER_INVALID_DEFAULT and not skip_default:
972990
self._logger.warning(
973991
"MySQL failed creating table %s with DEFAULT values: %s. Retrying without DEFAULT values ...",
974-
safe_identifier_length(table_name),
992+
mysql_table_name,
975993
err,
976994
)
977995
return self._create_table(table_name, transfer_rowid, skip_default=True)
978996
else:
979997
self._logger.error(
980998
"MySQL failed creating table %s: %s",
981-
safe_identifier_length(table_name),
999+
mysql_table_name,
9821000
err,
9831001
)
9841002
raise
9851003

9861004
def _truncate_table(self, table_name: str) -> None:
1005+
mysql_table_name: str = self._mysql_table_name(table_name)
9871006
self._mysql_cur.execute(
9881007
"""
9891008
SELECT `TABLE_NAME`
@@ -992,14 +1011,15 @@ def _truncate_table(self, table_name: str) -> None:
9921011
AND `TABLE_NAME` = %s
9931012
LIMIT 1
9941013
""",
995-
(self._mysql_database, safe_identifier_length(table_name)),
1014+
(self._mysql_database, mysql_table_name),
9961015
)
9971016
if len(self._mysql_cur.fetchall()) > 0:
998-
self._logger.info("Truncating table %s", safe_identifier_length(table_name))
999-
self._mysql_cur.execute(f"TRUNCATE TABLE `{safe_identifier_length(table_name)}`")
1017+
self._logger.info("Truncating table %s", mysql_table_name)
1018+
self._mysql_cur.execute(f"TRUNCATE TABLE `{mysql_table_name}`")
10001019

10011020
def _add_indices(self, table_name: str) -> None:
10021021
quoted_table_name: str = self._sqlite_quote_ident(table_name)
1022+
mysql_table_name: str = self._mysql_table_name(table_name)
10031023

10041024
self._sqlite_cur.execute(f'PRAGMA table_info("{quoted_table_name}")')
10051025
table_columns: t.Dict[str, str] = {}
@@ -1063,7 +1083,7 @@ def _add_indices(self, table_name: str) -> None:
10631083
self._logger.warning(
10641084
"""Failed adding index to column "%s" in table %s: Column not found!""",
10651085
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1066-
safe_identifier_length(table_name),
1086+
mysql_table_name,
10671087
)
10681088
continue
10691089

@@ -1107,12 +1127,13 @@ def _add_index(
11071127
index_infos: t.Tuple[t.Dict[str, t.Any], ...],
11081128
index_iteration: int = 0,
11091129
) -> None:
1130+
mysql_table_name: str = self._mysql_table_name(table_name)
11101131
sql: str = (
11111132
"""
11121133
ALTER TABLE `{table}`
11131134
ADD {index_type} `{name}`({columns})
11141135
""".format(
1115-
table=safe_identifier_length(table_name),
1136+
table=mysql_table_name,
11161137
index_type=index_type,
11171138
name=(
11181139
safe_identifier_length(index["name"])
@@ -1128,14 +1149,13 @@ def _add_index(
11281149
"""Adding %s to column "%s" in table %s""",
11291150
"unique index" if int(index["unique"]) == 1 else "index",
11301151
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1131-
safe_identifier_length(table_name),
1152+
mysql_table_name,
11321153
)
11331154
self._mysql_cur.execute(sql)
11341155
self._mysql.commit()
11351156
except mysql.connector.Error as err:
11361157
if err.errno == errorcode.ER_DUP_KEYNAME:
11371158
if not self._ignore_duplicate_keys:
1138-
# handle a duplicate key name
11391159
self._add_index(
11401160
table_name=table_name,
11411161
index_type=index_type,
@@ -1147,59 +1167,59 @@ def _add_index(
11471167
self._logger.warning(
11481168
"""Duplicate key "%s" in table %s detected! Trying to create new key "%s_%s" ...""",
11491169
safe_identifier_length(index["name"]),
1150-
safe_identifier_length(table_name),
1170+
mysql_table_name,
11511171
safe_identifier_length(index["name"]),
11521172
index_iteration + 1,
11531173
)
11541174
else:
11551175
self._logger.warning(
11561176
"""Ignoring duplicate key "%s" in table %s!""",
11571177
safe_identifier_length(index["name"]),
1158-
safe_identifier_length(table_name),
1178+
mysql_table_name,
11591179
)
11601180
elif err.errno == errorcode.ER_DUP_ENTRY:
11611181
self._logger.warning(
11621182
"""Ignoring duplicate entry when adding index to column "%s" in table %s!""",
11631183
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1164-
safe_identifier_length(table_name),
1184+
mysql_table_name,
11651185
)
11661186
elif err.errno == errorcode.ER_DUP_FIELDNAME:
11671187
self._logger.warning(
11681188
"""Failed adding index to column "%s" in table %s: Duplicate field name! Ignoring...""",
11691189
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1170-
safe_identifier_length(table_name),
1190+
mysql_table_name,
11711191
)
11721192
elif err.errno == errorcode.ER_TOO_MANY_KEYS:
11731193
self._logger.warning(
11741194
"""Failed adding index to column "%s" in table %s: Too many keys! Ignoring...""",
11751195
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1176-
safe_identifier_length(table_name),
1196+
mysql_table_name,
11771197
)
11781198
elif err.errno == errorcode.ER_TOO_LONG_KEY:
11791199
self._logger.warning(
11801200
"""Failed adding index to column "%s" in table %s: Key length too long! Ignoring...""",
11811201
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1182-
safe_identifier_length(table_name),
1202+
mysql_table_name,
11831203
)
11841204
elif err.errno == errorcode.ER_BAD_FT_COLUMN:
1185-
# handle bad FULLTEXT index
11861205
self._logger.warning(
11871206
"""Failed adding FULLTEXT index to column "%s" in table %s. Retrying without FULLTEXT ...""",
11881207
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1189-
safe_identifier_length(table_name),
1208+
mysql_table_name,
11901209
)
11911210
raise
11921211
else:
11931212
self._logger.error(
11941213
"""MySQL failed adding index to column "%s" in table %s: %s""",
11951214
", ".join(safe_identifier_length(index_info["name"]) for index_info in index_infos),
1196-
safe_identifier_length(table_name),
1215+
mysql_table_name,
11971216
err,
11981217
)
11991218
raise
12001219

12011220
def _add_foreign_keys(self, table_name: str) -> None:
12021221
quoted_table_name: str = self._sqlite_quote_ident(table_name)
1222+
mysql_table_name: str = self._mysql_table_name(table_name)
12031223
self._sqlite_cur.execute(f'PRAGMA foreign_key_list("{quoted_table_name}")')
12041224

12051225
foreign_keys: t.Dict[int, t.List[t.Dict[str, t.Any]]] = {}
@@ -1219,7 +1239,7 @@ def _add_foreign_keys(self, table_name: str) -> None:
12191239
self._logger.warning(
12201240
'Skipping foreign key "%s" in table %s: partially defined reference columns.',
12211241
safe_identifier_length(fk_rows[0]["from"]),
1222-
safe_identifier_length(table_name),
1242+
mysql_table_name,
12231243
)
12241244
continue
12251245

@@ -1228,14 +1248,15 @@ def _add_foreign_keys(self, table_name: str) -> None:
12281248
self._logger.warning(
12291249
'Skipping foreign key "%s" in table %s: unable to resolve referenced primary key columns from table %s.',
12301250
safe_identifier_length(fk_rows[0]["from"]),
1231-
safe_identifier_length(table_name),
1232-
safe_identifier_length(ref_table),
1251+
mysql_table_name,
1252+
self._mysql_table_name(ref_table),
12331253
)
12341254
continue
12351255
referenced_columns = primary_keys
12361256
else:
12371257
referenced_columns = [safe_identifier_length(fk_row["to"]) for fk_row in fk_rows]
12381258

1259+
mysql_ref_table_name: str = self._mysql_table_name(ref_table)
12391260
sql = """
12401261
ALTER TABLE `{table}`
12411262
ADD CONSTRAINT `{table}_FK_{id}_{seq}`
@@ -1246,9 +1267,9 @@ def _add_foreign_keys(self, table_name: str) -> None:
12461267
""".format(
12471268
id=fk_id,
12481269
seq=fk_rows[0]["seq"],
1249-
table=safe_identifier_length(table_name),
1270+
table=mysql_table_name,
12501271
columns=", ".join(f"`{column}`" for column in from_columns),
1251-
ref_table=safe_identifier_length(ref_table),
1272+
ref_table=mysql_ref_table_name,
12521273
ref_columns=", ".join(f"`{column}`" for column in referenced_columns),
12531274
on_delete=(
12541275
fk_rows[0]["on_delete"].upper() if fk_rows[0]["on_delete"].upper() != "SET DEFAULT" else "NO ACTION"
@@ -1261,19 +1282,19 @@ def _add_foreign_keys(self, table_name: str) -> None:
12611282
try:
12621283
self._logger.info(
12631284
"Adding foreign key to %s.(%s) referencing %s.(%s)",
1264-
safe_identifier_length(table_name),
1285+
mysql_table_name,
12651286
", ".join(from_columns),
1266-
safe_identifier_length(ref_table),
1287+
mysql_ref_table_name,
12671288
", ".join(referenced_columns),
12681289
)
12691290
self._mysql_cur.execute(sql)
12701291
self._mysql.commit()
12711292
except mysql.connector.Error as err:
12721293
self._logger.error(
12731294
"MySQL failed adding foreign key to %s.(%s) referencing %s.(%s): %s",
1274-
safe_identifier_length(table_name),
1295+
mysql_table_name,
12751296
", ".join(from_columns),
1276-
safe_identifier_length(ref_table),
1297+
mysql_ref_table_name,
12771298
", ".join(referenced_columns),
12781299
err,
12791300
)
@@ -1320,6 +1341,7 @@ def transfer(self) -> None:
13201341
table_name: str = table["name"]
13211342
object_type: str = table.get("type", "table")
13221343
quoted_table_name: str = self._sqlite_quote_ident(table_name)
1344+
mysql_table_name: str = self._mysql_table_name(table_name)
13231345

13241346
# check if we're transferring rowid
13251347
transfer_rowid: bool = self._with_rowid and self._sqlite_table_has_rowid(table_name)
@@ -1391,7 +1413,7 @@ def transfer(self) -> None:
13911413
{values_clause}
13921414
ON DUPLICATE KEY UPDATE {field_updates}
13931415
""".format(
1394-
table=safe_identifier_length(table_name),
1416+
table=mysql_table_name,
13951417
fields=("`{}`, " * len(columns)).rstrip(" ,").format(*columns),
13961418
values_clause=(
13971419
"VALUES ({placeholders}) AS `__new__`"
@@ -1411,7 +1433,7 @@ def transfer(self) -> None:
14111433
VALUES ({placeholders})
14121434
""".format(
14131435
ignore="IGNORE" if self._mysql_insert_method.upper() == "IGNORE" else "",
1414-
table=safe_identifier_length(table_name),
1436+
table=mysql_table_name,
14151437
fields=("`{}`, " * len(columns)).rstrip(" ,").format(*columns),
14161438
placeholders=("%s, " * len(columns)).rstrip(" ,"),
14171439
)
@@ -1421,7 +1443,7 @@ def transfer(self) -> None:
14211443
self._logger.error(
14221444
"MySQL transfer failed inserting data into %s %s: %s",
14231445
"view" if object_type == "view" else "table",
1424-
safe_identifier_length(table_name),
1446+
mysql_table_name,
14251447
err,
14261448
)
14271449
raise

src/sqlite3_to_mysql/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class SQLite3toMySQLParams(TypedDict):
4747
mysql_insert_method: t.Optional[str]
4848
mysql_string_type: t.Optional[str]
4949
mysql_text_type: t.Optional[str]
50+
mysql_table_prefix: t.Optional[str]
5051

5152

5253
class SQLite3toMySQLAttributes:
@@ -75,6 +76,7 @@ class SQLite3toMySQLAttributes:
7576
_mysql_integer_type: str
7677
_mysql_string_type: str
7778
_mysql_text_type: str
79+
_mysql_table_prefix: str
7880
_mysql_charset: str
7981
_mysql_collation: str
8082
_ignore_duplicate_keys: bool

0 commit comments

Comments
 (0)