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.
Files changed (23) hide show
  1. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/Cargo.lock +1 -1
  2. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/Cargo.toml +1 -1
  3. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/PKG-INFO +6 -1
  4. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/README.md +5 -0
  5. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/pyproject.toml +1 -1
  6. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/__init__.pyi +5 -0
  7. datafusion_query_builder-0.2.0/src/dialect.rs +54 -0
  8. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/expr.rs +7 -0
  9. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/lib.rs +1 -0
  10. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/lower.rs +24 -5
  11. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/python.rs +26 -4
  12. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/render.rs +4 -32
  13. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/core.rs +79 -1
  14. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/test_python.py +37 -0
  15. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/.github/workflows/ci.yml +0 -0
  16. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/.gitignore +0 -0
  17. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/LICENSE +0 -0
  18. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/__init__.py +0 -0
  19. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/python/datafusion_query_builder/py.typed +0 -0
  20. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/functions.rs +0 -0
  21. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/src/query.rs +0 -0
  22. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/properties.proptest-regressions +0 -0
  23. {datafusion_query_builder-0.1.2 → datafusion_query_builder-0.2.0}/tests/properties.rs +0 -0
@@ -1302,7 +1302,7 @@ dependencies = [
1302
1302
 
1303
1303
  [[package]]
1304
1304
  name = "datafusion-query-builder"
1305
- version = "0.1.2"
1305
+ version = "0.2.0"
1306
1306
  dependencies = [
1307
1307
  "datafusion",
1308
1308
  "insta",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafusion-query-builder"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.85.0"
6
6
  description = "Programmatic, injection-safe builder for DataFusion SQL — a typed Rust core with a Python API."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datafusion-query-builder
3
- Version: 0.1.2
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
  ```
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "datafusion-query-builder"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Programmatic, injection-safe builder for DataFusion SQL."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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)]
@@ -15,6 +15,7 @@
15
15
  clippy::single_match_else
16
16
  )]
17
17
 
18
+ mod dialect;
18
19
  pub mod expr;
19
20
  pub mod functions;
20
21
  pub mod lower;
@@ -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 = GenericDialect {};
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 = GenericDialect {};
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>) -> PyResult<Vec<Expr>> {
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("expected a list/tuple of values or a subquery"))?;
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 = ValidationDialect;
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)