Skip to content

Commit bdbe09d

Browse files
authored
🐛 remove TABLE_SCHEMA from SQLite views (#112)
1 parent 05ad606 commit bdbe09d

File tree

4 files changed

+90
-50
lines changed

4 files changed

+90
-50
lines changed

src/mysql_to_sqlite3/transporter.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -895,37 +895,37 @@ def _create_table(self, table_name: str, attempting_reconnect: bool = False) ->
895895
self._logger.error("SQLite failed creating table %s: %s", table_name, err)
896896
raise
897897

898-
@staticmethod
899-
def _mysql_viewdef_to_sqlite(
900-
view_select_sql: str,
901-
view_name: str,
902-
schema_name: t.Optional[str] = None,
903-
keep_schema: bool = False,
904-
) -> str:
905-
"""
906-
Convert a MySQL VIEW_DEFINITION (a SELECT ...) to a SQLite CREATE VIEW statement.
907-
908-
If keep_schema is False and schema_name is provided, strip qualifiers like `example`.table.
909-
If keep_schema is True, you must ATTACH the SQLite database as that schema name before using the view.
910-
"""
898+
def _mysql_viewdef_to_sqlite(self, view_select_sql: str, view_name: str) -> str:
899+
"""Convert a MySQL VIEW_DEFINITION (a SELECT ...) to a SQLite CREATE VIEW statement."""
911900
# Normalize whitespace and avoid double semicolons in output
912901
cleaned_sql = view_select_sql.strip().rstrip(";")
913902

914903
try:
915-
tree = parse_one(cleaned_sql, read="mysql")
916-
except (ParseError, ValueError, Exception): # pylint: disable=W0718
917-
# Fallback: return a basic CREATE VIEW using the original SELECT
918-
return f'CREATE VIEW IF NOT EXISTS "{view_name}" AS\n{cleaned_sql};'
919-
920-
if not keep_schema and schema_name:
921-
# Remove schema qualifiers that match schema_name
922-
for tbl in tree.find_all(exp.Table):
923-
db = tbl.args.get("db")
924-
if db and db.name.strip('`"') == schema_name:
925-
tbl.set("db", None)
926-
927-
sqlite_select = tree.sql(dialect="sqlite")
928-
return f'CREATE VIEW IF NOT EXISTS "{view_name}" AS\n{sqlite_select};'
904+
tree: Expression = parse_one(cleaned_sql, read="mysql")
905+
except (ParseError, ValueError, AttributeError, TypeError):
906+
# Fallback: try to remove schema qualifiers if requested, then return
907+
stripped_sql = cleaned_sql
908+
# Remove qualifiers `schema`.tbl or "schema".tbl or schema.tbl
909+
sn: str = re.escape(self._mysql_database)
910+
for pat in (rf"`{sn}`\.", rf'"{sn}"\.', rf"\b{sn}\."):
911+
stripped_sql = re.sub(pat, "", stripped_sql, flags=re.IGNORECASE)
912+
view_ident = self._quote_sqlite_identifier(view_name)
913+
return f"CREATE VIEW IF NOT EXISTS {view_ident} AS\n{stripped_sql};"
914+
915+
# Remove schema qualifiers that match schema_name on tables
916+
for tbl in tree.find_all(exp.Table):
917+
db = tbl.args.get("db")
918+
if db and db.name.strip('`"').lower() == self._mysql_database.lower():
919+
tbl.set("db", None)
920+
# Also remove schema qualifiers on fully-qualified columns (db.table.column)
921+
for col in tree.find_all(exp.Column):
922+
db = col.args.get("db")
923+
if db and db.name.strip('`"').lower() == self._mysql_database.lower():
924+
col.set("db", None)
925+
926+
sqlite_select: str = tree.sql(dialect="sqlite")
927+
view_ident = self._quote_sqlite_identifier(view_name)
928+
return f"CREATE VIEW IF NOT EXISTS {view_ident} AS\n{sqlite_select};"
929929

930930
def _build_create_view_sql(self, view_name: str) -> str:
931931
"""Build a CREATE VIEW statement for SQLite from a MySQL VIEW definition."""
@@ -990,7 +990,6 @@ def _build_create_view_sql(self, view_name: str) -> str:
990990
return self._mysql_viewdef_to_sqlite(
991991
view_name=view_name,
992992
view_select_sql=definition,
993-
schema_name=self._mysql_database,
994993
)
995994

996995
def _create_view(self, view_name: str, attempting_reconnect: bool = False) -> None:

tests/unit/test_views_build_paths_extra.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,9 @@ def test_build_create_view_sql_information_schema_bytes_decode_failure_falls_bac
2626

2727
captured = {}
2828

29-
def fake_converter(*, view_select_sql: str, view_name: str, schema_name: str, keep_schema: bool = False) -> str:
29+
def fake_converter(*, view_select_sql: str, view_name: str) -> str:
3030
captured["view_select_sql"] = view_select_sql
3131
captured["view_name"] = view_name
32-
captured["schema_name"] = schema_name
33-
captured["keep_schema"] = keep_schema
3432
return f'CREATE VIEW IF NOT EXISTS "{view_name}" AS SELECT 1;'
3533

3634
monkeypatch.setattr(MySQLtoSQLite, "_mysql_viewdef_to_sqlite", staticmethod(fake_converter))
@@ -39,7 +37,6 @@ def fake_converter(*, view_select_sql: str, view_name: str, schema_name: str, ke
3937

4038
# Converter was invoked with the string representation of the undecodable bytes
4139
assert captured["view_name"] == "v_strange"
42-
assert captured["schema_name"] == "db"
4340
assert isinstance(captured["view_select_sql"], str)
4441
# And a CREATE VIEW statement was produced
4542
assert sql.startswith('CREATE VIEW IF NOT EXISTS "v_strange" AS')

tests/unit/test_views_create_view.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,9 @@ def capture_execute(sql: str) -> None:
3838
# Capture the definition passed to _mysql_viewdef_to_sqlite and return a dummy SQL
3939
captured: t.Dict[str, str] = {}
4040

41-
def fake_mysql_viewdef_to_sqlite(
42-
*, view_select_sql: str, view_name: str, schema_name: t.Optional[str] = None, keep_schema: bool = False
43-
) -> str:
41+
def fake_mysql_viewdef_to_sqlite(*, view_select_sql: str, view_name: str) -> str:
4442
captured["select"] = view_select_sql
4543
captured["view_name"] = view_name
46-
captured["schema_name"] = schema_name or ""
47-
captured["keep_schema"] = str(keep_schema)
4844
return 'CREATE VIEW IF NOT EXISTS "dummy" AS SELECT 1;'
4945

5046
monkeypatch.setattr(MySQLtoSQLite, "_mysql_viewdef_to_sqlite", staticmethod(fake_mysql_viewdef_to_sqlite))
@@ -64,5 +60,3 @@ def fake_mysql_viewdef_to_sqlite(
6460
assert captured["select"] == "SELECT 1 AS `x`"
6561
# Check view_name was threaded unchanged to the converter
6662
assert captured["view_name"] == "we`ird"
67-
# Schema name also provided
68-
assert captured["schema_name"] == "db"

tests/unit/test_views_sqlglot.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from unittest.mock import patch
23

34
import pytest
45

@@ -8,10 +9,13 @@
89
class TestViewsSqlglot:
910
def test_mysql_viewdef_to_sqlite_strips_schema_and_transpiles(self) -> None:
1011
mysql_select = "SELECT `u`.`id`, `u`.`name` FROM `db`.`users` AS `u` WHERE `u`.`id` > 1"
11-
sql = MySQLtoSQLite._mysql_viewdef_to_sqlite(
12+
# Use an instance to ensure access to _mysql_database for stripping
13+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
14+
inst = MySQLtoSQLite() # type: ignore[call-arg]
15+
inst._mysql_database = "db" # type: ignore[attr-defined]
16+
sql = inst._mysql_viewdef_to_sqlite(
1217
view_select_sql=mysql_select,
1318
view_name="v_users",
14-
schema_name="db",
1519
)
1620
assert sql.startswith('CREATE VIEW IF NOT EXISTS "v_users" AS')
1721
# Ensure schema qualifier was removed
@@ -26,29 +30,75 @@ def test_mysql_viewdef_to_sqlite_parse_fallback(self, monkeypatch: pytest.Monkey
2630
# Force parse_one to raise so we hit the fallback path
2731
from sqlglot.errors import ParseError
2832

29-
def boom(*args, **kwargs):
33+
def boom(*_, **__):
3034
raise ParseError("boom")
3135

3236
monkeypatch.setattr("mysql_to_sqlite3.transporter.parse_one", boom)
3337

3438
sql_in = "SELECT 1"
35-
out = MySQLtoSQLite._mysql_viewdef_to_sqlite(
39+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
40+
inst = MySQLtoSQLite() # type: ignore[call-arg]
41+
inst._mysql_database = "db" # type: ignore[attr-defined]
42+
out = inst._mysql_viewdef_to_sqlite(
3643
view_select_sql=sql_in,
3744
view_name="v1",
38-
schema_name="db",
3945
)
4046
assert out.startswith('CREATE VIEW IF NOT EXISTS "v1" AS')
4147
assert "SELECT 1" in out
4248
assert out.strip().endswith(";")
4349

44-
def test_mysql_viewdef_to_sqlite_keep_schema_true_preserves_qualifiers(self) -> None:
50+
def test_mysql_viewdef_to_sqlite_parse_fallback_strips_schema(self, monkeypatch: pytest.MonkeyPatch) -> None:
51+
# Force parse_one to raise so we exercise the fallback path with schema qualifiers
52+
from sqlglot.errors import ParseError
53+
54+
def boom(*_, **__):
55+
raise ParseError("boom")
56+
57+
monkeypatch.setattr("mysql_to_sqlite3.transporter.parse_one", boom)
58+
59+
mysql_select = "SELECT `u`.`id` FROM `db`.`users` AS `u` WHERE `u`.`id` > 1"
60+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
61+
inst = MySQLtoSQLite() # type: ignore[call-arg]
62+
inst._mysql_database = "db" # type: ignore[attr-defined]
63+
out = inst._mysql_viewdef_to_sqlite(
64+
view_select_sql=mysql_select,
65+
view_name="v_users",
66+
)
67+
# Should not contain schema qualifier anymore
68+
assert "`db`." not in out and '"db".' not in out and " db." not in out
69+
# Should still reference the table name
70+
assert "FROM `users`" in out or 'FROM "users"' in out or "FROM users" in out
71+
assert out.strip().endswith(";")
72+
73+
def test_mysql_viewdef_to_sqlite_strips_schema_from_qualified_columns_nested(self) -> None:
74+
# Based on the user-reported example with nested subquery and fully-qualified columns
75+
mysql_sql = (
76+
"select `p`.`instrument_id` AS `instrument_id`,`p`.`price_date` AS `price_date`,`p`.`close` AS `close` "
77+
"from (`example`.`prices` `p` join (select `example`.`prices`.`instrument_id` AS `instrument_id`,"
78+
"max(`example`.`prices`.`price_date`) AS `max_date` from `example`.`prices` group by "
79+
"`example`.`prices`.`instrument_id`) `t` on(((`t`.`instrument_id` = `p`.`instrument_id`) and "
80+
"(`t`.`max_date` = `p`.`price_date`))))"
81+
)
82+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
83+
inst = MySQLtoSQLite() # type: ignore[call-arg]
84+
inst._mysql_database = "example" # type: ignore[attr-defined]
85+
out = inst._mysql_viewdef_to_sqlite(view_select_sql=mysql_sql, view_name="v_prices")
86+
# Ensure all schema qualifiers are removed, including on qualified columns inside subqueries
87+
assert '"example".' not in out and "`example`." not in out and " example." not in out
88+
# Still references the base table name
89+
assert 'FROM "prices"' in out or 'FROM ("prices"' in out or "FROM prices" in out
90+
assert out.strip().endswith(";")
91+
92+
def test_mysql_viewdef_to_sqlite_strips_matching_schema_qualifiers(self) -> None:
4593
mysql_select = "SELECT `u`.`id` FROM `db`.`users` AS `u`"
46-
sql = MySQLtoSQLite._mysql_viewdef_to_sqlite(
94+
# Use instance for consistent attribute access
95+
with patch.object(MySQLtoSQLite, "__init__", return_value=None):
96+
inst = MySQLtoSQLite() # type: ignore[call-arg]
97+
inst._mysql_database = "db" # type: ignore[attr-defined]
98+
# Since keep_schema behavior is no longer parameterized, ensure that if schema matches current db, it is stripped
99+
sql = inst._mysql_viewdef_to_sqlite(
47100
view_select_sql=mysql_select,
48101
view_name="v_users",
49-
schema_name="db",
50-
keep_schema=True,
51102
)
52-
# Should not strip the schema when keep_schema=True
53-
assert "`db`." in sql or '"db".' in sql
103+
assert "`db`." not in sql and '"db".' not in sql
54104
assert sql.strip().endswith(";")

0 commit comments

Comments
 (0)