Fix SQLite WHERE predicate pushdown for strings with special characters - #104217
Conversation
`StorageSQLite::read` used `LiteralEscapingStyle::Regular`, which escapes single quotes as `\'`. SQLite does not recognise backslash escapes at all; its only valid string escape is doubling single quotes as `''`. A pushed-down predicate like `WHERE col = 'it\'s'` causes SQLite to parse `'it\'` as a closed string and `s'` as a stray token — either a syntax error or a SQL injection vector. Switching to `LiteralEscapingStyle::PostgreSQL` would fix the single-quote case but still emit `\n`, `\r`, `\t`, etc. as backslash sequences because `writeAnyEscapedString` applies those escapes unconditionally. SQLite does not interpret them, so predicates on strings containing control characters would silently return no rows. Add a dedicated `LiteralEscapingStyle::SQLite` backed by a new `writeQuotedStringSQLite` function. SQLite's only escape rule is `'` → `''`; all other bytes (including `\`, newline, tab) are embedded literally in the string literal. NUL bytes (`\0`) cannot be embedded literally: SQLite's tokenizer loop in `sqlite3GetToken` terminates on `c==0` even inside a single-quoted literal, returning `TK_ILLEGAL` and causing the prepare call to fail. We emit the two-char sequence `\0` instead; predicates on NUL-containing strings are therefore unsupported and will silently return no rows. Test `04141_sqlite_where_pushdown_escaping` covers single-quote, tab, newline, and backslash predicates on both the `SQLite` table engine and the `sqlite()` table function. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Workflow [PR], commit [1eb04ce] Summary: ✅
AI ReviewSummaryThis PR fixes a long list of real escaping and raw-query normalization bugs around external SQLite and PostgreSQL sources, and the current head addresses the earlier review threads. One correctness gap still remains in the updated logic: the raw Findings
Final VerdictStatus: Minimum required actions:
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 567/595 (95.29%) · Uncovered code |
This PR's branch was created on top of the interserver fix branch `fix_unauthenticated_table_existence_request`, so the unrelated `TablesStatusRequest` authentication check in `TCPHandler.cpp` (and its `04036_interserver_tables_status_auth` test) leaked into a PR whose stated scope is only the SQLite literal escaping fix. That change is also incorrect: interserver authentication is performed per-query via the cluster-secret hash computed over the query data, which is verified in `receiveQuery` where `is_interserver_authenticated` is set. A `TablesStatusRequest` is a standalone packet that arrives before any query, so `is_interserver_authenticated` is always `false` at that point. The check therefore rejected every legitimate interserver `TablesStatusRequest`, breaking distributed queries (`ALL_CONNECTION_TRIES_FAILED` / `ATTEMPT_TO_READ_AFTER_EOF`) across ~135 integration tests. The interserver fix is tracked separately in PR ClickHouse#99854; revert it here so this PR contains only the SQLite escaping change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…literals
Two correctness gaps in the SQLite `WHERE`-predicate pushdown escaping were
flagged in review:
1. NUL bytes. `writeQuotedStringSQLite` previously emitted the two-char sequence
`\0` for an embedded NUL. SQLite cannot represent NUL in a string literal at
all (`sqlite3GetToken` terminates the literal on `c==0`), so this produced a
literal that can never match the intended value — a silent wrong result.
Now such predicates are not pushed down: `isCompatible` rejects string
literals containing NUL (including nested in `IN` tuples / arrays / maps) when
the escaping style is `SQLite`, so ClickHouse evaluates them instead (or, with
`external_table_strict_query=1`, the query fails explicitly). As a
defense-in-depth backstop, `FieldVisitorToStringSQLite` throws if a NUL
literal is ever formatted, and `writeQuotedStringSQLite` no longer emits the
mismatching `\0`.
2. `IN` / `notIn` tuple literals. Scalar `IN` lists are kept as a single
`ASTLiteral(Tuple)`, which previously hit the template fallback in
`FieldVisitorToStringSQLite` and was formatted by `FieldVisitorToString` with
regular backslash escaping — reintroducing the very syntax errors / wrong
results this change fixes (e.g. `val IN ('it''s', 'a\tb')`).
`FieldVisitorToStringSQLite` now handles `Array`/`Tuple`/`Map` explicitly and
recurses into itself, so every nested string element is escaped for SQLite.
`04141_sqlite_where_pushdown_escaping` is extended with `IN` / `NOT IN`
special-character cases and NUL-byte cases (both default and strict pushdown).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…escaping-pushdown
|
📊 Cloud Performance Report ✅ AI verdict: This PR changes only how string literals and subqueries are serialized when pushed down to external SQLite/PostgreSQL databases (new SQLite escaping style, lossless PostgreSQL escaping, and row-value/container normalization). None of that code runs in the ClickBench or TPC-H benchmarks, which execute against local MergeTree tables, so the query-execution hot path is untouched. All six flagged improvements — including the large TPC-H Q7 (×8.1 faster) and Q8 (×2.2 faster) deltas — are off-path and have been downgraded to inconclusive run-to-run variance rather than real PR effects. No plausible mechanism links this diff to any per-query speedup. clickbenchFlagged queries (4 of 43)
Change = percent below ×2; the ratio of medians (×N faster/slower) beyond, where percent understates the scale. q-value = BH-FDR adjusted p; smaller is stronger evidence. MIRAI flags a query when q < fdr_q (default 0.10) — the value the verdict is based on. tpch_adapted_1_officialFlagged queries (2 of 22)
Change = percent below ×2; the ratio of medians (×N faster/slower) beyond, where percent understates the scale. q-value = BH-FDR adjusted p; smaller is stronger evidence. MIRAI flags a query when q < fdr_q (default 0.10) — the value the verdict is based on. Debug info
|
# Conflicts: # src/Storages/StorageSQLite.cpp
The pushed-down WHERE path already reserializes literals with LiteralEscapingStyle::SQLite, but the (SELECT ...) table argument of the sqlite table function and of ENGINE = SQLite still went through tryGetExternalDatabaseQuery with LiteralEscapingStyle::Regular, so a literal like 'it''s' reached SQLite as 'it\'s' — a syntax error or a wrong match, the same bug this PR fixes for predicates. Addresses the review comment from 2026-07-02 on ClickHouse#104217
…kSize The recursive Array/Tuple/Map specializations of FieldVisitorToStringSQLite lacked the checkStackSize guard that FieldVisitorToString has, so a deeply nested literal could exhaust the stack instead of throwing TOO_DEEP_RECURSION. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The helper recursively walks Tuple/Array/Map `Field` containers without the `checkStackSize()` guard that `FieldVisitorToString` and the SQLite literal visitor use, so a deeply nested literal in a SQLite-pushed predicate could exhaust the thread stack here before reaching the guarded formatting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/Storages/transformQueryForExternalDatabase.cpp
…uments
`tryGetExternalDatabaseQuery` formats a user-provided `(SELECT ...)` table
argument straight from the parsed AST, so it bypassed the single-row tuple-set
normalization that `transformQueryForExternalDatabase` applies to `IN`. As a
result `sqlite('db', (SELECT ... WHERE (a, b) IN ((1, 'x'))))` re-serialized as
`... IN (1, 'x')` — the row value collapsed into a scalar list — and SQLite
rejected it with `SQL logic error` (`IN(...) element has 1 term - expected 2`).
Extract the existing wrap logic in `isCompatible` into a shared
`wrapSingleRowTupleSetForINNode`, add a recursive `wrapSingleRowTupleSetsForIN`,
and apply it (on a clone, leaving the user's AST untouched) to the subquery
before formatting it for the external database. Extend
`04493_sqlite_subquery_literal_escaping` with a multi-column single-row `IN`
case (plus a multi-row control, which was already correct).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…escaping-pushdown
`04341_sqlite_query_passing` exercised the query-backed SQLite source only with numeric filters, so the `LiteralEscapingStyle::SQLite` escaping on the subquery path (`StorageSQLite`/`TableFunctionSQLite` `tryGetExternalDatabaseQuery`) was untested. Add a table with a single-quote and a tab value and filter it through the `(SELECT ... WHERE s = 'it''s')` subquery form so a regression back to backslash escaping (which SQLite rejects) is caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lite` query argument
|
🕵 Status of the reds on
The new AI-Review Major (raw |
…escaping-pushdown
The `arm_tidy` build failed on the new NUL-byte checks: use `contains` instead of `find != npos`. https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104217&sha=e467ef390d1e4bc63bf54600b8ac6ff68882dfb6&name_0=PR&name_1=Build%20%28arm_tidy%29 ClickHouse#104217
`normalizeSubqueryForExternalDatabase` left the `PREWHERE` clause of a parsed `(SELECT ...)` table argument intact, so it was serialized back as `PREWHERE`, which no external database can parse. Lower it into `WHERE` (merging with an existing `WHERE` via `AND`) before the walk, so the lowered filter also gets the boolean-position normalization.
…caping The bridge protocol only reports the identifier quoting style, not the literal escaping dialect of the remote database, so plumbing a dialect-aware escaping style through XDBC needs a bridge protocol extension and is out of scope here; the call site keeps the historical behavior and now says so explicitly.
…escaping-pushdown
The UUID range-comparison check walks the user-controlled AST recursively before `isCompatible`'s own guarded walk, so a deeply nested predicate could exhaust the worker stack instead of failing with `TOO_DEEP_RECURSION`. Add the same `checkStackSize()` guard as in the other recursive helpers in this file (`fieldHasStringWithNulByte`, `fieldContainsArrayOrMap`, `fieldRequiresClickHouseOnlySyntax`). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🕵 The two |
…escaping-pushdown
|
🕵 The only red on head |
| /// a row value as a condition). | ||
| for (auto & child : node->children) | ||
| { | ||
| const bool is_boolean_clause = (select->where() && child == select->where()) |
There was a problem hiding this comment.
The new boolean-position normalization still skips a top-level JOIN ... ON expression. This is_boolean_clause test only marks WHERE / HAVING / QUALIFY, so a raw PostgreSQL table argument like (SELECT * FROM t1 JOIN t2 ON (a > 0, b > 10)) reaches normalizeSubqueryForExternalDatabaseImpl with RowValueContext::Disallowed.
Because rowValueIsValidAnywhere(PostgreSQL) returns true, that tuple is reformatted as the record (a > 0, b > 10) instead of being lowered to AND, and PostgreSQL rejects the generated SQL because ON needs a boolean, not a record. Please treat JOIN ON as another BooleanPredicate carrier (or reject tuple conditions there) and add a raw-subquery regression for the PostgreSQL path.
master absorbed this branch's original subject - container literals in predicates pushed down to PostgreSQL - through ClickHouse#104217, which generalized the escaping to both the PostgreSQL and SQLite dialects: `FieldVisitorToStringForDialect` recurses through the dialect visitor and rejects outright the literals PostgreSQL cannot parse, and `writeQuotedStringPostgreSQLLossless` replaced this branch's `writeQuotedStringPostgreSQLLiteral`. That implementation is the better one, so all four conflicts take it wholesale - `src/IO/WriteHelpers.h`, `src/Parsers/ASTLiteral.cpp`, `src/Parsers/LiteralEscapingStyle.h` and `src/Storages/tests/gtest_transform_query_for_external_database.cpp` - and the duplicate `FieldVisitorToStringPostgreSQL` this branch had in `src/Common/FieldVisitorToString.{h,cpp}` goes with them. What remains is the sinks master still has: SQL literals in the metadata and replication queries. `quoteStringPostgreSQL` now routes through master's lossless helper rather than doubling only the quote, which is what `checkPostgresTable`, the requested column names and the publication name are quoted with. The dictionary-source `E'...'` hardening is dropped as well (`FormatSettings::escape_string_for_postgresql` and the three serializations, `ExternalQueryBuilder`, `PostgreSQLDictionarySource`). master's `ExternalQueryBuilder` already sets `escape_quote_with_quote` for the PostgreSQL quoting style, so that source is safe under the default `standard_conforming_strings`; what was here on top only covered `standard_conforming_strings = off`, at the price of a new `FormatSettings` field and three shared serialization paths, which is not worth carrying on a change that has to be backported. Adds the regression coverage the metadata sinks had none of: a gtest pinning `quoteStringPostgreSQL`, and an integration test asserting that a `schema` payload is not executed by PostgreSQL. The latter is load-bearing - with `DatabasePostgreSQL.cpp` reverted to master it fails on the marker table the payload really does create.
Backport #104217 to 26.7: Fix SQLite WHERE predicate pushdown for strings with special characters
…wn for strings with special characters
StorageSQLite::readusedLiteralEscapingStyle::Regular, which escapes single quotes as\'. SQLite does not recognise backslash escapes; its only valid string escape is''. A pushed-down predicate likeWHERE col = 'it\'s'causes SQLite to parse'it\'as a closed string ands'as a stray token — a SQL syntax error or injection vector.Switching to
LiteralEscapingStyle::PostgreSQLwould fix single quotes but still emit\n,\r,\tas backslash sequences (whichwriteAnyEscapedStringapplies unconditionally). SQLite does not interpret those, so predicates on control-character strings would silently return no rows.This PR adds a dedicated
LiteralEscapingStyle::SQLitebacked bywriteQuotedStringSQLite: only'→''; all other bytes (including\, newline, tab) are embedded literally. NUL bytes cannot be embedded — SQLite's tokenizer loop insqlite3GetTokenterminates onc==0even inside a string literal, returningTK_ILLEGAL— so a predicate whose string literal (possibly nested in anINtuple, array or map) contains a NUL byte is not pushed down at all: ClickHouse evaluates it locally, and withexternal_table_strict_query = 1the query is rejected instead of silently returning wrong rows.This is a follow-up to PR #74144 which fixed the DDL/PRAGMA and INSERT paths for SQLite but left the SELECT pushdown path using the wrong escaping style.
During review the same class of bug was fixed on the PostgreSQL pushdown path as well: strings nested inside
Array/Tuple/Mapliterals (e.g. the elements of a pushed-downINlist) now stay in the selected dialect all the way down instead of falling back to the regular ClickHouse escaping, and PostgreSQL string literals that contain backslashes or control characters are emitted as escape string constants (E'...'), so the server reads back exactly the original bytes regardless ofstandard_conforming_strings(a real tab used to be sent as the two characters\t). Predicates whose string literals contain a NUL byte are not pushed down to PostgreSQL either, since a PostgreSQL string value cannot contain NUL.The same row-value restriction is applied on the normal
WHEREpushdown path: a multi-column tuple is written as the row value(a, b), which SQLite and MySQL accept only next to a comparison orIN, so a predicate such asWHERE (id, val) IS NOT NULLis no longer pushed down to them (ClickHouse evaluates it, and withexternal_table_strict_query = 1the query is rejected) instead of being sent as SQL the external database cannot parse (SQLite reportsrow value misused). For PostgreSQL, whose row constructors are ordinary value expressions, it is still pushed down. A tuple used as the whole condition is ClickHouse's list-of-predicates form and keeps being pushed down as a conjunction,WHERE ("a" > 0) AND ("column" > 10), for every dialect - no external database accepts a row value as a condition.The user-provided
(SELECT ...)table argument ofsqlite/postgresql/mysql, which is re-serialized from the parsed AST and sent to the external database as is, no longer leaks ClickHouse-only syntax into that SQL:Array/Mapliterals and tuples with fewer than two elements (which could only be written back astuple(...)) now throwBAD_ARGUMENTSinstead of producing SQL the external database cannot parse, an explicittuple(a, b)call is re-serialized as the parenthesized row value(a, b)- for SQLite and MySQL only in positions where those databases accept a row value (an operand of a comparison orIN); in any other position, such as the SELECT list, both thetuple(...)call and the equivalent tuple literal throwBAD_ARGUMENTS, because the parenthesized form is a syntax error there (SQLite reportsrow value misused). PostgreSQL row constructors are ordinary value expressions, valid in any expression position (SELECT (a, b),WHERE (a, b) IS NOT NULL), so for PostgreSQL such tuples are sent through as row values everywhere instead of being rejected - everywhere except a boolean position, since no database accepts a record as a condition. A tuple in a boolean position - theWHERE/HAVINGof the passed query, or an operand ofAND/OR/NOT- is ClickHouse's list-of-predicates form, and is lowered to a conjunction for every dialect:(SELECT ... WHERE (a > 0, b > 10))reaches the external database asWHERE (a > 0) AND (b > 10), the same rewrite the normal pushdown path applies;PREWHERE, which is ClickHouse-only syntax no external database can parse, is lowered intoWHEREon that path as well (merging with an existingWHEREviaAND), and the lowered filter gets the same boolean-position normalization. the equivalent tuple literal of constants is not a list of predicates the external database could evaluate and throwsBAD_ARGUMENTSthere instead.array/mapcalls on that path are rejected for all three databases. The internal_CAST(literal, 'Type')wrapper that the analyzer'sConstantNode::toASTputs around a tuple literal used as a plain expression operand (e.g.WHERE (id, val) = (2, 'y')) when it re-serializes the subquery argument from the query tree is unwrapped back to the literal, instead of leaking the ClickHouse-internal_CASTfunction into the SQL sent to the external database. A single-row multi-columnINset keeps its outer parentheses for both carriers - the fast-path literal(a, b) IN ((1, 'x'))and the explicit call(a, b) IN (tuple(1, 'x'))- so it reaches the external database asIN ((1, 'x'))instead of collapsing to the scalar listIN (1, 'x'). This normalization applies to MySQL as well: it shares the same re-serialization path, and although itsRegularliteral escaping style is correct for MySQL string literals (MySQL interprets backslash escapes like ClickHouse), thetuple(...)/array(...)/map(...)forms andArray/Map/ single-element-tuple literals are not MySQL syntax either.The JDBC/ODBC (
StorageXDBC) pushdown path is intentionally out of scope: the bridge protocol only reports the identifier quoting style, not the literal escaping dialect of the remote database, so plumbing a dialect-aware escaping style through it needs a bridge protocol extension. That path keeps the historicalRegularescaping, and the limitation is now documented at the call site.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed incorrect SQL literal escaping in
StorageSQLiteandsqlite()table function when pushingWHEREpredicates to SQLite: single quotes and control characters (\n,\r,\t,\) were escaped with backslashes, which SQLite does not interpret, causing syntax errors or wrong query results. Also fixed the escaping of string literals pushed down to PostgreSQL: strings nested insideINlists kept ClickHouse escaping, and control characters were sent as backslash sequences that PostgreSQL reads back as different bytes.Documentation entry for user-facing changes
Version info
26.7.7.60