Skip to content

Commit 8ddd91a

Browse files
authored
✨ add support for creating SQLite STRICT tables via -M/--strict CLI switch (#107)
1 parent e34caec commit 8ddd91a

File tree

6 files changed

+177
-1
lines changed

6 files changed

+177
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Options:
6060
-X, --without-foreign-keys Do not transfer foreign keys.
6161
-Z, --without-tables Do not transfer tables, data only.
6262
-W, --without-data Do not transfer table data, DDL only.
63+
-M, --strict Create SQLite STRICT tables when supported.
6364
-h, --mysql-host TEXT MySQL host. Defaults to localhost.
6465
-P, --mysql-port INTEGER MySQL port. Defaults to 3306.
6566
--mysql-charset TEXT MySQL database and table character set

docs/README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Transfer Options
3838
- ``-X, --without-foreign-keys``: Do not transfer foreign keys.
3939
- ``-Z, --without-tables``: Do not transfer tables, data only.
4040
- ``-W, --without-data``: Do not transfer table data, DDL only.
41+
- ``-M, --strict``: Create SQLite STRICT tables when supported.
4142

4243
Connection Options
4344
""""""""""""""""""

src/mysql_to_sqlite3/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
is_flag=True,
107107
help="Do not transfer table data, DDL only.",
108108
)
109+
@click.option(
110+
"-M",
111+
"--strict",
112+
is_flag=True,
113+
help="Create SQLite STRICT tables when supported.",
114+
)
109115
@click.option("-h", "--mysql-host", default="localhost", help="MySQL host. Defaults to localhost.")
110116
@click.option("-P", "--mysql-port", type=int, default=3306, help="MySQL port. Defaults to 3306.")
111117
@click.option(
@@ -167,6 +173,7 @@ def cli(
167173
without_foreign_keys: bool,
168174
without_tables: bool,
169175
without_data: bool,
176+
strict: bool,
170177
mysql_host: str,
171178
mysql_port: int,
172179
mysql_charset: str,
@@ -215,6 +222,7 @@ def cli(
215222
without_foreign_keys=without_foreign_keys or bool(mysql_tables) or bool(exclude_mysql_tables),
216223
without_tables=without_tables,
217224
without_data=without_data,
225+
sqlite_strict=strict,
218226
mysql_host=mysql_host,
219227
mysql_port=mysql_port,
220228
mysql_charset=mysql_charset,

src/mysql_to_sqlite3/transporter.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,17 @@ def __init__(self, **kwargs: Unpack[MySQLtoSQLiteParams]) -> None:
120120

121121
self._quiet = bool(kwargs.get("quiet", False))
122122

123+
self._sqlite_strict = bool(kwargs.get("sqlite_strict", False))
124+
123125
self._logger = self._setup_logger(log_file=kwargs.get("log_file") or None, quiet=self._quiet)
124126

127+
if self._sqlite_strict and sqlite3.sqlite_version < "3.37.0":
128+
self._logger.warning(
129+
"SQLite version %s does not support STRICT tables. Tables will be created without strict mode.",
130+
sqlite3.sqlite_version,
131+
)
132+
self._sqlite_strict = False
133+
125134
sqlite3.register_adapter(Decimal, adapt_decimal)
126135
sqlite3.register_converter("DECIMAL", convert_decimal)
127136
sqlite3.register_adapter(timedelta, adapt_timedelta)
@@ -570,7 +579,10 @@ def _build_create_table_sql(self, table_name: str) -> str:
570579
"ON DELETE {on_delete}".format(**foreign_key) # type: ignore[str-bytes-safe]
571580
)
572581

573-
sql += "\n);"
582+
sql += "\n)"
583+
if self._sqlite_strict:
584+
sql += " STRICT"
585+
sql += ";\n"
574586
sql += indices
575587

576588
return sql

src/mysql_to_sqlite3/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class MySQLtoSQLiteParams(TypedDict):
3939
prefix_indices: t.Optional[bool]
4040
quiet: t.Optional[bool]
4141
sqlite_file: t.Union[str, "os.PathLike[t.Any]"]
42+
sqlite_strict: t.Optional[bool]
4243
vacuum: t.Optional[bool]
4344
without_tables: t.Optional[bool]
4445
without_data: t.Optional[bool]
@@ -74,6 +75,7 @@ class MySQLtoSQLiteAttributes:
7475
_sqlite: Connection
7576
_sqlite_cur: Cursor
7677
_sqlite_file: t.Union[str, "os.PathLike[t.Any]"]
78+
_sqlite_strict: bool
7779
_without_tables: bool
7880
_sqlite_json1_extension_enabled: bool
7981
_vacuum: bool

tests/unit/test_transporter.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import builtins
12
import sqlite3
23
from unittest.mock import MagicMock, patch
34

45
import pytest
6+
from pytest_mock import MockerFixture
57

8+
from mysql_to_sqlite3.sqlite_utils import CollatingSequences
69
from mysql_to_sqlite3.transporter import MySQLtoSQLite
710

811

@@ -152,6 +155,155 @@ def test_transfer_exception_handling(self, mock_sqlite_connect: MagicMock, mock_
152155
# Verify that foreign keys are re-enabled in the finally block
153156
mock_sqlite_cursor.execute.assert_called_with("PRAGMA foreign_keys=ON")
154157

158+
@patch("mysql_to_sqlite3.transporter.sqlite3.connect")
159+
@patch("mysql_to_sqlite3.transporter.mysql.connector.connect")
160+
def test_sqlite_strict_supported_keeps_flag(
161+
self,
162+
mock_mysql_connect: MagicMock,
163+
mock_sqlite_connect: MagicMock,
164+
mocker: MockerFixture,
165+
) -> None:
166+
"""Ensure STRICT mode remains enabled when SQLite supports it."""
167+
168+
class FakeMySQLConnection:
169+
def __init__(self) -> None:
170+
self.database = None
171+
172+
def is_connected(self) -> bool:
173+
return True
174+
175+
def cursor(self, *args, **kwargs) -> MagicMock:
176+
return MagicMock()
177+
178+
mock_logger = MagicMock()
179+
mocker.patch.object(MySQLtoSQLite, "_setup_logger", return_value=mock_logger)
180+
mocker.patch("mysql_to_sqlite3.transporter.sqlite3.sqlite_version", "3.38.0")
181+
mock_mysql_connect.return_value = FakeMySQLConnection()
182+
183+
mock_sqlite_cursor = MagicMock()
184+
mock_sqlite_connection = MagicMock()
185+
mock_sqlite_connection.cursor.return_value = mock_sqlite_cursor
186+
mock_sqlite_connect.return_value = mock_sqlite_connection
187+
188+
from mysql_to_sqlite3 import transporter as transporter_module
189+
190+
original_isinstance = builtins.isinstance
191+
192+
def fake_isinstance(obj: object, classinfo: object) -> bool:
193+
if classinfo is transporter_module.MySQLConnectionAbstract:
194+
return True
195+
return original_isinstance(obj, classinfo)
196+
197+
mocker.patch("mysql_to_sqlite3.transporter.isinstance", side_effect=fake_isinstance)
198+
199+
instance = MySQLtoSQLite(
200+
sqlite_file="file.db",
201+
mysql_user="user",
202+
mysql_password=None,
203+
mysql_database="db",
204+
mysql_host="localhost",
205+
mysql_port=3306,
206+
sqlite_strict=True,
207+
)
208+
209+
assert instance._sqlite_strict is True
210+
mock_logger.warning.assert_not_called()
211+
212+
@patch("mysql_to_sqlite3.transporter.sqlite3.connect")
213+
@patch("mysql_to_sqlite3.transporter.mysql.connector.connect")
214+
def test_sqlite_strict_unsupported_disables_flag(
215+
self,
216+
mock_mysql_connect: MagicMock,
217+
mock_sqlite_connect: MagicMock,
218+
mocker: MockerFixture,
219+
) -> None:
220+
"""Ensure STRICT mode is disabled with a warning on old SQLite versions."""
221+
222+
class FakeMySQLConnection:
223+
def __init__(self) -> None:
224+
self.database = None
225+
226+
def is_connected(self) -> bool:
227+
return True
228+
229+
def cursor(self, *args, **kwargs) -> MagicMock:
230+
return MagicMock()
231+
232+
mock_logger = MagicMock()
233+
mocker.patch.object(MySQLtoSQLite, "_setup_logger", return_value=mock_logger)
234+
mocker.patch("mysql_to_sqlite3.transporter.sqlite3.sqlite_version", "3.36.0")
235+
mock_mysql_connect.return_value = FakeMySQLConnection()
236+
237+
mock_sqlite_cursor = MagicMock()
238+
mock_sqlite_connection = MagicMock()
239+
mock_sqlite_connection.cursor.return_value = mock_sqlite_cursor
240+
mock_sqlite_connect.return_value = mock_sqlite_connection
241+
242+
from mysql_to_sqlite3 import transporter as transporter_module
243+
244+
original_isinstance = builtins.isinstance
245+
246+
def fake_isinstance(obj: object, classinfo: object) -> bool:
247+
if classinfo is transporter_module.MySQLConnectionAbstract:
248+
return True
249+
return original_isinstance(obj, classinfo)
250+
251+
mocker.patch("mysql_to_sqlite3.transporter.isinstance", side_effect=fake_isinstance)
252+
253+
instance = MySQLtoSQLite(
254+
sqlite_file="file.db",
255+
mysql_user="user",
256+
mysql_password=None,
257+
mysql_database="db",
258+
mysql_host="localhost",
259+
mysql_port=3306,
260+
sqlite_strict=True,
261+
)
262+
263+
assert instance._sqlite_strict is False
264+
mock_logger.warning.assert_called_once()
265+
266+
def test_build_create_table_sql_appends_strict(self) -> None:
267+
"""Ensure STRICT is appended to CREATE TABLE statements when enabled."""
268+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
269+
instance = MySQLtoSQLite()
270+
271+
instance._sqlite_strict = True
272+
instance._sqlite_json1_extension_enabled = False
273+
instance._mysql_cur_dict = MagicMock()
274+
instance._mysql_cur_dict.fetchall.side_effect = [
275+
[
276+
{
277+
"Field": "id",
278+
"Type": "INTEGER",
279+
"Null": "NO",
280+
"Default": None,
281+
"Key": "PRI",
282+
"Extra": "auto_increment",
283+
},
284+
{
285+
"Field": "name",
286+
"Type": "TEXT",
287+
"Null": "NO",
288+
"Default": None,
289+
"Key": "",
290+
"Extra": "",
291+
},
292+
],
293+
[],
294+
]
295+
instance._mysql_cur_dict.fetchone.return_value = {"count": 0}
296+
instance._mysql_database = "db"
297+
instance._collation = CollatingSequences.BINARY
298+
instance._prefix_indices = False
299+
instance._without_tables = False
300+
instance._without_foreign_keys = True
301+
instance._logger = MagicMock()
302+
303+
sql = instance._build_create_table_sql("products")
304+
305+
assert "STRICT;" in sql
306+
155307
def test_constructor_missing_mysql_database(self) -> None:
156308
"""Test constructor raises ValueError if mysql_database is missing."""
157309
from mysql_to_sqlite3.transporter import MySQLtoSQLite

0 commit comments

Comments
 (0)