Skip to content

Commit c69c712

Browse files
authored
✨ add support for SQLite's JSONB column type (#156)
1 parent 1847200 commit c69c712

File tree

3 files changed

+205
-2
lines changed

3 files changed

+205
-2
lines changed

src/sqlite3_to_mysql/sqlite_utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,14 @@ def check_sqlite_table_xinfo_support(version_string: str) -> bool:
5353
"""Check for SQLite table_xinfo support."""
5454
sqlite_version: Version = version.parse(version_string)
5555
return sqlite_version.major > 3 or (sqlite_version.major == 3 and sqlite_version.minor >= 26)
56+
57+
58+
def check_sqlite_jsonb_support(version_string: str) -> bool:
59+
"""Check for SQLite JSONB support."""
60+
sqlite_version: Version = version.parse(version_string)
61+
return sqlite_version.major > 3 or (sqlite_version.major == 3 and sqlite_version.minor >= 45)
62+
63+
64+
def sqlite_jsonb_column_expression(quoted_column_name: str) -> str:
65+
"""Return a SELECT expression that converts JSONB blobs to textual JSON while preserving NULLs."""
66+
return 'CASE WHEN "{name}" IS NULL THEN NULL ELSE json("{name}") END AS "{name}"'.format(name=quoted_column_name)

src/sqlite3_to_mysql/transporter.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@
3636
from sqlite3_to_mysql.sqlite_utils import (
3737
adapt_decimal,
3838
adapt_timedelta,
39+
check_sqlite_jsonb_support,
3940
check_sqlite_table_xinfo_support,
4041
convert_date,
4142
convert_decimal,
4243
convert_timedelta,
44+
sqlite_jsonb_column_expression,
4345
unicase_compare,
4446
)
4547

@@ -188,6 +190,7 @@ def __init__(self, **kwargs: Unpack[SQLite3toMySQLParams]):
188190

189191
self._sqlite_version = self._get_sqlite_version()
190192
self._sqlite_table_xinfo_support = check_sqlite_table_xinfo_support(self._sqlite_version)
193+
self._sqlite_jsonb_support = check_sqlite_jsonb_support(self._sqlite_version)
191194

192195
self._mysql_create_tables = bool(kwargs.get("mysql_create_tables", True))
193196
self._mysql_transfer_data = bool(kwargs.get("mysql_transfer_data", True))
@@ -304,6 +307,13 @@ def _get_table_info(self, table_name: str) -> t.List[t.Dict[str, t.Any]]:
304307
self._sqlite_cur.execute(f'PRAGMA {pragma}("{quoted_table_name}")')
305308
return [dict(row) for row in self._sqlite_cur.fetchall()]
306309

310+
@staticmethod
311+
def _declared_type_is_jsonb(column_type: t.Optional[str]) -> bool:
312+
"""Return True when a SQLite column is declared as JSONB."""
313+
if not column_type:
314+
return False
315+
return column_type.strip().upper().startswith("JSONB")
316+
307317
def _get_table_primary_key_columns(self, table_name: str) -> t.List[str]:
308318
"""Return visible primary key columns ordered by their PK sequence."""
309319
primary_key_rows: t.List[t.Dict[str, t.Any]] = sorted(
@@ -516,6 +526,8 @@ def _translate_type_from_sqlite_to_mysql_legacy(self, column_type: str) -> str:
516526
return "TINYINT(1)"
517527
if data_type.startswith(("REAL", "DOUBLE", "FLOAT", "DECIMAL", "DEC", "FIXED")):
518528
return full_column_type
529+
if data_type == "JSONB" or data_type.startswith("JSONB"):
530+
return "JSON" if self._mysql_json_support else self._mysql_text_type
519531
if data_type not in MYSQL_COLUMN_TYPES:
520532
return self._mysql_string_type
521533
return full_column_type
@@ -1323,8 +1335,36 @@ def transfer(self) -> None:
13231335
"view" if object_type == "view" else "table",
13241336
table_name,
13251337
)
1338+
table_column_info: t.List[t.Dict[str, t.Any]] = self._get_table_info(table_name)
1339+
visible_columns: t.List[t.Dict[str, t.Any]] = [
1340+
column for column in table_column_info if column.get("hidden", 0) != 1
1341+
]
1342+
jsonb_columns: t.Set[str]
1343+
if self._sqlite_jsonb_support:
1344+
jsonb_columns = {
1345+
str(column["name"])
1346+
for column in visible_columns
1347+
if column.get("name") and self._declared_type_is_jsonb(column.get("type"))
1348+
}
1349+
else:
1350+
jsonb_columns = set()
1351+
1352+
select_parts: t.List[str] = []
13261353
if transfer_rowid:
1327-
select_list: str = 'rowid as "rowid", *'
1354+
select_parts.append('rowid AS "rowid"')
1355+
1356+
for column in visible_columns:
1357+
column_name: t.Optional[str] = column.get("name")
1358+
if not column_name:
1359+
continue
1360+
quoted_column: str = self._sqlite_quote_ident(column_name)
1361+
if column_name in jsonb_columns:
1362+
select_parts.append(sqlite_jsonb_column_expression(quoted_column))
1363+
else:
1364+
select_parts.append(f'"{quoted_column}"')
1365+
1366+
if select_parts:
1367+
select_list = ", ".join(select_parts)
13281368
else:
13291369
select_list = "*"
13301370
self._sqlite_cur.execute(f'SELECT {select_list} FROM "{quoted_table_name}"')

