feat: AST SQL rewriting - #10604
feat: AST SQL rewriting#10604MazterQyou wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10604 +/- ##
==========================================
- Coverage 84.88% 81.09% -3.79%
==========================================
Files 261 502 +241
Lines 87535 111391 +23856
Branches 0 3923 +3923
==========================================
+ Hits 74306 90336 +16030
- Misses 13229 20506 +7277
- Partials 0 549 +549
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ec27221 to
71ace5b
Compare
71ace5b to
f6f3c15
Compare
| }); | ||
| })); | ||
|
|
||
| app.get(`${this.basePath}/v1/sql-filters`, userMiddlewares, userAsyncHandler(async (req: any, res) => { |
| }); | ||
| })); | ||
|
|
||
| app.post(`${this.basePath}/v1/sql-filters`, jsonParser, userMiddlewares, userAsyncHandler(async (req, res) => { |
|
Claude finished @MazterQyou's task in 4m 1s —— View job Review complete — 1 high, 3 medium, 4 low. Main concern: unvalidated user values emitted as raw SQL numeric literals (#3722392675).Full review — findings, praise, and checklistFindings
Non-blocking, no inline comment posted:
What's good
Checklist
No prior threads of mine on this PR, so nothing to resolve or dedupe against. Findings are from static reading — I did not build the Rust crate or run the test suite. |
f6f3c15 to
7965b9f
Compare
|
Claude finished @MazterQyou's task in 4m 9s —— View job Review complete — solid design, but 2 high / 4 medium / 5 low open; nothing from the previous round was addressed in this push.Full review — expand for findings, prior-round status, and notes
New this round (4 inline comments posted)
Previous round — all 8 threads re-verified, all still applyRe-affirmed rather than re-posted (unresolved state already signals this):
The two What's good
Not verified
|
7965b9f to
ea52be1
Compare
|
Claude finished @MazterQyou's task in 5m 25s —— View job Review complete — every finding from the last two rounds is addressed. 4 new: 1 medium, 2 low, 1 test-coverage.Full review — expand for findings, prior-round status, and notes
Previous rounds — 12 threads resolvedAll 12 of my open threads are fixed in this push, most with a test pinning the behaviour:
New this round (4 inline comments)
Minor, no thread posted: the schema-qualification nit from the CTE thread is still live — Nested What's good
Not verified
|
|
Claude finished @MazterQyou's task in 10m 8s —— View job No blockers — 2 low posted inline, 2 re-affirmed on open threads, nothing high or mediumReview details
Posted inline
Re-affirmed, not re-posted
Looked at and cleared
Not a code issueThe PR body says Verification not run
Review threadsThe listing returned 13 of my own threads (single page). Resolved 11 as addressed by the current diff; re-affirmed 2 (the |
|
Claude finished @MazterQyou's task in 7m 29s —— View job Review complete — 3 findings (2 medium, 1 low), posted inlineDetails
Findings (inline)
Review threadsThe listing returned 6 unresolved threads, 4 of them mine. All 4 were resolved as addressed by the current diff (doc-comment verbosity trimmed; What holds up well
Not verifiedNo build or test run: this checkout has no |
|
Claude finished @MazterQyou's task in 9m 21s —— View job 1 high, 1 medium, 3 low — 5 inline comments postedFull review
Findings
What holds up wellThe SQL-injection surface is closed off properly: values reach the AST only as Not flagged
VerificationNeither suite was run: the checkout has no Review threadsThe listing returned 3 |
|
Claude finished @MazterQyou's task in 9m 19s —— View job Review: 1 medium, 3 low — 4 inline comments posted, 5 prior threads resolvedFull review
Findings
Checked and clear
Worth calling out
The Rust test module is genuinely thorough — 80-odd cases including the escaping, case-folding, CTE-shadowing, batch-cache and Not verified
Review threadsThe listing returned 5 of my own threads (7 unresolved in total; the 2 |
|
Claude finished @MazterQyou's task in 8m 24s —— View job Reviewed — 1 medium correctness, 1 docs accuracy, 3 low. 5 inline comments posted.Full review
Findings
What holds up well
Two notes, not posted inline
Not verifiedNeither test suite ran: Review threadsThe listing returned 4 of my own threads (plus 2 CodeQL threads, untouched). All 4 resolved as addressed by this revision: the |
| | --- | --- | --- | | ||
| | `query`, `string` | SQL API query to rewrite | ✅ Yes | | ||
| | `add`, `array` | Filters to add to the outermost `SELECT` | One of the four | | ||
| | `set`, `array` | Filters to replace the query's reported filters with: every filter `GET` reports is removed, then these are added | One of the four | |
There was a problem hiding this comment.
"every filter GET reports is removed" overstates what set does — a reported filter can be silently kept, with the response still status: ok.
Column-based removal only engages for a filter that is sole on its member (ReportedFilters::is_sole_on_member, ast_conv.rs:2370). When two reported filters share a member and the query does not spell one of them out the way this API writes it, the Remove action is a no-op; verify_additions only checks the additions, so nothing surfaces. The PR's own test_remove_by_column_only_for_the_sole_reported_filter (ast_conv.rs:7134) pins exactly that case: order_date >= x AND order_date < y AND order_date IS NOT NULL, reported as inDateRange + set, leaves the clause untouched and reports applied == false.
A caller asking for set: [] on such a query gets status: ok and a filters array that still lists the range. Either say so here ("a reported filter the outermost SELECT does not spell out may be kept; compare the returned filters") or have set fail when a removal it promised did not apply.
| pub security_context: NonDebugInRelease<Option<serde_json::Value>>, | ||
| } | ||
|
|
||
| /// Reads a security context passed from JS as a JSON string. A missing, null |
There was a problem hiding this comment.
This changes rest4sql's behaviour, not only the new entry points. The code it replaces (rest4sql.rs:131 on master) swallowed both failures with .ok() / Err(_) => None; now a non-string argument throws Security context must be a JSON string and a malformed string throws Security context is not valid JSON.
Throwing is the right call — the old path planned as if the caller had no security context and silently dropped the row-level filters derived from it. But it's a behaviour change to a shipped endpoint riding in on a PR titled "AST SQL rewriting": any native-addon consumer that passed an object instead of a string, or that relied on the silent fallback, now gets a hard rejection. Worth a line in the PR description at least, since js/index.ts stringifies for every in-repo caller and nothing in the test suite covers the direct-native case.
Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
|
Claude finished @MazterQyou's task in 7m 37s —— View job 2 medium, 1 low — solid implementation with unusually thorough test coverage; both mediums are in the error/logging plumbing, not the rewriting logic. Review details
Findings posted inline
Checked and clear
Not run
Review threadsThe listing returned 5 of my own threads (plus 2 CodeQL rate-limiting threads on |
|
|
||
| await this.resSqlFilters(result, res); | ||
| } catch (e: any) { | ||
| this.handleError({ |
There was a problem hiding this comment.
Passing the raw SQL string as query makes the logged query a character-indexed object. handleError runs it through sanitizeQueryForLogging (gateway.ts:2048), which does { ...query } — spreading a string yields {"0":"S","1":"E","2":"L",...}, one key per character of the statement. Both new handlers do this (gateway.ts:1611 and gateway.ts:1691); every other caller passes an object, and /v1/cubesql — the other endpoint taking a SQL API statement — wraps it as query: { sql: query } (gateway.ts:593).
Two more things are missing at the same site:
redactedQuery: this.redactedSqlForLog(query), which/v1/cubesqlpasses atgateway.ts:596. Without it the statement's literals reach the log sink verbatim even withCUBEJS_LOG_REDACTIONon — and/v1/sql-filterscarries literals both in the statement and in thevaluesof the filters it is handed.requestStarted, sothis.duration(requestStarted)(gateway.ts:737) returnsundefinedand the error event logs no duration.
| this.handleError({ | |
| this.handleError({ | |
| e, | |
| context, | |
| query: { sql: query }, | |
| redactedQuery: this.redactedSqlForLog(query), | |
| res, | |
| requestStarted, | |
| }); |
(needs const requestStarted = new Date(); at the top of each handler, as in sql() at gateway.ts:1711.)
| /// that is not there - is answered in-band as `{ status: "error" }`, which | ||
| /// the gateway maps to a 400. An internal one is thrown, so that it reaches | ||
| /// the gateway's error handler and is answered as the server fault it is. | ||
| fn in_band_or_thrown(err: CubeError) -> Result<SqlFiltersResponse, CubeError> { |
There was a problem hiding this comment.
A statement with no logical plan is classified as internal, so it lands on 500 rather than the in-band 400 this function exists to produce.
plan_and_extract_filters (rust/cubesql/cubesql/src/compile/ast_conv.rs:2676) wraps only the convert_sql_to_cube_query failure as CubeError::user; the try_as_logical_plan()? on the next line propagates plan.rs:117's CubeError::internal("This query doesnt have a plan, because it already has values for response") untouched. MetaOk / MetaTabular / CopyFrom / CreateEmptyTempTable all reach it, i.e. anything that compiles but isn't a SELECT.
GET /v1/sql-filters?query=SET%20timezone%20%3D%20%27UTC%27 → Internal(_) → thrown → 500 with an internal-sounding message, for a plain caller mistake. Same for set, delete and replace, which all call plan_and_extract_filters on the original query first.
It is also asymmetric with add, which parses before it plans: parse_single_query (ast_conv.rs:588) rejects the same input with NotImplemented("Only SELECT statements are supported"), which add_sql_filters maps to CubeError::user and the gateway answers 400. Two operations, one input, two status codes.
Fix at the source rather than here — in plan_and_extract_filters, map the try_as_logical_plan error to a user error, e.g.
let logical_plan = query_plan
.try_as_logical_plan()
.map_err(|_| CubeError::user("Only SELECT queries are supported".to_string()))?;| /// Upper bound on the predicates a clause of the outermost SELECT may hold. | ||
| /// The clause walks here are iterative; this backstops the renderer and the | ||
| /// drop, which recurse once per conjunct. Measured: a clause of twice this | ||
| /// overflows a 2 MiB worker stack in a debug build, one of this does not, | ||
| /// which `test_clause_at_the_bound_renders_on_a_worker_stack` pins. It sits | ||
| /// an order of magnitude above what [`MAX_FILTERS`] additions can build, so a | ||
| /// query this API produced is never one it then refuses. |
There was a problem hiding this comment.
Seven lines to say one thing. The load-bearing sentence is the measurement — the bound backstops a renderer that recurses per conjunct, and twice it overflows a 2 MiB worker stack. The rest (MAX_FILTERS comparison, "a query this API produced is never one it then refuses") re-derives from the two constants, and test_clause_at_the_bound_renders_on_a_worker_stack already names the pin it is being credited with:
/// Upper bound on the predicates a clause of the outermost SELECT may hold,
/// backstopping the renderer and the drop, which recurse per conjunct.
/// Measured: twice this overflows a 2 MiB worker stack in a debug build.
Same shape at ast_conv.rs:1934 (with_keys, five lines for "the two splits walk in the same order; if that ever stops holding the keys are recomputed") and ast_conv.rs:2451 (thirteen lines of normalized_filter_json). House rule is 3 lines for an explanatory comment — worth a pass over the file, which carries a lot of these.
Check List
Description of Changes Made
GET ?query= → extract filters from logical plan, no rewriting:
{ "status": "ok", "filters": [ ... ] }POST { query, add | set | delete | replace } - exactly one op required, else
Exactly one of add, set, delete or replace parameters is required:add:[filters]: adds each; already-present identical filter = no-op.set:[filters]: drops the filter predicates of the outermost WHERE + HAVING, then adds the set. Predicates that are not Cube filters (join conditions, subquery predicates, predicates over computed columns) are kept.set: []strips all outermost filters. CTEs and subqueries untouched.delete:[filters]: attempts deletion; all occurrences of equal filters removed; not-present = tolerated no-op.replace:{ old: [filters], new: [filters] }: exact set→set; every old must be found (else error); all occurrences replaced. Single old + single new in same clause → in-place, positions preserved; otherwise remove-all + add.