Repository navigation
fix: make the SQLite Data Catalog's Describe and Show DDL actions work - #1195
Conversation
tconbeer
left a comment
There was a problem hiding this comment.
I reviewed this in four separate passes: correctness, reuse/simplification, security, and comments/changelog. The fix is right. Describe and Show DDL now work on main, on an attached database and on temp. They also work with names that contain ", ', spaces, unicode or mixed case, and on WITHOUT ROWID tables, FTS5 tables and their shadow tables. The quoting can't be escaped by a hostile name, and the old tbl_name = '{label}' was injectable, so this is strictly better than before. Both new tests fail when interactions.py is reverted to main and pass on this head.
Should fix (inline)
- Show DDL can return a trigger's DDL when a trigger has the same name as the table. Fix: add
and type in ('table', 'view'). _quote_identifiercopies quoting the adapter already does insearch_catalog. Suggest one shared helper.
Pre-existing, not introduced here (suggest a follow-up issue)
catalog.pywraps names in"without escaping embedded quotes.- Confirmed Drop Table on a table named
victim" --runsdrop table "main"."victim" --"and dropsvictim. The confirm dialog shows the crafted name. - Preview Data on a similar name produces a multi-statement buffer.
- The shared helper from (2) fixes both.
- Confirmed Drop Table on a table named
_get_relationsand_get_columns(adapter.py:340,:347) don't escape names either, and_get_columnsdoesn't quote the database at all. Expanding a table namedhas'sqfails withnear "sq": syntax error, so Describe now works on names whose catalog node won't expand.HarlequinSqliteCursor.fetchone()returns(None,)rather thanNonewhen nothing matches. As a result, Show DDL on a relation dropped since the catalog loaded insertsNoneinto a new buffer.
Not checked
- SQLite older than 3.33, which is when
sqlite_schemaappeared.mainalready depends on it in three places, so this PR adds no new requirement. - The inserted pragma running through the app's editor, statement splitter and
HarlequinSqliteConnection.execute(). I ran it on the rawsqlite3connection only, not throughpilot. - Show DDL in manual transaction mode, where
connection.execute()may issuebegin. - The DuckDB adapter's interactions.
- Windows/macOS.
tests/adapter_tests/test_sqlite.pywas run on Linux with Python 3.10 only; I did not run the full suite, mypy or lint-imports. - Whether an issue exists for the CHANGELOG entry to reference.
Generated by Claude Code
| from sqlite_schema | ||
| where tbl_name = '{item.label}' | ||
| from {_quote_identifier(item.parent.label)}.sqlite_schema | ||
| where name = {_quote_literal(item.label)} |
There was a problem hiding this comment.
Should fix — narrow regression. In SQLite a trigger can share a table's name, because triggers have their own namespace. With no type filter here, where name = … returns whichever matching row comes first:
create table base(a);
create trigger samename after insert on base begin select 1; end;
create table samename(x int);Here, Show DDL on main.samename inserts CREATE TRIGGER samename …, while main's tbl_name filter returns CREATE TABLE samename(x int). Tables, views and indexes can't share a name with each other, so filtering on type is enough:
| where name = {_quote_literal(item.label)} | |
| where name = {_quote_literal(item.label)} and type in ('table', 'view') |
A trigger in two_databases with the same name as one of its relations would pin this.
Generated by Claude Code
| def _quote_identifier(name: str) -> str: | ||
| return '"' + name.replace('"', '""') + '"' | ||
|
|
||
|
|
||
| def _quote_literal(text: str) -> str: | ||
| return "'" + text.replace("'", "''") + "'" |
There was a problem hiding this comment.
Should fix — reuse. _quote_identifier is the same expression search_catalog() already writes inline (adapter.py:251). That makes this the adapter's second copy of identifier quoting, and every other place in the adapter quotes without escaping:
catalog.pybuildsqualified_identifier/query_nameasf'"{label}"'(lines 32-33, 85-86, 110-111, 130). That's why this PR couldn't reuseitem.parent.qualified_identifier._get_relationsand_get_columns(adapter.py:340,:347).
Suggest putting the helper in one small stdlib-only module in harlequin_sqlite, imported by both interactions.py and adapter.py, and using it in search_catalog too. Applying it to the catalog constructors can be a follow-up (see the review summary).
Optional: _quote_literal can go if the lookup binds the label instead. For example, a HarlequinSqliteConnection._get_ddl(db_name, relation_name) next to _get_relations/_get_columns that runs where name = ? on self.conn, the way search_catalog binds its params.
Generated by Claude Code
| ) -> None: | ||
| if item.parent is None: | ||
| return | ||
| # a pragma's argument is a bare name; the database qualifies the pragma itself |
There was a problem hiding this comment.
Nit: "bare name" isn't quite accurate. The argument on the next line is quoted, and SQLite accepts that. What it rejects is a schema-qualified argument: pragma table_info("main"."t") fails with near ".": syntax error. SQLite also calls that prefix the schema, not the database.
| # a pragma's argument is a bare name; the database qualifies the pragma itself | |
| # a pragma's argument can't be schema-qualified; the schema prefixes the pragma |
Generated by Claude Code
|
|
||
| @pytest.fixture | ||
| def two_databases(tmp_path: Path) -> HarlequinSqliteConnection: | ||
| """main and an attached database, each with a table called orders.""" |
There was a problem hiding this comment.
Nit: the docstring leaves out the Bob's orders view, which is what both tests rely on for the quoting case.
| """main and an attached database, each with a table called orders.""" | |
| """main and an attached archive, each with a table called orders; archive | |
| also has a view with a quote in its name.""" |
Generated by Claude Code
Describe inserted `pragma table_info("main"."orders")`, which SQLite
rejects for every relation: a pragma's argument is a bare name, and the
database qualifies the pragma itself. Show DDL read main's sqlite_schema
whatever database the relation was in, so it inserted nothing for a
relation in an attached or temp database, or main's DDL when main had a
relation of the same name; an unescaped quote in the label also broke
its query.
Both now take the database from the relation's parent item. Identifiers
are quoted the way search_catalog() quotes a database name, and a quote
in the label is escaped in Show DDL's string literal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A trigger can have the same name as a table, because triggers have their own namespace, so matching sqlite_schema on name alone could return the trigger's row. Only tables and views are in the Data Catalog, and they can't share a name with each other or with an index, so filtering on type leaves the one row Show DDL wants. The two_databases fixture now has a trigger called orders in archive, created before archive's table, and its docstring mentions the view the quoting case relies on. Co-Authored-By: Claude Opus 5.5 <[email protected]>
search_catalog() and the Describe and Show DDL interactions each quoted an identifier with their own copy of the same expression. Both now call quote_identifier() from a new stdlib-only harlequin_sqlite.quoting module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A quoted argument is fine; a schema-qualified one is what SQLite rejects, and SQLite calls that prefix the schema. Co-Authored-By: Claude Opus 5.5 <[email protected]>
cd90d41 to
f20de91
Compare
|
Thank you for your PR! |
There is no open issue for this. I found it reading
src/harlequin_sqlite/interactions.py, so the CHANGELOG entry references none.Two of the SQLite adapter's Data Catalog actions (right-click, or
., on a table or view) build the wrong SQL:pragma table_info("main"."orders"). SQLite rejects that withnear ".": syntax errorfor every table and view in every database, because a pragma's argument is a bare name; the database has to qualify the pragma itself, as inpragma "main".table_info("orders").sqlite_schema, which ismain's. For a table or view in an attached (ortemp) database it inserts nothing, or, whenmainhas a table with the same name,main's DDL instead of the one you clicked. It also put the label into a string literal unescaped, so for a view calledBob's ordersit raisednear "s": syntax error.To reproduce on
main:In the Data Catalog, open the context menu on
archive>orders. Describe opens a buffer withpragma table_info("archive"."orders"), and running it showsnear ".": syntax error. Show DDL opensCREATE TABLE orders (id bigint), which ismain.orders.What are the key elements of this solution?
Both functions now take the database from the relation's parent item, which is already there (
show_relation_ddlwas guarding on it but not using it):pragma "<database>".table_info("<relation>")."<database>".sqlite_schemawhere name = '<relation>'.Identifiers are double-quoted with any
"doubled, which is howsearch_catalog()already quotes a database name. In Show DDL's string literal, any'in the label is doubled.Why did you design your solution this way? Did you assess any alternatives? Are there tradeoffs?
Describe keeps the
pragmastatement it always meant to insert, so the buffer reads the same, just valid.select * from pragma_table_info('orders', 'archive')also works, but it changes what users see for no gain.Show DDL now matches on
namerather thantbl_name, sincetbl_namealso matches a table's indexes and triggers. Withlimit 1and noorder by, the table's own row only came first because of rowid order.HarlequinSqliteConnection.execute()takes no parameters, so the label is escaped into the literal rather than bound.The DuckDB adapter's versions of these actions are unaffected:
describe "db"."schema"."table"is valid DuckDB, and itsshow_relation_ddlalready filters on database and schema.Does this PR require a change to Harlequin's docs?
Did you add or update tests for this change?
Please complete the following checklist:
CHANGELOG.md, under the[Unreleased]section heading. That entry references the issue closed by this PR.There are two new tests in
tests/adapter_tests/test_sqlite.py. Both use atwo_databasesfixture:mainand an attachedarchive, each with a table calledorders, plus a view inarchivecalledBob's orders.test_describe_inserts_a_pragma_that_runsruns the text Describe inserts for every relation and checks that it lists that relation's own columns. Onmainit fails withsqlite3.OperationalError: near ".": syntax error.test_show_ddl_reads_the_database_the_relation_is_inchecks the DDL that Show DDL inserts for every relation. Onmainit fails withHarlequinQueryError: near "s": syntax errorfrom the quoted view. With the view left out, the diff shows"archive"."orders"gettingmain'sCREATE TABLE orders (id bigint).Testing was done on macOS arm64 with Python 3.10.15.
uv run pytest tests/adapter_tests/test_sqlite.py -n0gives1 failed, 23 passed, 2 skipped. Withinteractions.pyreverted tomainand the new tests kept, it gives3 failed, 21 passed, 2 skipped. The failure common to both runs istest_load_extension, which fails the same way on unmodifiedmainhere, because the committedhello0.dylibfixture is x86_64 and dlopen wants arm64e on this machine.uv run pytest -m "not online"gives9 failed, 2039 passed, 9 skipped, 112 errors. Running the same test files on unmodifiedmainhere fails or errors on exactly the same 121 node IDs. Apart fromtest_load_extension, they need things this environment denies:/tmp, for hsql's warm-session tests (test_hsql_equivalence.py,test_hsql_serve.pyandtest_hsql_session.py)/tmp/harlequin, fortest_keys_appsysctlbyname, for pyarrow's ORC writer (TestOrcOptions::test_compression)test_query_log.pyOn Python 3.12.15,
tests/adapter_tests/test_sqlite.pygives1 failed, 24 passed, 1 skipped, where the one failure is againtest_load_extension.ruff format --check .reports214 files already formatted,ruff check .reportsAll checks passed!,mypyreportsSuccess: no issues found in 165 source files, andlint-importsreportsContracts: 5 kept, 0 broken.🤖 Generated with Claude Code