Skip to content

Fix SQLite WHERE predicate pushdown for strings with special characters - #104217

Merged
tiandiwonder merged 49 commits into
ClickHouse:masterfrom
tiandiwonder:fix/sqlite-literal-escaping-pushdown
Aug 14, 2026
Merged

Fix SQLite WHERE predicate pushdown for strings with special characters#104217
tiandiwonder merged 49 commits into
ClickHouse:masterfrom
tiandiwonder:fix/sqlite-literal-escaping-pushdown

Conversation

@tiandiwonder

@tiandiwonder tiandiwonder commented May 6, 2026

Copy link
Copy Markdown
Contributor

StorageSQLite::read used LiteralEscapingStyle::Regular, which escapes single quotes as \'. SQLite does not recognise backslash escapes; its only valid string escape is ''. A pushed-down predicate like WHERE col = 'it\'s' causes SQLite to parse 'it\' as a closed string and s' as a stray token — a SQL syntax error or injection vector.

Switching to LiteralEscapingStyle::PostgreSQL would fix single quotes but still emit \n, \r, \t as backslash sequences (which writeAnyEscapedString applies unconditionally). SQLite does not interpret those, so predicates on control-character strings would silently return no rows.

This PR adds a dedicated LiteralEscapingStyle::SQLite backed by writeQuotedStringSQLite: only '''; all other bytes (including \, newline, tab) are embedded literally. NUL bytes cannot be embedded — SQLite's tokenizer loop in sqlite3GetToken terminates on c==0 even inside a string literal, returning TK_ILLEGAL — so a predicate whose string literal (possibly nested in an IN tuple, array or map) contains a NUL byte is not pushed down at all: ClickHouse evaluates it locally, and with external_table_strict_query = 1 the 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 / Map literals (e.g. the elements of a pushed-down IN list) 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 of standard_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 WHERE pushdown path: a multi-column tuple is written as the row value (a, b), which SQLite and MySQL accept only next to a comparison or IN, so a predicate such as WHERE (id, val) IS NOT NULL is no longer pushed down to them (ClickHouse evaluates it, and with external_table_strict_query = 1 the query is rejected) instead of being sent as SQL the external database cannot parse (SQLite reports row 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 of sqlite / 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 / Map literals and tuples with fewer than two elements (which could only be written back as tuple(...)) now throw BAD_ARGUMENTS instead of producing SQL the external database cannot parse, an explicit tuple(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 or IN); in any other position, such as the SELECT list, both the tuple(...) call and the equivalent tuple literal throw BAD_ARGUMENTS, because the parenthesized form is a syntax error there (SQLite reports row 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 - the WHERE / HAVING of the passed query, or an operand of AND / 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 as WHERE (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 into WHERE on that path as well (merging with an existing WHERE via AND), 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 throws BAD_ARGUMENTS there instead. array / map calls on that path are rejected for all three databases. The internal _CAST(literal, 'Type') wrapper that the analyzer's ConstantNode::toAST puts 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 _CAST function into the SQL sent to the external database. A single-row multi-column IN set 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 as IN ((1, 'x')) instead of collapsing to the scalar list IN (1, 'x'). This normalization applies to MySQL as well: it shares the same re-serialization path, and although its Regular literal escaping style is correct for MySQL string literals (MySQL interprets backslash escapes like ClickHouse), the tuple(...) / array(...) / map(...) forms and Array / 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 historical Regular escaping, and the limitation is now documented at the call site.

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fixed incorrect SQL literal escaping in StorageSQLite and sqlite() table function when pushing WHERE predicates 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 inside IN lists kept ClickHouse escaping, and control characters were sent as backslash sequences that PostgreSQL reads back as different bytes.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

Version info

  • Backported to: 26.7.7.60

`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>
@clickhouse-gh

clickhouse-gh Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [1eb04ce]

Summary:


AI Review

Summary

This 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 (SELECT ...) normalizer still misses top-level JOIN ... ON boolean tuples for PostgreSQL.

Findings

⚠️ Majors

  • [src/Storages/transformQueryForExternalDatabase.cpp:962] The raw-subquery normalization marks only WHERE / HAVING / QUALIFY as boolean positions, so a top-level JOIN ... ON (a > 0, b > 10) in a PostgreSQL table argument still gets reformatted as a row value (a > 0, b > 10) instead of a conjunction. PostgreSQL rejects that generated SQL because ON requires a boolean condition, not a record. Suggested fix: treat JOIN ON as another BooleanPredicate carrier (or reject tuple conditions there) and add a PostgreSQL regression for the query-backed path.
Final Verdict

Status: ⚠️ Request changes

Minimum required actions:

  • Extend boolean-position normalization (or rejection) to top-level JOIN ... ON in raw PostgreSQL table arguments.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.70% 86.70% +0.00%
Functions 92.00% 92.00% +0.00%
Branches 79.10% 79.10% +0.00%

Changed lines: Changed C/C++ lines covered: 567/595 (95.29%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label May 6, 2026
Comment thread src/IO/WriteHelpers.h Outdated
Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
@tiandiwonder tiandiwonder added the can be tested Allows running workflows for external contributors label Jun 5, 2026
Comment thread src/Parsers/ASTLiteral.cpp Outdated
alexey-milovidov and others added 3 commits June 16, 2026 00:38
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>
@GrigoryPervakov GrigoryPervakov self-assigned this Jun 29, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

✅ AI verdict: no_change — no significant changes across 35 queries analysed

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.

clickbench

⚠️ 4 inconclusive

Flagged queries (4 of 43)
Query Verdict Baseline median (ms) PR median (ms) Change q-value Hint
⚠️ 16 not_sure 793 712 -10.2% <0.0001 This PR only changes SQLite/PostgreSQL external-database literal escaping, which a local ClickBench SELECT never executes, so the -10.2% delta is off-path run-to-run variance.
⚠️ 17 not_sure 556 450 -19.1% <0.0001 The diff touches only external-table query serialization; Q17 runs against local tables, so the -19.1% reading cannot be attributed to this change and is variance.
⚠️ 18 not_sure 1298 1194 -8.0% <0.0001 This change affects only external-database pushdown escaping, a path Q18 does not exercise, so the -8.0% delta is off-path and inconclusive.
⚠️ 23 not_sure 167 75 ×2.2 faster <0.0001 The diff only alters external-database query serialization, unrelated to Q23; combined with this query's noisy history, the "×2.2 faster" reading is variance, not a PR effect.

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_official

⚠️ 2 inconclusive

Flagged queries (2 of 22)
Query Verdict Baseline median (ms) PR median (ms) Change q-value Hint
⚠️ 7 not_sure 574 71 ×8.1 faster <0.0001 This PR changes only SQLite/PostgreSQL pushdown serialization; TPC-H runs on local tables, so the "×8.1 faster" reading is off-path variance, not caused by this change.
⚠️ 8 not_sure 175 81 ×2.2 faster <0.0001 The diff touches only external-database literal escaping, which TPC-H does not execute, so the "×2.2 faster" reading is off-path and inconclusive.

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
  • StressHouse run: f80ae6ca-10ea-462e-840b-6c4368e07f0b
  • MIRAI run: 0efae3c2-f2b2-4c4a-90cb-be0f38ee3125
  • PR check IDs:
    • clickbench_1143476_1786670142
    • clickbench_1143573_1786670145
    • clickbench_1143597_1786670146
    • tpch_adapted_1_official_1143781_1786670154
    • tpch_adapted_1_official_1143795_1786670155
    • tpch_adapted_1_official_1144023_1786670191

@tiandiwonder
tiandiwonder enabled auto-merge June 30, 2026 05:12
# Conflicts:
#	src/Storages/StorageSQLite.cpp
Comment thread 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
Comment thread src/Parsers/ASTLiteral.cpp Outdated
Comment thread src/Parsers/ASTLiteral.cpp Outdated
…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>
Comment thread src/Storages/transformQueryForExternalDatabase.cpp
tiandiwonder and others added 2 commits July 13, 2026 09:02
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
Comment thread src/TableFunctions/TableFunctionSQLite.cpp
tiandiwonder and others added 3 commits July 20, 2026 01:37
…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>
`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>
@alexey-milovidov

Copy link
Copy Markdown
Member

🕵 Status of the reds on 43dc1f992b4b - all unrelated to this PR:

The new AI-Review Major (raw (SELECT ...) arguments never carried BooleanPredicate, so a tuple-of-predicates WHERE leaked to PostgreSQL as a record) is fixed in c3949d2bf37b - see the thread reply for details.

Comment thread src/Storages/transformQueryForExternalDatabase.cpp Outdated
Comment thread src/Parsers/LiteralEscapingStyle.h
`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.
Comment thread src/Storages/transformQueryForExternalDatabase.cpp
alexey-milovidov and others added 2 commits August 12, 2026 11:19
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>
@alexey-milovidov

Copy link
Copy Markdown
Member

🕵 The two Stress test (arm_release) / Stress test (arm_debug) reds on dc4bc3fdae03 are the fleet-wide hung-check storm, not this PR: hung_check.log shows DROP TABLE prometheus stuck in DatabaseCatalog::waitTableFinallyDropped from 04811_promql_topk_bottomk_limitk, affecting 170+ PRs including master since 2026-08-10. The revert #114326 merged on 2026-08-12 fixes it; this branch has now merged master past that commit, so the fresh CI run should be clear. Also addressed the remaining AI-review Major (checkStackSize in containsUUIDColumn) in 9b94980.

@alexey-milovidov

Copy link
Copy Markdown
Member

🕵 The only red on head 9b949803 was Stress test (arm_asan_ubsan, s3) — a LeakSanitizer report in the Context::createCopy / QueryTreeBuilder cluster, unrelated to this PR (tracked in #114396 / #113954). The fix #113397 merged into master on 2026-08-12, so I merged master into this branch to pull it in. All 30 TransformQueryForExternalDatabase/escaping gtests pass after the merge. The previous AI review verdict on 9b949803 was clean ("No new findings") with zero unresolved threads.

/// a row value as a condition).
for (auto & child : node->children)
{
const bool is_boolean_clause = (select->where() && child == select->where())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tiandiwonder
tiandiwonder added this pull request to the merge queue Aug 14, 2026
Merged via the queue into ClickHouse:master with commit 82af975 Aug 14, 2026
182 checks passed
@tiandiwonder
tiandiwonder deleted the fix/sqlite-literal-escaping-pushdown branch August 14, 2026 04:29
@robot-ch-test-poll4 robot-ch-test-poll4 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 14, 2026
@antonio2368 antonio2368 added the pr-must-backport Pull request should be backported intentionally. Use this label with great care! label Sep 2, 2026
@robot-clickhouse robot-clickhouse added the pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR label Sep 2, 2026
tiandiwonder added a commit to tiandiwonder/ClickHouse that referenced this pull request Sep 7, 2026
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.
@robot-clickhouse robot-clickhouse added the pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore label Sep 7, 2026
PedroTadim added a commit that referenced this pull request Sep 9, 2026
Backport #104217 to 26.7: Fix SQLite WHERE predicate pushdown for strings with special characters
kewin-robetti pushed a commit to viasoftkorp/ClickHouse that referenced this pull request Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore pr-bugfix Pull request with bugfix, not backported by default pr-must-backport Pull request should be backported intentionally. Use this label with great care! pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants