datafusion-query-builder 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/Cargo.lock +1 -1
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/Cargo.toml +1 -1
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/PKG-INFO +6 -1
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/README.md +5 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/pyproject.toml +1 -1
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/__init__.pyi +5 -0
- datafusion_query_builder-0.2.0/src/dialect.rs +54 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/expr.rs +7 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/lib.rs +1 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/lower.rs +24 -5
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/python.rs +26 -4
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/render.rs +4 -32
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/core.rs +79 -1
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/test_python.py +37 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/.github/workflows/ci.yml +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/.gitignore +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/LICENSE +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/__init__.py +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/py.typed +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/functions.rs +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/query.rs +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/properties.proptest-regressions +0 -0
- {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/properties.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datafusion-query-builder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -64,6 +64,11 @@ literals are escaped — values are injection-safe by construction. Reach for `r
|
|
|
64
64
|
fragment), `f.call("name", ...)` (any function), or `param("name")` (a `${name}` placeholder) when
|
|
65
65
|
you step outside the v1 grammar.
|
|
66
66
|
|
|
67
|
+
The JSONB key-exists operators are first-class: `col("attributes").has_key("gen_ai.input.messages")`
|
|
68
|
+
renders `attributes ? 'gen_ai.input.messages'` — a cheap presence check that never extracts the
|
|
69
|
+
value (contrast `raw("attributes ->> 'k'").is_not_null()`, which reads the whole value out just to
|
|
70
|
+
test it). `has_any_key` and `has_all_keys` render `?|` / `?&`.
|
|
71
|
+
|
|
67
72
|
## Architecture
|
|
68
73
|
|
|
69
74
|
```
|
|
@@ -44,6 +44,11 @@ literals are escaped — values are injection-safe by construction. Reach for `r
|
|
|
44
44
|
fragment), `f.call("name", ...)` (any function), or `param("name")` (a `${name}` placeholder) when
|
|
45
45
|
you step outside the v1 grammar.
|
|
46
46
|
|
|
47
|
+
The JSONB key-exists operators are first-class: `col("attributes").has_key("gen_ai.input.messages")`
|
|
48
|
+
renders `attributes ? 'gen_ai.input.messages'` — a cheap presence check that never extracts the
|
|
49
|
+
value (contrast `raw("attributes ->> 'k'").is_not_null()`, which reads the whole value out just to
|
|
50
|
+
test it). `has_any_key` and `has_all_keys` render `?|` / `?&`.
|
|
51
|
+
|
|
47
52
|
## Architecture
|
|
48
53
|
|
|
49
54
|
```
|
|
@@ -18,6 +18,11 @@ class Expr:
|
|
|
18
18
|
def is_null(self) -> Expr: ...
|
|
19
19
|
def is_not_null(self) -> Expr: ...
|
|
20
20
|
def between(self, low: IntoExpr, high: IntoExpr) -> Expr: ...
|
|
21
|
+
# JSONB key-exists operators (`?` / `?|` / `?&`). `has_key` is a cheap presence check that,
|
|
22
|
+
# unlike `col ->> 'k' is not None`, never extracts the value.
|
|
23
|
+
def has_key(self, key: IntoExpr) -> Expr: ...
|
|
24
|
+
def has_any_key(self, keys: Sequence[IntoExpr]) -> Expr: ...
|
|
25
|
+
def has_all_keys(self, keys: Sequence[IntoExpr]) -> Expr: ...
|
|
21
26
|
def asc(self, nulls_first: bool | None = ...) -> SortExpr: ...
|
|
22
27
|
def desc(self, nulls_first: bool | None = ...) -> SortExpr: ...
|
|
23
28
|
# Valid only on a function-call expression:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//! The single SQL dialect the builder parses against.
|
|
2
|
+
//!
|
|
3
|
+
//! Two call sites re-parse SQL: [`crate::lower`] parses `raw(...)` fragments and cast-type strings
|
|
4
|
+
//! into AST nodes, and [`crate::render`] re-parses the fully rendered query as a self-test. Both use
|
|
5
|
+
//! this one dialect so a `raw` fragment round-trips through the *same* grammar the final query is
|
|
6
|
+
//! validated against — otherwise a fragment could parse one way going in and fail (or mean something
|
|
7
|
+
//! else) coming out.
|
|
8
|
+
//!
|
|
9
|
+
//! It is `GenericDialect` plus the handful of capabilities DataFusion's own parser accepts that the
|
|
10
|
+
//! generic dialect gates off by default.
|
|
11
|
+
|
|
12
|
+
use sqlparser::dialect::{Dialect, GenericDialect};
|
|
13
|
+
|
|
14
|
+
/// Generic SQL plus the DataFusion-flavoured extensions the builder relies on.
|
|
15
|
+
#[derive(Debug, Default)]
|
|
16
|
+
pub(crate) struct BuilderDialect;
|
|
17
|
+
|
|
18
|
+
impl Dialect for BuilderDialect {
|
|
19
|
+
fn is_identifier_start(&self, ch: char) -> bool {
|
|
20
|
+
GenericDialect {}.is_identifier_start(ch)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn is_identifier_part(&self, ch: char) -> bool {
|
|
24
|
+
GenericDialect {}.is_identifier_part(ch)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// `${var}` dollar-brace placeholders (the pydantic `sqlparser` fork extension the DataFusion
|
|
28
|
+
/// planner relies on).
|
|
29
|
+
fn supports_dollar_placeholder(&self) -> bool {
|
|
30
|
+
true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Capabilities DataFusion's parser accepts that the generic dialect gates off by default.
|
|
34
|
+
fn supports_filter_during_aggregation(&self) -> bool {
|
|
35
|
+
true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn supports_group_by_expr(&self) -> bool {
|
|
39
|
+
true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Parse `?` / `?|` / `?&` as the Postgres-style JSON key-exists operators (the same ones our
|
|
43
|
+
/// engine uses), rather than as prepared-statement `?` placeholders.
|
|
44
|
+
///
|
|
45
|
+
/// The method name is an `sqlparser` quirk, not our intent: the fork happens to gate `?`-family
|
|
46
|
+
/// tokenization behind `supports_geometric_types` — it's the single lever that flips `?` from a
|
|
47
|
+
/// placeholder to `Token::Question`, and the real Postgres dialect enables the operators through
|
|
48
|
+
/// the same switch. This has nothing to do with geometry; we're here only for the JSON operators.
|
|
49
|
+
/// With it off, `attributes ? 'key'` tokenizes `?` as a placeholder, `parse_expr` stops at
|
|
50
|
+
/// `attributes`, and the operator is silently dropped.
|
|
51
|
+
fn supports_geometric_types(&self) -> bool {
|
|
52
|
+
true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -36,6 +36,13 @@ pub enum BinaryOp {
|
|
|
36
36
|
And,
|
|
37
37
|
Or,
|
|
38
38
|
StringConcat,
|
|
39
|
+
/// JSONB key-exists `?`: does the string on the right exist as a top-level key/element of the
|
|
40
|
+
/// JSON on the left. DataFusion's JSON extension maps it to `json_contains`.
|
|
41
|
+
JsonExists,
|
|
42
|
+
/// JSONB `?|`: does *any* string in the right-hand array exist as a top-level key/element.
|
|
43
|
+
JsonExistsAny,
|
|
44
|
+
/// JSONB `?&`: do *all* strings in the right-hand array exist as top-level keys/elements.
|
|
45
|
+
JsonExistsAll,
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
//! high-churn nodes are isolated into the small `lower_*` helpers below for exactly that reason.
|
|
7
7
|
|
|
8
8
|
use sqlparser::ast;
|
|
9
|
-
use sqlparser::dialect::GenericDialect;
|
|
10
9
|
use sqlparser::parser::Parser;
|
|
11
10
|
use sqlparser::tokenizer::Span;
|
|
12
11
|
|
|
12
|
+
use crate::dialect::BuilderDialect;
|
|
13
13
|
use crate::expr::{BinaryOp, Call, Expr, Scalar, SortExpr, UnaryOp, Window};
|
|
14
14
|
use crate::query::{Body, BuildError, Cte, Join, JoinKind, Query, Result, Select, SetOp, TableRef};
|
|
15
15
|
|
|
@@ -78,7 +78,7 @@ fn string_literal(s: &str) -> ast::Expr {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
fn parse_data_type(text: &str) -> Result<ast::DataType> {
|
|
81
|
-
let dialect =
|
|
81
|
+
let dialect = BuilderDialect;
|
|
82
82
|
let mut parser = Parser::new(&dialect)
|
|
83
83
|
.try_with_sql(text)
|
|
84
84
|
.map_err(|e| BuildError::UnparsableSql(format!("invalid cast type {text:?}: {e}")))?;
|
|
@@ -109,6 +109,10 @@ fn binary_op_prec(op: BinaryOp) -> u8 {
|
|
|
109
109
|
| BinaryOp::LtEq
|
|
110
110
|
| BinaryOp::Gt
|
|
111
111
|
| BinaryOp::GtEq => 4,
|
|
112
|
+
// JSONB key-exists operators return a boolean and are typically combined with AND/OR/NOT;
|
|
113
|
+
// one comparison-level tier keeps `(a ? 'x') AND (b ? 'y')` and `NOT (a ? 'x')` grouped
|
|
114
|
+
// correctly, which is all realistic usage needs.
|
|
115
|
+
BinaryOp::JsonExists | BinaryOp::JsonExistsAny | BinaryOp::JsonExistsAll => 4,
|
|
112
116
|
BinaryOp::Plus | BinaryOp::Minus | BinaryOp::StringConcat => 5,
|
|
113
117
|
BinaryOp::Multiply | BinaryOp::Divide | BinaryOp::Modulo => 6,
|
|
114
118
|
}
|
|
@@ -154,6 +158,9 @@ fn lower_binary_op(op: BinaryOp) -> ast::BinaryOperator {
|
|
|
154
158
|
BinaryOp::And => B::And,
|
|
155
159
|
BinaryOp::Or => B::Or,
|
|
156
160
|
BinaryOp::StringConcat => B::StringConcat,
|
|
161
|
+
BinaryOp::JsonExists => B::Question,
|
|
162
|
+
BinaryOp::JsonExistsAny => B::QuestionPipe,
|
|
163
|
+
BinaryOp::JsonExistsAll => B::QuestionAnd,
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
@@ -361,14 +368,26 @@ pub fn lower_expr(expr: &Expr) -> Result<ast::Expr> {
|
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
/// Parse a raw fragment into an expression so it composes with the rest of the AST.
|
|
371
|
+
///
|
|
372
|
+
/// `parse_expr` stops at the first token it can't continue on and returns the partial expression it
|
|
373
|
+
/// has so far — so a fragment like `attributes ? 'k'` parsed under a dialect that doesn't know `?`
|
|
374
|
+
/// would yield just `attributes`, silently dropping the rest. We parse with [`BuilderDialect`] (which
|
|
375
|
+
/// *does* understand the JSONB operators) and then assert the parser reached EOF, turning any
|
|
376
|
+
/// leftover tokens into a loud error instead of a truncated query. Mirrors [`parse_data_type`].
|
|
364
377
|
fn parse_raw_expr(sql: &str) -> Result<ast::Expr> {
|
|
365
|
-
let dialect =
|
|
378
|
+
let dialect = BuilderDialect;
|
|
366
379
|
let mut parser = Parser::new(&dialect)
|
|
367
380
|
.try_with_sql(sql)
|
|
368
381
|
.map_err(|e| BuildError::UnparsableSql(format!("invalid raw expression {sql:?}: {e}")))?;
|
|
369
|
-
parser
|
|
382
|
+
let expr = parser
|
|
370
383
|
.parse_expr()
|
|
371
|
-
.map_err(|e| BuildError::UnparsableSql(format!("invalid raw expression {sql:?}: {e}")))
|
|
384
|
+
.map_err(|e| BuildError::UnparsableSql(format!("invalid raw expression {sql:?}: {e}")))?;
|
|
385
|
+
if parser.peek_token().token != sqlparser::tokenizer::Token::EOF {
|
|
386
|
+
return Err(BuildError::UnparsableSql(format!(
|
|
387
|
+
"invalid raw expression {sql:?}: unexpected trailing tokens"
|
|
388
|
+
)));
|
|
389
|
+
}
|
|
390
|
+
Ok(expr)
|
|
372
391
|
}
|
|
373
392
|
|
|
374
393
|
fn lower_select_item(expr: &Expr) -> Result<ast::SelectItem> {
|
|
@@ -172,7 +172,7 @@ impl PyExpr {
|
|
|
172
172
|
self.inner.clone().in_subquery(q.inner.clone(), false),
|
|
173
173
|
));
|
|
174
174
|
}
|
|
175
|
-
let list = coerce_list(values)?;
|
|
175
|
+
let list = coerce_list(values, "expected a list/tuple of values or a subquery")?;
|
|
176
176
|
Ok(PyExpr::new(self.inner.clone().in_list(list, false)))
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -183,7 +183,7 @@ impl PyExpr {
|
|
|
183
183
|
self.inner.clone().in_subquery(q.inner.clone(), true),
|
|
184
184
|
));
|
|
185
185
|
}
|
|
186
|
-
let list = coerce_list(values)?;
|
|
186
|
+
let list = coerce_list(values, "expected a list/tuple of values or a subquery")?;
|
|
187
187
|
Ok(PyExpr::new(self.inner.clone().in_list(list, true)))
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -199,6 +199,28 @@ impl PyExpr {
|
|
|
199
199
|
PyExpr::new(self.inner.clone().between(low.0, high.0, false))
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/// JSONB key-exists `?`: does `key` exist as a top-level key/element of this JSON value.
|
|
203
|
+
/// A cheap presence check that (unlike `->> key IS NOT NULL`) never extracts the value.
|
|
204
|
+
fn has_key(&self, key: ExprArg) -> PyExpr {
|
|
205
|
+
self.bin(BinaryOp::JsonExists, key)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// JSONB `?|`: does *any* of `keys` exist as a top-level key/element.
|
|
209
|
+
fn has_any_key(&self, keys: &Bound<'_, PyAny>) -> PyResult<PyExpr> {
|
|
210
|
+
let array = Expr::Array(coerce_list(keys, "expected a list/tuple of keys")?);
|
|
211
|
+
Ok(PyExpr::new(
|
|
212
|
+
self.inner.clone().binary(BinaryOp::JsonExistsAny, array),
|
|
213
|
+
))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// JSONB `?&`: do *all* of `keys` exist as top-level keys/elements.
|
|
217
|
+
fn has_all_keys(&self, keys: &Bound<'_, PyAny>) -> PyResult<PyExpr> {
|
|
218
|
+
let array = Expr::Array(coerce_list(keys, "expected a list/tuple of keys")?);
|
|
219
|
+
Ok(PyExpr::new(
|
|
220
|
+
self.inner.clone().binary(BinaryOp::JsonExistsAll, array),
|
|
221
|
+
))
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
#[pyo3(signature = (nulls_first=None))]
|
|
203
225
|
fn asc(&self, nulls_first: Option<bool>) -> PySortExpr {
|
|
204
226
|
PySortExpr {
|
|
@@ -334,10 +356,10 @@ impl PyExpr {
|
|
|
334
356
|
}
|
|
335
357
|
}
|
|
336
358
|
|
|
337
|
-
fn coerce_list(obj: &Bound<'_, PyAny
|
|
359
|
+
fn coerce_list(obj: &Bound<'_, PyAny>, expected: &'static str) -> PyResult<Vec<Expr>> {
|
|
338
360
|
let items: Vec<ExprArg> = obj
|
|
339
361
|
.extract()
|
|
340
|
-
.map_err(|_| QueryBuilderError::new_err(
|
|
362
|
+
.map_err(|_| QueryBuilderError::new_err(expected))?;
|
|
341
363
|
Ok(items.into_iter().map(|a| a.0).collect())
|
|
342
364
|
}
|
|
343
365
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
//! Rendering façade queries to SQL text, plus a cheap round-trip parse self-test.
|
|
2
2
|
|
|
3
|
-
use sqlparser::dialect::{Dialect, GenericDialect};
|
|
4
3
|
use sqlparser::parser::Parser;
|
|
5
4
|
|
|
5
|
+
use crate::dialect::BuilderDialect;
|
|
6
6
|
use crate::lower::lower_query;
|
|
7
7
|
use crate::query::{BuildError, Query, Result};
|
|
8
8
|
|
|
@@ -11,42 +11,14 @@ pub fn to_sql(query: &Query) -> Result<String> {
|
|
|
11
11
|
Ok(lower_query(query)?.to_string())
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/// A parsing dialect that mirrors what DataFusion accepts closely enough to prove a generated
|
|
15
|
-
/// query is well-formed: generic SQL plus `${var}` dollar-brace placeholders (the pydantic
|
|
16
|
-
/// `sqlparser` fork extension).
|
|
17
|
-
#[derive(Debug, Default)]
|
|
18
|
-
struct ValidationDialect;
|
|
19
|
-
|
|
20
|
-
impl Dialect for ValidationDialect {
|
|
21
|
-
fn is_identifier_start(&self, ch: char) -> bool {
|
|
22
|
-
GenericDialect {}.is_identifier_start(ch)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
fn is_identifier_part(&self, ch: char) -> bool {
|
|
26
|
-
GenericDialect {}.is_identifier_part(ch)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
fn supports_dollar_placeholder(&self) -> bool {
|
|
30
|
-
true
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Capabilities DataFusion's parser accepts that the generic dialect gates off by default.
|
|
34
|
-
fn supports_filter_during_aggregation(&self) -> bool {
|
|
35
|
-
true
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
fn supports_group_by_expr(&self) -> bool {
|
|
39
|
-
true
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
14
|
/// Render and re-parse the query, returning the SQL on success. Proves *parseability* (catches a
|
|
44
15
|
/// malformed `raw(...)` fragment or a structural bug); it does not prove the query will *plan*
|
|
45
16
|
/// (unknown columns/functions/types are caught by DataFusion at plan time). Returns the SQL so callers can
|
|
46
|
-
/// validate-and-use in one step.
|
|
17
|
+
/// validate-and-use in one step. Re-parses with [`BuilderDialect`] — the same grammar `raw(...)`
|
|
18
|
+
/// fragments are parsed against — so a fragment that lowered cleanly always re-parses.
|
|
47
19
|
pub fn validate(query: &Query) -> Result<String> {
|
|
48
20
|
let sql = to_sql(query)?;
|
|
49
|
-
let dialect =
|
|
21
|
+
let dialect = BuilderDialect;
|
|
50
22
|
Parser::parse_sql(&dialect, &sql).map_err(|e| {
|
|
51
23
|
// The builder produced SQL that doesn't parse — a bug in the builder, not caller input.
|
|
52
24
|
BuildError::Misuse(format!(
|
|
@@ -6,7 +6,7 @@ use insta::assert_snapshot;
|
|
|
6
6
|
|
|
7
7
|
use datafusion_query_builder::expr::{BinaryOp, Expr, Scalar, UnaryOp};
|
|
8
8
|
use datafusion_query_builder::functions::{call, count_star};
|
|
9
|
-
use datafusion_query_builder::query::{JoinKind, Query, SetOp, TableRef};
|
|
9
|
+
use datafusion_query_builder::query::{BuildError, JoinKind, Query, SetOp, TableRef};
|
|
10
10
|
use datafusion_query_builder::{to_sql, validate};
|
|
11
11
|
|
|
12
12
|
fn lit_str(s: &str) -> Expr {
|
|
@@ -224,6 +224,84 @@ fn invalid_raw_fragment_is_rejected() {
|
|
|
224
224
|
assert!(to_sql(&q).is_err());
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
#[test]
|
|
228
|
+
fn raw_json_key_exists_no_longer_truncates() {
|
|
229
|
+
// Regression: `raw("attributes ? '...'")` used to render as just `attributes` — the JSONB `?`
|
|
230
|
+
// key-exists operator was silently dropped because the raw fragment was parsed with a dialect
|
|
231
|
+
// that tokenized `?` as a prepared-statement placeholder and `parse_expr` stopped early. It now
|
|
232
|
+
// parses (and re-parses) faithfully.
|
|
233
|
+
let q = Query::table("records")
|
|
234
|
+
.select(vec![
|
|
235
|
+
Expr::raw("attributes ? 'gen_ai.input.messages'").alias("x"),
|
|
236
|
+
])
|
|
237
|
+
.unwrap();
|
|
238
|
+
assert_snapshot!(
|
|
239
|
+
validate(&q).unwrap(),
|
|
240
|
+
@"SELECT attributes ? 'gen_ai.input.messages' AS x FROM records"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#[test]
|
|
245
|
+
fn raw_fragment_with_trailing_tokens_is_rejected() {
|
|
246
|
+
// The other half of the truncation bug: a fragment that parses a valid leading expression but
|
|
247
|
+
// leaves tokens behind must error, not silently keep only the prefix. Pre-fix `1 + 1 oops`
|
|
248
|
+
// rendered as `1 + 1`.
|
|
249
|
+
let q = Query::table("t")
|
|
250
|
+
.select(vec![Expr::raw("1 + 1 oops").alias("x")])
|
|
251
|
+
.unwrap();
|
|
252
|
+
let err = to_sql(&q).unwrap_err();
|
|
253
|
+
assert!(
|
|
254
|
+
matches!(&err, BuildError::UnparsableSql(m) if m.contains("trailing")),
|
|
255
|
+
"unexpected error: {err:?}"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn json_key_exists_operators_render_natively() {
|
|
261
|
+
// The three JSONB key-exists operators as first-class `BinaryOp`s, so callers don't need `raw()`.
|
|
262
|
+
let exists = Expr::column("attributes")
|
|
263
|
+
.binary(BinaryOp::JsonExists, lit_str("gen_ai.input.messages"))
|
|
264
|
+
.alias("has_msgs");
|
|
265
|
+
let any = Expr::column("attributes")
|
|
266
|
+
.binary(
|
|
267
|
+
BinaryOp::JsonExistsAny,
|
|
268
|
+
Expr::Array(vec![lit_str("a"), lit_str("b")]),
|
|
269
|
+
)
|
|
270
|
+
.alias("has_any");
|
|
271
|
+
let all = Expr::column("attributes")
|
|
272
|
+
.binary(
|
|
273
|
+
BinaryOp::JsonExistsAll,
|
|
274
|
+
Expr::Array(vec![lit_str("a"), lit_str("b")]),
|
|
275
|
+
)
|
|
276
|
+
.alias("has_all");
|
|
277
|
+
let q = Query::table("records")
|
|
278
|
+
.select(vec![exists, any, all])
|
|
279
|
+
.unwrap();
|
|
280
|
+
assert_snapshot!(
|
|
281
|
+
validate(&q).unwrap(),
|
|
282
|
+
@"SELECT attributes ? 'gen_ai.input.messages' AS has_msgs, attributes ?| ARRAY['a', 'b'] AS has_any, attributes ?& ARRAY['a', 'b'] AS has_all FROM records"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn json_key_exists_combines_with_boolean_ops_without_losing_grouping() {
|
|
288
|
+
// Combined with AND / NOT: the key-exists operands must stay grouped so the meaning survives a
|
|
289
|
+
// round-trip through the parser.
|
|
290
|
+
let pred = Expr::unary(
|
|
291
|
+
UnaryOp::Not,
|
|
292
|
+
Expr::column("attributes").binary(BinaryOp::JsonExists, lit_str("a")),
|
|
293
|
+
)
|
|
294
|
+
.binary(
|
|
295
|
+
BinaryOp::And,
|
|
296
|
+
Expr::column("resource").binary(BinaryOp::JsonExists, lit_str("b")),
|
|
297
|
+
);
|
|
298
|
+
let q = Query::table("records").filter(pred).unwrap();
|
|
299
|
+
assert_snapshot!(
|
|
300
|
+
validate(&q).unwrap(),
|
|
301
|
+
@"SELECT * FROM records WHERE NOT attributes ? 'a' AND resource ? 'b'"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
227
305
|
#[test]
|
|
228
306
|
fn string_literal_with_backslash_quote_round_trips() {
|
|
229
307
|
// Regression: a value of `\'` (backslash + single quote) must render as a properly escaped
|
|
@@ -135,6 +135,43 @@ def test_injection_is_escaped():
|
|
|
135
135
|
q.validate() # still a single well-formed statement
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def test_json_key_exists_operators():
|
|
139
|
+
# The JSONB key-exists family: `?`, `?|`, `?&`. `has_key` is the cheap presence check the
|
|
140
|
+
# value-extraction fallback (`->> 'k' is not null`) was standing in for.
|
|
141
|
+
q = (
|
|
142
|
+
table('records')
|
|
143
|
+
.select(
|
|
144
|
+
col('attributes').has_key('gen_ai.input.messages').alias('has_msgs'),
|
|
145
|
+
col('attributes').has_any_key(['a', 'b']).alias('has_any'),
|
|
146
|
+
col('attributes').has_all_keys(['a', 'b']).alias('has_all'),
|
|
147
|
+
)
|
|
148
|
+
.filter(col('attributes').has_key('gen_ai.input.messages'))
|
|
149
|
+
)
|
|
150
|
+
assert q.validate() == (
|
|
151
|
+
"SELECT attributes ? 'gen_ai.input.messages' AS has_msgs, "
|
|
152
|
+
"attributes ?| ARRAY['a', 'b'] AS has_any, "
|
|
153
|
+
"attributes ?& ARRAY['a', 'b'] AS has_all "
|
|
154
|
+
"FROM records WHERE attributes ? 'gen_ai.input.messages'"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_raw_json_operator_no_longer_silently_truncated():
|
|
159
|
+
# The reported bug: raw() dropped the `?` operator, rendering just `attributes`. It now renders
|
|
160
|
+
# (and re-parses) faithfully.
|
|
161
|
+
q = table('records').select(raw("attributes ? 'gen_ai.input.messages'").alias('x'))
|
|
162
|
+
assert q.validate() == "SELECT attributes ? 'gen_ai.input.messages' AS x FROM records"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_raw_fragment_with_trailing_tokens_raises():
|
|
166
|
+
# A fragment that parses a leading expression but leaves tokens behind is now a loud error
|
|
167
|
+
# rather than a silent truncation to the prefix.
|
|
168
|
+
try:
|
|
169
|
+
table('t').select(raw('1 + 1 oops')).to_sql()
|
|
170
|
+
except UnparsableSqlError:
|
|
171
|
+
return
|
|
172
|
+
raise AssertionError('expected UnparsableSqlError for trailing tokens')
|
|
173
|
+
|
|
174
|
+
|
|
138
175
|
def test_unparsable_sql_raises_unparsable_sql_error():
|
|
139
176
|
# A bad raw() fragment or cast type is caller-supplied bad SQL -> UnparsableSqlError.
|
|
140
177
|
assert issubclass(UnparsableSqlError, QueryBuilderError)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|