tests/unit/sqlite3_to_mysql_test.py

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from tests.conftest import MySQLCredentials
2525

2626

27+
SQLITE_SUPPORTS_JSONB: bool = sqlite3.sqlite_version_info >= (3, 45, 0)
28+
29+
2730
def test_cli_sqlite_views_flag_propagates(
2831
cli_runner: CliRunner,
2932
sqlite_database: str,
@@ -398,9 +401,35 @@ def _make_transfer_stub(mocker: MockFixture) -> SQLite3toMySQL:
398401
instance._translate_sqlite_view_definition = mocker.MagicMock(return_value="CREATE VIEW translated AS SELECT 1")
399402
instance._sqlite_cur.fetchall.return_value = []
400403
instance._sqlite_cur.execute.return_value = None
404+
instance._get_table_info = mocker.MagicMock(
405+
return_value=[
406+
{"name": "c1", "type": "TEXT", "hidden": 0},
407+
]
408+
)
409+
instance._sqlite_jsonb_support = True
401410
return instance
402411

403412

413+
class RecordingMySQLCursor:
414+
def __init__(self) -> None:
415+
self.executed_sql: t.List[str] = []
416+
self.inserted_batches: t.List[t.List[t.Tuple[t.Any, ...]]] = []
417+
418+
def execute(self, sql: str, params: t.Optional[t.Tuple[t.Any, ...]] = None) -> None:
419+
del params
420+
self.executed_sql.append(sql)
421+
422+
def fetchall(self) -> t.List[t.Any]:
423+
return []
424+
425+
def fetchone(self) -> t.Optional[t.Any]:
426+
return None
427+
428+
def executemany(self, sql: str, rows: t.Iterable[t.Tuple[t.Any, ...]]) -> None:
429+
self.executed_sql.append(sql)
430+
self.inserted_batches.append([tuple(row) for row in rows])
431+
432+
404433
def test_transfer_creates_mysql_views(mocker: MockFixture) -> None:
405434
instance = _make_transfer_stub(mocker)
406435

@@ -472,7 +501,112 @@ def execute_side_effect(sql, *params):
472501

473502
executed_sqls = [call.args[0] for call in instance._sqlite_cur.execute.call_args_list]
474503
assert 'SELECT COUNT(*) AS total_records FROM "tbl""quote"' in executed_sqls
475-
assert 'SELECT * FROM "tbl""quote"' in executed_sqls
504+
assert 'SELECT "c1" FROM "tbl""quote"' in executed_sqls
505+
506+
507+
def test_transfer_selects_jsonb_columns_via_json_function(mocker: MockFixture) -> None:
508+
instance = _make_transfer_stub(mocker)
509+
instance._mysql_transfer_data = True
510+
instance._sqlite_cur.fetchone.return_value = {"total_records": 1}
511+
instance._sqlite_cur.fetchall.return_value = [(1, b"blob")]
512+
instance._get_table_info.return_value = [
513+
{"name": "id", "type": "INTEGER", "hidden": 0},
514+
{"name": "payload", "type": "JSONB", "hidden": 0},
515+
]
516+
517+
def execute_side_effect(sql, *params):
518+
del params
519+
if 'json("payload")' in sql:
520+
instance._sqlite_cur.description = [("id",), ("payload",)]
521+
return None
522+
523+
instance._sqlite_cur.execute.side_effect = execute_side_effect
524+
instance._fetch_sqlite_master_rows = mocker.MagicMock(side_effect=[[{"name": "tbl", "type": "table"}], []])
525+
526+
instance.transfer()
527+
528+
executed_sqls = [call.args[0] for call in instance._sqlite_cur.execute.call_args_list]
529+
json_selects = [sql for sql in executed_sqls if 'json("payload")' in sql]
530+
assert json_selects
531+
532+
533+
def test_transfer_leaves_jsonb_columns_when_sqlite_lacks_support(mocker: MockFixture) -> None:
534+
instance = _make_transfer_stub(mocker)
535+
instance._sqlite_jsonb_support = False
536+
instance._mysql_transfer_data = True
537+
instance._sqlite_cur.fetchone.return_value = {"total_records": 1}
538+
instance._sqlite_cur.fetchall.return_value = [(1, b"blob")]
539+
instance._get_table_info.return_value = [
540+
{"name": "id", "type": "INTEGER", "hidden": 0},
541+
{"name": "payload", "type": "JSONB", "hidden": 0},
542+
]
543+
544+
def execute_side_effect(sql, *params):
545+
del params
546+
if sql.startswith("SELECT ") and "FROM" in sql and "COUNT" not in sql.upper():
547+
instance._sqlite_cur.description = [("id",), ("payload",)]
548+
return None
549+
550+
instance._sqlite_cur.execute.side_effect = execute_side_effect
551+
instance._fetch_sqlite_master_rows = mocker.MagicMock(side_effect=[[{"name": "tbl", "type": "table"}], []])
552+
553+
instance.transfer()
554+
555+
executed_sqls = [call.args[0] for call in instance._sqlite_cur.execute.call_args_list]
556+
assert all('json("payload")' not in sql for sql in executed_sqls)
557+
558+
559+
@pytest.mark.skipif(not SQLITE_SUPPORTS_JSONB, reason="SQLite 3.45+ required for JSONB tests")
560+
def test_transfer_converts_jsonb_values_to_textual_json(mocker: MockFixture) -> None:
561+
sqlite_connection = sqlite3.connect(":memory:", detect_types=sqlite3.PARSE_DECLTYPES)
562+
sqlite_connection.row_factory = sqlite3.Row
563+
sqlite_cursor = sqlite_connection.cursor()
564+
sqlite_cursor.execute("CREATE TABLE data (id INTEGER PRIMARY KEY, payload JSONB)")
565+
sqlite_cursor.execute("INSERT INTO data(payload) VALUES (jsonb(?))", ('{"foo":"bar"}',))
566+
sqlite_cursor.execute("INSERT INTO data(payload) VALUES (NULL)")
567+
sqlite_connection.commit()
568+
569+
instance = SQLite3toMySQL.__new__(SQLite3toMySQL)
570+
instance._sqlite = sqlite_connection
571+
instance._sqlite_cur = sqlite_connection.cursor()
572+
instance._sqlite_tables = tuple()
573+
instance._exclude_sqlite_tables = tuple()
574+
instance._sqlite_views_as_tables = False
575+
instance._sqlite_table_xinfo_support = True
576+
instance._sqlite_jsonb_support = SQLITE_SUPPORTS_JSONB
577+
instance._mysql_create_tables = False
578+
instance._mysql_transfer_data = True
579+
instance._mysql_truncate_tables = False
580+
instance._mysql_insert_method = "IGNORE"
581+
instance._mysql_version = "8.0.32"
582+
instance._without_foreign_keys = True
583+
instance._use_fulltext = False
584+
instance._mysql_fulltext_support = False
585+
instance._with_rowid = False
586+
instance._chunk_size = None
587+
instance._quiet = True
588+
instance._mysql_charset = "utf8mb4"
589+
instance._mysql_collation = "utf8mb4_unicode_ci"
590+
instance._mysql_cur = RecordingMySQLCursor()
591+
instance._mysql = mocker.MagicMock()
592+
instance._mysql.commit = mocker.MagicMock()
593+
instance._logger = mocker.MagicMock()
594+
instance._create_table = mocker.MagicMock()
595+
instance._truncate_table = mocker.MagicMock()
596+
instance._add_indices = mocker.MagicMock()
597+
instance._add_foreign_keys = mocker.MagicMock()
598+
instance._create_mysql_view = mocker.MagicMock()
599+
instance._translate_sqlite_view_definition = mocker.MagicMock()
600+
instance._sqlite_table_has_rowid = lambda _table: False
601+
instance._fetch_sqlite_master_rows = mocker.MagicMock(side_effect=[[{"name": "data", "type": "table"}], []])
602+
603+
instance.transfer()
604+
605+
assert instance._mysql_cur.inserted_batches, "expected captured MySQL inserts"
606+
inserted_rows = instance._mysql_cur.inserted_batches[0]
607+
payload_by_id = {row[0]: row[1] for row in inserted_rows}
608+
assert payload_by_id[1] == '{"foo":"bar"}'
609+
assert payload_by_id[2] is None
476610

477611

478612
def test_translate_sqlite_view_definition_strftime_weekday() -> None:
@@ -554,6 +688,24 @@ def test_transfer_table_data_with_chunking(mocker: MockFixture) -> None:
554688
instance._mysql.commit.assert_called_once()
555689

556690

691+
@pytest.mark.parametrize(
692+
"json_support,expected",
693+
[
694+
(True, "JSON"),
695+
(False, "TEXT"),
696+
],
697+
)
698+
def test_translate_type_from_sqlite_maps_jsonb_to_json(json_support: bool, expected: str) -> None:
699+
instance = SQLite3toMySQL.__new__(SQLite3toMySQL)
700+
instance._mysql_text_type = "TEXT"
701+
instance._mysql_string_type = "VARCHAR(255)"
702+
instance._mysql_integer_type = "INT"
703+
instance._mysql_json_support = json_support
704+
705+
assert instance._translate_type_from_sqlite_to_mysql("JSONB") == expected
706+
assert instance._translate_type_from_sqlite_to_mysql("jsonb(16)") == expected
707+
708+
557709
@pytest.mark.usefixtures("sqlite_database", "mysql_instance")
558710
class TestSQLite3toMySQL:
559711
@pytest.mark.parametrize("quiet", [False, True])

0 commit comments

Comments
 (0)