You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(sql): validate table qualifiers in FTS function column args (SQLR-15) (#168)
fts_match() / bm25_score() extract their column argument syntactically
(they need the column name to find the index, not its value), so the
qualifier never passed through RowScope::lookup and SQLR-14's check.
A bogus qualifier was silently dropped: fts_match(bogus.body, 'q') ran
as fts_match(body, 'q').
Add RowScope::scope_name() (Some(name) for single-table scope, None for
joined / group-row scopes) and run check_single_scope_qualifier in
resolve_fts_args when the column arg is a CompoundIdentifier. Error
wording matches SQLR-14. 3+-part identifiers now error like the main
evaluator instead of being silently truncated.
Audited the other syntactic extraction sites: JSON and vec_distance
helpers evaluate args through eval_expr_scope (already covered);
pager catalog re-parses are trusted engine-written SQL.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/fts.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -105,6 +105,8 @@ The function requires a TEXT column with an FTS index attached. Without one, it
105
105
fts_match(body, ...): no FTS index on column 'body' (run CREATE INDEX <name> ON <table> USING fts(body) first)
106
106
```
107
107
108
+
The column argument may be qualified (`fts_match(docs.body, 'q')` or the FROM alias). The qualifier is validated the same way as any column reference (SQLR-15): it must name the table in scope, else the query errors with `unknown table qualifier`.
109
+
108
110
This contrasts with SQLite's `MATCH` operator, which is parser-level; SQLRite uses a function-call shape because `sqlparser`'s SQLite dialect doesn't expose a `BinaryOperator::Match` variant we can dispatch on ([Q1](phase-8-plan.md#q1-match-operator-syntax)).
109
111
110
112
### `bm25_score(col, 'q')`
@@ -119,7 +121,7 @@ Notes:
119
121
120
122
-**`DESC`** is the conventional direction. `ASC` works (returns least-relevant first) but disables the optimizer probe — see [Optimizer hook](#optimizer-hook).
121
123
-**Same query string** must appear in `WHERE fts_match(...)` and `ORDER BY bm25_score(...)`. The optimizer recognizes the joint pattern; mismatched strings fall through to a slow per-row evaluation.
122
-
-**Same caveats as `fts_match`:** requires an FTS index on the column; errors clearly otherwise.
124
+
-**Same caveats as `fts_match`:** requires an FTS index on the column; errors clearly otherwise. Qualified column args (`bm25_score(docs.body, …)`) are validated against the table in scope (SQLR-15) — note the optimizer probe only matches a *bare* column identifier, so a qualified arg always takes the per-row evaluation path.
123
125
124
126
The math is the standard BM25+ variant (`k1 = 1.5`, `b = 0.75`, fixed at SQLite FTS5 defaults). Full formula in [`src/sql/fts/bm25.rs`](../src/sql/fts/bm25.rs).
Copy file name to clipboardExpand all lines: docs/sql-engine.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -82,7 +82,7 @@ match query {
82
82
83
83
-`Expr::Nested(inner)` → recurse
84
84
-`Expr::Identifier(ident)` → look up `ident.value` on the table at the given rowid
85
-
-`Expr::CompoundIdentifier(parts)` → same, after validating the qualifier against the one table in scope — `scope_name` is the FROM alias when declared, else the table name, and anything else errors with `unknown table qualifier` (SQLR-14, matching the joined scope's behavior)
85
+
-`Expr::CompoundIdentifier(parts)` → same, after validating the qualifier against the one table in scope — `scope_name` is the FROM alias when declared, else the table name, and anything else errors with `unknown table qualifier` (SQLR-14, matching the joined scope's behavior). Functions that extract a column *name* syntactically instead of resolving a value — `fts_match` / `bm25_score`'s first argument — run the same check via `RowScope::scope_name()` (SQLR-15)
86
86
-`Expr::Value(v)` → convert a sqlparser literal to a runtime `Value`
-**Projection**: `*` (all columns in declaration order), a bare column list, or an explicit list mixing bare columns and aggregate calls. Each item can carry an optional `AS alias` (the alias becomes the output column header and is recognized by `ORDER BY`).
205
-
-**Qualified column references** (SQLR-14): `t.col` works in the projection, `WHERE`, `GROUP BY`, `ORDER BY`, and aggregate arguments, but the qualifier must name the table in scope — the `FROM` alias when one is declared, else the table name (ASCII case-insensitive). Anything else errors with `unknown table qualifier`, matching the joined-scope behavior and SQLite. Declaring an alias removes the table name from scope (`SELECT t.id FROM t AS a` errors), and the same validation applies to `UPDATE` / `DELETE` (including alias forms `UPDATE t AS a …` / `DELETE FROM t AS a …`).
205
+
-**Qualified column references** (SQLR-14): `t.col` works in the projection, `WHERE`, `GROUP BY`, `ORDER BY`, and aggregate arguments, but the qualifier must name the table in scope — the `FROM` alias when one is declared, else the table name (ASCII case-insensitive). Anything else errors with `unknown table qualifier`, matching the joined-scope behavior and SQLite. Declaring an alias removes the table name from scope (`SELECT t.id FROM t AS a` errors), and the same validation applies to `UPDATE` / `DELETE` (including alias forms `UPDATE t AS a …` / `DELETE FROM t AS a …`). The column argument of `fts_match` / `bm25_score` gets the same check (SQLR-15) — `fts_match(bogus.body, 'q')` errors instead of silently dropping the qualifier.
206
206
-**`WHERE`**: any [expression](#expressions). Evaluated per row; NULL-as-false in WHERE context (three-valued logic collapsed to two-valued for filtering). Includes **`IS NULL`** / **`IS NOT NULL`** for explicit null tests, **`LIKE` / `NOT LIKE` / `ILIKE`** for pattern matching, and **`IN (list) / NOT IN (list)`** for set-membership against literal lists.
207
207
-**`DISTINCT`**: `SELECT DISTINCT` deduplicates result rows after projection (and after aggregation, when both apply). `NULL` values compare equal to other `NULL`s for dedupe, matching SQL's DISTINCT semantic.
208
208
-**`GROUP BY`**: one or more column names, optionally qualified (`GROUP BY customers.name`) — the qualifier disambiguates same-named columns across joined tables (SQLR-6). Every non-aggregate item in the projection must appear in the `GROUP BY` list (rejected with a clear message — by the parser for single-table queries, by the executor for joined ones, where resolving qualifiers needs the schemas). `GROUP BY <col>` without any aggregate behaves like an implicit `DISTINCT <col>`.
0 commit comments