datafusion-query-builder 0.2.0__tar.gz → 0.3.1__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.2.0 → datafusion_query_builder-0.3.1}/Cargo.lock +1 -1
  2. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/Cargo.toml +1 -1
  3. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/PKG-INFO +7 -1
  4. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/README.md +6 -0
  5. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/pyproject.toml +1 -1
  6. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/python/datafusion_query_builder/__init__.pyi +1 -0
  7. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/lower.rs +145 -52
  8. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/python.rs +8 -0
  9. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/query.rs +14 -0
  10. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/tests/core.rs +163 -0
  11. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/tests/properties.proptest-regressions +1 -0
  12. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/tests/properties.rs +167 -0
  13. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/tests/test_python.py +30 -0
  14. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/.github/workflows/ci.yml +0 -0
  15. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/.gitignore +0 -0
  16. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/LICENSE +0 -0
  17. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/python/datafusion_query_builder/__init__.py +0 -0
  18. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/python/datafusion_query_builder/py.typed +0 -0
  19. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/dialect.rs +0 -0
  20. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/expr.rs +0 -0
  21. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/functions.rs +0 -0
  22. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/lib.rs +0 -0
  23. {datafusion_query_builder-0.2.0 → datafusion_query_builder-0.3.1}/src/render.rs +0 -0
@@ -1302,7 +1302,7 @@ dependencies = [
1302
1302
 
1303
1303
  [[package]]
1304
1304
  name = "datafusion-query-builder"
1305
- version = "0.2.0"
1305
+ version = "0.3.1"
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.2.0"
3
+ version = "0.3.1"
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.2.0
3
+ Version: 0.3.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -64,6 +64,12 @@ 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
+ A `raw(...)` fragment must be one complete SQL expression. The builder parses it on its own, drops
68
+ any comments, and adds brackets where its top-level operator binds looser than the surrounding
69
+ one, as it does for native expressions. Pass a user-written predicate as `raw(predicate)`, not
70
+ `raw(f"({predicate})")`. Text wrapping breaks on a trailing `-- comment`, and a predicate such as
71
+ `1=1) OR (1=1` can close the bracket.
72
+
67
73
  The JSONB key-exists operators are first-class: `col("attributes").has_key("gen_ai.input.messages")`
68
74
  renders `attributes ? 'gen_ai.input.messages'` — a cheap presence check that never extracts the
69
75
  value (contrast `raw("attributes ->> 'k'").is_not_null()`, which reads the whole value out just to
@@ -44,6 +44,12 @@ 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
+ A `raw(...)` fragment must be one complete SQL expression. The builder parses it on its own, drops
48
+ any comments, and adds brackets where its top-level operator binds looser than the surrounding
49
+ one, as it does for native expressions. Pass a user-written predicate as `raw(predicate)`, not
50
+ `raw(f"({predicate})")`. Text wrapping breaks on a trailing `-- comment`, and a predicate such as
51
+ `1=1) OR (1=1` can close the bracket.
52
+
47
53
  The JSONB key-exists operators are first-class: `col("attributes").has_key("gen_ai.input.messages")`
48
54
  renders `attributes ? 'gen_ai.input.messages'` — a cheap presence check that never extracts the
49
55
  value (contrast `raw("attributes ->> 'k'").is_not_null()`, which reads the whole value out just to
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "datafusion-query-builder"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Programmatic, injection-safe builder for DataFusion SQL."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -84,6 +84,7 @@ class Query:
84
84
  ) -> Query: ...
85
85
  def cross_join(self, other: str | Query, alias: str | None = ...) -> Query: ...
86
86
  def with_cte(self, name: str, query: Query) -> Query: ...
87
+ def with_recursive_cte(self, name: str, query: Query) -> Query: ...
87
88
  def order_by(self, *sorts: IntoSort) -> Query: ...
88
89
  def limit(self, n: int) -> Query: ...
89
90
  def offset(self, n: int) -> Query: ...
@@ -95,41 +95,131 @@ fn parse_data_type(text: &str) -> Result<ast::DataType> {
95
95
  Ok(data_type)
96
96
  }
97
97
 
98
- /// Binding tightness of an operator (higher binds tighter), used to decide where to insert
99
- /// parentheses. `sqlparser`'s `Display` is purely structural — it never adds precedence parens —
100
- /// so the builder must wrap operands itself or a tree like `a / (b - c)` would render as the
101
- /// semantically different `a / b - c`.
98
+ // Binding tightness tiers (higher binds tighter), used to decide where to insert parentheses.
99
+ // `sqlparser`'s `Display` is purely structural — it never adds precedence parens — so the builder
100
+ // must wrap operands itself or a tree like `a / (b - c)` would render as the semantically different
101
+ // `a / b - c`.
102
+ const OR_PREC: u8 = 10;
103
+ const AND_PREC: u8 = 20;
104
+ const NOT_PREC: u8 = 30;
105
+ /// `IS [NOT] NULL`, `IN`, `BETWEEN`, `LIKE` and friends. Dialects disagree on where these sit
106
+ /// relative to comparisons (Postgres binds `LIKE` tighter than `=`, `GenericDialect` looser), so
107
+ /// they rank just below comparisons: always wrapped inside a comparison or arithmetic operand,
108
+ /// never wrapped under `AND` / `OR` / `NOT`.
109
+ const PREDICATE_PREC: u8 = 35;
110
+ const COMPARISON_PREC: u8 = 40;
111
+ /// Operators outside the builder's own set (`->>`, `@>`, bitwise, custom). Postgres binds these
112
+ /// tighter than comparisons and looser than arithmetic.
113
+ const OTHER_OP_PREC: u8 = 45;
114
+ const ADDITIVE_PREC: u8 = 50;
115
+ const MULTIPLICATIVE_PREC: u8 = 60;
116
+ const AT_TIME_ZONE_PREC: u8 = 65;
117
+ const PREFIX_PREC: u8 = 70;
118
+ const ATOM_PREC: u8 = u8::MAX;
119
+
102
120
  fn binary_op_prec(op: BinaryOp) -> u8 {
103
121
  match op {
104
- BinaryOp::Or => 1,
105
- BinaryOp::And => 2,
122
+ BinaryOp::Or => OR_PREC,
123
+ BinaryOp::And => AND_PREC,
106
124
  BinaryOp::Eq
107
125
  | BinaryOp::NotEq
108
126
  | BinaryOp::Lt
109
127
  | BinaryOp::LtEq
110
128
  | BinaryOp::Gt
111
- | BinaryOp::GtEq => 4,
129
+ | BinaryOp::GtEq => COMPARISON_PREC,
112
130
  // JSONB key-exists operators return a boolean and are typically combined with AND/OR/NOT;
113
131
  // one comparison-level tier keeps `(a ? 'x') AND (b ? 'y')` and `NOT (a ? 'x')` grouped
114
132
  // correctly, which is all realistic usage needs.
115
- BinaryOp::JsonExists | BinaryOp::JsonExistsAny | BinaryOp::JsonExistsAll => 4,
116
- BinaryOp::Plus | BinaryOp::Minus | BinaryOp::StringConcat => 5,
117
- BinaryOp::Multiply | BinaryOp::Divide | BinaryOp::Modulo => 6,
133
+ BinaryOp::JsonExists | BinaryOp::JsonExistsAny | BinaryOp::JsonExistsAll => COMPARISON_PREC,
134
+ BinaryOp::Plus | BinaryOp::Minus | BinaryOp::StringConcat => ADDITIVE_PREC,
135
+ BinaryOp::Multiply | BinaryOp::Divide | BinaryOp::Modulo => MULTIPLICATIVE_PREC,
118
136
  }
119
137
  }
120
138
 
121
- /// Binding tightness of an expression; atoms (columns, literals, function calls, casts, …) bind
122
- /// tightest and never need wrapping.
123
- fn expr_prec(expr: &Expr) -> u8 {
139
+ /// Binding tightness of an already-lowered expression.
140
+ ///
141
+ /// This reads the `sqlparser` AST rather than the façade, because a `raw(...)` fragment is opaque
142
+ /// until it is parsed: `raw("a = 1 OR b = 2")` is an `OR` and must be wrapped like one, or
143
+ /// `raw("a = 1 OR b = 2") & c` renders as `a = 1 OR b = 2 AND c`. Anything not recognized as an
144
+ /// atom ranks lowest, so an unfamiliar node gets redundant parentheses rather than a wrong grouping.
145
+ fn ast_prec(expr: &ast::Expr) -> u8 {
146
+ use ast::BinaryOperator as B;
147
+ use ast::Expr as E;
124
148
  match expr {
125
- Expr::Binary { op, .. } => binary_op_prec(*op),
126
- Expr::Unary {
127
- op: UnaryOp::Not, ..
128
- } => 3,
129
- Expr::Unary {
130
- op: UnaryOp::Neg, ..
131
- } => 7,
132
- _ => u8::MAX,
149
+ E::BinaryOp { op, .. } => match op {
150
+ B::Or => OR_PREC,
151
+ B::And => AND_PREC,
152
+ B::Eq
153
+ | B::NotEq
154
+ | B::Lt
155
+ | B::LtEq
156
+ | B::Gt
157
+ | B::GtEq
158
+ | B::Spaceship
159
+ | B::Question
160
+ | B::QuestionAnd
161
+ | B::QuestionPipe => COMPARISON_PREC,
162
+ B::Plus | B::Minus | B::StringConcat => ADDITIVE_PREC,
163
+ B::Multiply | B::Divide | B::Modulo => MULTIPLICATIVE_PREC,
164
+ _ => OTHER_OP_PREC,
165
+ },
166
+ E::UnaryOp {
167
+ op: ast::UnaryOperator::Not,
168
+ ..
169
+ }
170
+ | E::Exists { negated: true, .. } => NOT_PREC,
171
+ E::UnaryOp { .. } => PREFIX_PREC,
172
+ E::IsFalse(_)
173
+ | E::IsNotFalse(_)
174
+ | E::IsTrue(_)
175
+ | E::IsNotTrue(_)
176
+ | E::IsNull(_)
177
+ | E::IsNotNull(_)
178
+ | E::IsUnknown(_)
179
+ | E::IsNotUnknown(_)
180
+ | E::IsDistinctFrom(..)
181
+ | E::IsNotDistinctFrom(..)
182
+ | E::IsNormalized { .. }
183
+ | E::InList { .. }
184
+ | E::InSubquery { .. }
185
+ | E::InUnnest { .. }
186
+ | E::Between { .. }
187
+ | E::Like { .. }
188
+ | E::ILike { .. }
189
+ | E::SimilarTo { .. }
190
+ | E::RLike { .. }
191
+ | E::AnyOp { .. }
192
+ | E::AllOp { .. }
193
+ | E::MemberOf(_) => PREDICATE_PREC,
194
+ E::AtTimeZone { .. } => AT_TIME_ZONE_PREC,
195
+ E::Identifier(_)
196
+ | E::CompoundIdentifier(_)
197
+ | E::CompoundFieldAccess { .. }
198
+ | E::JsonAccess { .. }
199
+ | E::Value(_)
200
+ | E::TypedString { .. }
201
+ | E::Function(_)
202
+ | E::Cast { .. }
203
+ | E::Convert { .. }
204
+ | E::Case { .. }
205
+ | E::Nested(_)
206
+ | E::Tuple(_)
207
+ | E::Array(_)
208
+ | E::Subquery(_)
209
+ | E::Exists { negated: false, .. }
210
+ | E::Interval(_)
211
+ | E::Extract { .. }
212
+ | E::Ceil { .. }
213
+ | E::Floor { .. }
214
+ | E::Position { .. }
215
+ | E::Substring { .. }
216
+ | E::Trim { .. }
217
+ | E::Overlay { .. }
218
+ | E::Collate { .. }
219
+ | E::Struct { .. }
220
+ | E::Map(_)
221
+ | E::Dictionary(_) => ATOM_PREC,
222
+ _ => 0,
133
223
  }
134
224
  }
135
225
 
@@ -236,13 +326,20 @@ fn lower_call(call: &Call) -> Result<ast::Expr> {
236
326
  }))
237
327
  }
238
328
 
329
+ /// Lower an operand, and parenthesize it when its precedence is `<= wrap_at_or_below`.
330
+ fn lower_operand(expr: &Expr, wrap_at_or_below: u8) -> Result<ast::Expr> {
331
+ let lowered = lower_expr(expr)?;
332
+ let wrap = ast_prec(&lowered) <= wrap_at_or_below;
333
+ Ok(maybe_paren(lowered, wrap))
334
+ }
335
+
239
336
  fn lower_binary(left: &Expr, op: BinaryOp, right: &Expr) -> Result<ast::Expr> {
240
337
  let parent = binary_op_prec(op);
241
338
  // Left keeps its operator unwrapped while it binds at least as tight (left-associative); the
242
339
  // right operand is wrapped even at equal precedence, so `a - (b - c)` and `a / (b - c)` keep
243
340
  // their grouping.
244
- let left_ast = maybe_paren(lower_expr(left)?, expr_prec(left) < parent);
245
- let right_ast = maybe_paren(lower_expr(right)?, expr_prec(right) <= parent);
341
+ let left_ast = lower_operand(left, parent - 1)?;
342
+ let right_ast = lower_operand(right, parent)?;
246
343
  Ok(ast::Expr::BinaryOp {
247
344
  left: Box::new(left_ast),
248
345
  op: lower_binary_op(op),
@@ -250,31 +347,20 @@ fn lower_binary(left: &Expr, op: BinaryOp, right: &Expr) -> Result<ast::Expr> {
250
347
  })
251
348
  }
252
349
 
253
- /// Whether an expression's `Display` begins with a `-` token (a negative numeric literal or a
254
- /// nested unary negation). A unary minus placed directly in front of one emits `--`, which
255
- /// DataFusion lexes as a line comment — silently swallowing the rest of the query — so such an
256
- /// operand must be parenthesized.
257
- fn renders_with_leading_minus(expr: &Expr) -> bool {
258
- match expr {
259
- Expr::Literal(Scalar::Int(i)) => *i < 0,
260
- Expr::Literal(Scalar::Float(f)) => f.is_sign_negative(),
261
- Expr::Unary {
262
- op: UnaryOp::Neg, ..
263
- } => true,
264
- _ => false,
265
- }
266
- }
267
-
268
350
  fn lower_unary(op: UnaryOp, operand: &Expr) -> Result<ast::Expr> {
269
351
  let (parent, ast_op) = match op {
270
- UnaryOp::Not => (3, ast::UnaryOperator::Not),
271
- UnaryOp::Neg => (7, ast::UnaryOperator::Minus),
352
+ UnaryOp::Not => (NOT_PREC, ast::UnaryOperator::Not),
353
+ UnaryOp::Neg => (PREFIX_PREC, ast::UnaryOperator::Minus),
272
354
  };
273
- let wrap = expr_prec(operand) < parent
274
- || (matches!(op, UnaryOp::Neg) && renders_with_leading_minus(operand));
355
+ let lowered = lower_expr(operand)?;
356
+ // A unary minus placed directly in front of an operand that renders with a leading `-` (a
357
+ // negative literal, a nested negation, a `raw("-x")`) emits `--`, which DataFusion lexes as a
358
+ // line comment that swallows the rest of the query.
359
+ let wrap = ast_prec(&lowered) < parent
360
+ || (matches!(op, UnaryOp::Neg) && lowered.to_string().starts_with('-'));
275
361
  Ok(ast::Expr::UnaryOp {
276
362
  op: ast_op,
277
- expr: Box::new(maybe_paren(lower_expr(operand)?, wrap)),
363
+ expr: Box::new(maybe_paren(lowered, wrap)),
278
364
  })
279
365
  }
280
366
 
@@ -295,8 +381,10 @@ pub fn lower_expr(expr: &Expr) -> Result<ast::Expr> {
295
381
  Expr::Raw(sql) => parse_raw_expr(sql)?,
296
382
  Expr::Binary { left, op, right } => lower_binary(left, *op, right)?,
297
383
  Expr::Unary { op, expr } => lower_unary(*op, expr)?,
384
+ // The operand of a predicate form is wrapped when it is itself a comparison or looser, so
385
+ // `(a = b) IS NULL` and `(a OR b) IN (…)` keep their grouping in every dialect.
298
386
  Expr::IsNull { expr, negated } => {
299
- let inner = Box::new(lower_expr(expr)?);
387
+ let inner = Box::new(lower_operand(expr, COMPARISON_PREC)?);
300
388
  if *negated {
301
389
  ast::Expr::IsNotNull(inner)
302
390
  } else {
@@ -308,7 +396,7 @@ pub fn lower_expr(expr: &Expr) -> Result<ast::Expr> {
308
396
  list,
309
397
  negated,
310
398
  } => ast::Expr::InList {
311
- expr: Box::new(lower_expr(expr)?),
399
+ expr: Box::new(lower_operand(expr, COMPARISON_PREC)?),
312
400
  list: list.iter().map(lower_expr).collect::<Result<Vec<_>>>()?,
313
401
  negated: *negated,
314
402
  },
@@ -317,7 +405,7 @@ pub fn lower_expr(expr: &Expr) -> Result<ast::Expr> {
317
405
  subquery,
318
406
  negated,
319
407
  } => ast::Expr::InSubquery {
320
- expr: Box::new(lower_expr(expr)?),
408
+ expr: Box::new(lower_operand(expr, COMPARISON_PREC)?),
321
409
  subquery: Box::new(lower_query(subquery)?),
322
410
  negated: *negated,
323
411
  },
@@ -327,10 +415,10 @@ pub fn lower_expr(expr: &Expr) -> Result<ast::Expr> {
327
415
  high,
328
416
  negated,
329
417
  } => ast::Expr::Between {
330
- expr: Box::new(lower_expr(expr)?),
418
+ expr: Box::new(lower_operand(expr, COMPARISON_PREC)?),
331
419
  negated: *negated,
332
- low: Box::new(lower_expr(low)?),
333
- high: Box::new(lower_expr(high)?),
420
+ low: Box::new(lower_operand(low, COMPARISON_PREC)?),
421
+ high: Box::new(lower_operand(high, COMPARISON_PREC)?),
334
422
  },
335
423
  Expr::Case {
336
424
  when_then,
@@ -447,12 +535,17 @@ fn and_combine(filters: &[Expr]) -> Result<Option<ast::Expr>> {
447
535
  let Some(first) = iter.next() else {
448
536
  return Ok(None);
449
537
  };
450
- let mut acc = lower_expr(first)?;
538
+ // A lone filter needs no parentheses. Once filters are AND-combined, each one that binds looser
539
+ // than `AND` is wrapped, or `.filter(a | b).filter(c)` renders as `a OR b AND c`.
540
+ if filters.len() == 1 {
541
+ return Ok(Some(lower_expr(first)?));
542
+ }
543
+ let mut acc = lower_operand(first, AND_PREC - 1)?;
451
544
  for f in iter {
452
545
  acc = ast::Expr::BinaryOp {
453
546
  left: Box::new(acc),
454
547
  op: ast::BinaryOperator::And,
455
- right: Box::new(lower_expr(f)?),
548
+ right: Box::new(lower_operand(f, AND_PREC - 1)?),
456
549
  };
457
550
  }
458
551
  Ok(Some(acc))
@@ -640,7 +733,7 @@ pub fn lower_query(query: &Query) -> Result<ast::Query> {
640
733
  } else {
641
734
  Some(ast::With {
642
735
  with_token: ast::helpers::attached_token::AttachedToken::empty(),
643
- recursive: false,
736
+ recursive: query.recursive,
644
737
  cte_tables: query
645
738
  .ctes
646
739
  .iter()
@@ -541,6 +541,14 @@ impl PyQuery {
541
541
  PyQuery::new(self.inner.clone().with_cte(name, query.inner.clone()))
542
542
  }
543
543
 
544
+ fn with_recursive_cte(&self, name: &str, query: PyRef<'_, PyQuery>) -> PyQuery {
545
+ PyQuery::new(
546
+ self.inner
547
+ .clone()
548
+ .with_recursive_cte(name, query.inner.clone()),
549
+ )
550
+ }
551
+
544
552
  #[pyo3(signature = (*sorts))]
545
553
  fn order_by(&self, sorts: Vec<SortArg>) -> PyQuery {
546
554
  let sorts = sorts.into_iter().map(|s| s.0).collect();
@@ -110,6 +110,7 @@ pub struct Cte {
110
110
  #[derive(Debug, Clone, PartialEq)]
111
111
  pub struct Query {
112
112
  pub ctes: Vec<Cte>,
113
+ pub recursive: bool,
113
114
  pub body: Body,
114
115
  pub order_by: Vec<SortExpr>,
115
116
  pub limit: Option<i64>,
@@ -138,6 +139,7 @@ impl Query {
138
139
  fn from_select(select: Select) -> Self {
139
140
  Query {
140
141
  ctes: vec![],
142
+ recursive: false,
141
143
  body: Body::Select(Box::new(select)),
142
144
  order_by: vec![],
143
145
  limit: None,
@@ -232,6 +234,17 @@ impl Query {
232
234
  self
233
235
  }
234
236
 
237
+ /// Add a recursive CTE; reference it later by name in a `FROM` slot.
238
+ #[must_use]
239
+ pub fn with_recursive_cte(mut self, name: impl Into<String>, query: Query) -> Self {
240
+ self.recursive = true;
241
+ self.ctes.push(Cte {
242
+ name: name.into(),
243
+ query,
244
+ });
245
+ self
246
+ }
247
+
235
248
  /// Combine with another query via a set operation. The new outer query starts with no
236
249
  /// CTEs/ORDER BY/LIMIT (add them afterward to apply to the whole result); each operand keeps
237
250
  /// its own, and any operand carrying them is wrapped in a derived subquery during lowering.
@@ -239,6 +252,7 @@ impl Query {
239
252
  pub fn set_op(self, op: SetOp, all: bool, other: Query) -> Self {
240
253
  Query {
241
254
  ctes: vec![],
255
+ recursive: false,
242
256
  body: Body::SetOp {
243
257
  op,
244
258
  all,
@@ -142,6 +142,26 @@ fn slo_burn_rate_ctes_cross_join() {
142
142
  assert_snapshot!(validate(&q).unwrap(), @"WITH long_window AS (SELECT round(avg(bad), 2) AS burn_rate FROM records WHERE service_name = 'my-service' AND start_timestamp > now() - INTERVAL '1 hour'), short_window AS (SELECT round(avg(bad), 2) AS burn_rate FROM records WHERE service_name = 'my-service' AND start_timestamp > now() - INTERVAL '5 minutes') SELECT long_window.burn_rate AS long_burn, short_window.burn_rate AS short_burn FROM long_window CROSS JOIN short_window");
143
143
  }
144
144
 
145
+ #[test]
146
+ fn recursive_cte() {
147
+ let seed = Query::empty()
148
+ .select(vec![Expr::lit(Scalar::Int(1)).alias("n")])
149
+ .unwrap();
150
+ let step = Query::table("numbers")
151
+ .select(vec![
152
+ Expr::column("n")
153
+ .binary(BinaryOp::Plus, Expr::lit(Scalar::Int(1)))
154
+ .alias("n"),
155
+ ])
156
+ .unwrap()
157
+ .filter(Expr::column("n").binary(BinaryOp::Lt, Expr::lit(Scalar::Int(3))))
158
+ .unwrap();
159
+ let recursive = seed.set_op(SetOp::Union, true, step);
160
+ let q = Query::table("numbers").with_recursive_cte("numbers", recursive);
161
+
162
+ assert_snapshot!(validate(&q).unwrap(), @"WITH RECURSIVE numbers AS (SELECT 1 AS n UNION ALL SELECT n + 1 AS n FROM numbers WHERE n < 3) SELECT * FROM numbers");
163
+ }
164
+
145
165
  #[test]
146
166
  fn union_all_distinct_keys() {
147
167
  let attr = Query::table("metrics")
@@ -329,3 +349,146 @@ fn string_literals_escape_quotes_and_keep_backslashes() {
329
349
  @"SELECT * FROM records WHERE service_name = 'O''Brien' AND path = 'C:\\logs'"
330
350
  );
331
351
  }
352
+
353
+ #[test]
354
+ fn raw_fragments_keep_their_grouping_when_combined() {
355
+ // A `raw(...)` fragment is opaque until parsed, so it used to be treated as an atom and never
356
+ // wrapped: `raw("a = 1 OR b = 2") & c` rendered `a = 1 OR b = 2 AND c`, which means
357
+ // `a = 1 OR (b = 2 AND c)`. Callers had to wrap the text themselves as `({fragment})`, which
358
+ // breaks on a trailing `-- comment` and lets a fragment like `1=1) OR (1=1` close the bracket.
359
+ let or_fragment = || Expr::raw("a = 1 OR b = 2");
360
+ let c = || Expr::column("c");
361
+ let q = Query::table("t")
362
+ .select(vec![
363
+ or_fragment().binary(BinaryOp::And, c()).alias("and_right"),
364
+ c().binary(BinaryOp::And, or_fragment()).alias("and_left"),
365
+ Expr::unary(UnaryOp::Not, or_fragment()).alias("not_or"),
366
+ Expr::lit(Scalar::Bool(true))
367
+ .binary(BinaryOp::Eq, Expr::raw("x = 1"))
368
+ .alias("cmp_in_cmp"),
369
+ Expr::unary(UnaryOp::Neg, Expr::raw("x + 1")).alias("neg_sum"),
370
+ Expr::unary(UnaryOp::Neg, Expr::raw("-x")).alias("neg_neg"),
371
+ Expr::raw("x IS NULL")
372
+ .binary(BinaryOp::Eq, Expr::lit(Scalar::Bool(false)))
373
+ .alias("is_null_in_cmp"),
374
+ ])
375
+ .unwrap();
376
+ assert_snapshot!(
377
+ validate(&q).unwrap(),
378
+ @"SELECT (a = 1 OR b = 2) AND c AS and_right, c AND (a = 1 OR b = 2) AS and_left, NOT (a = 1 OR b = 2) AS not_or, true = (x = 1) AS cmp_in_cmp, -(x + 1) AS neg_sum, -(-x) AS neg_neg, (x IS NULL) = false AS is_null_in_cmp FROM t"
379
+ );
380
+ }
381
+
382
+ #[test]
383
+ fn raw_fragments_that_bind_tightly_stay_unwrapped() {
384
+ // Output compatibility: a fragment that already binds tighter than its parent renders exactly
385
+ // as before, so existing callers see no change in the common shapes.
386
+ let q = Query::table("t")
387
+ .filter(Expr::raw("attributes ->> 'k'").binary(BinaryOp::Eq, lit_str("v")))
388
+ .unwrap()
389
+ .filter(Expr::raw("x IS NULL"))
390
+ .unwrap()
391
+ .filter(Expr::raw("(a OR b)"))
392
+ .unwrap()
393
+ .filter(Expr::column("ts").binary(BinaryOp::Gt, Expr::raw("now() - INTERVAL '1 hour'")))
394
+ .unwrap()
395
+ .select(vec![
396
+ Expr::raw("attributes ->> 'n'")
397
+ .binary(BinaryOp::Plus, Expr::lit(Scalar::Int(1)))
398
+ .alias("n"),
399
+ ])
400
+ .unwrap();
401
+ assert_snapshot!(
402
+ validate(&q).unwrap(),
403
+ @"SELECT (attributes ->> 'n') + 1 AS n FROM t WHERE attributes ->> 'k' = 'v' AND x IS NULL AND (a OR b) AND ts > now() - INTERVAL '1 hour'"
404
+ );
405
+ }
406
+
407
+ #[test]
408
+ fn raw_fragment_with_trailing_line_comment_combines_safely() {
409
+ // The fragment is parsed on its own, so a trailing `-- comment` ends at the end of the
410
+ // fragment and is dropped. It can no longer swallow a bracket or operator that follows it.
411
+ let q = Query::table("t")
412
+ .filter(Expr::column("service_name").binary(BinaryOp::Eq, lit_str("svc")))
413
+ .unwrap()
414
+ .filter(Expr::raw("a = 1 OR b = 2 -- either marker"))
415
+ .unwrap()
416
+ .select(vec![count_star().alias("n")])
417
+ .unwrap();
418
+ assert_snapshot!(
419
+ validate(&q).unwrap(),
420
+ @"SELECT count(*) AS n FROM t WHERE service_name = 'svc' AND (a = 1 OR b = 2)"
421
+ );
422
+ }
423
+
424
+ #[test]
425
+ fn chained_filters_keep_disjunctions_grouped() {
426
+ // `filter()` calls are AND-combined. A disjunction passed to one of them used to render bare:
427
+ // `.filter(a | b).filter(c)` became `a OR b AND c`.
428
+ let a_or_b = || eq("a", "1").binary(BinaryOp::Or, eq("b", "1"));
429
+ let q = Query::table("t")
430
+ .filter(a_or_b())
431
+ .unwrap()
432
+ .filter(eq("c", "1"))
433
+ .unwrap()
434
+ .filter(a_or_b())
435
+ .unwrap()
436
+ .select(vec![Expr::column("x")])
437
+ .unwrap();
438
+ assert_snapshot!(
439
+ validate(&q).unwrap(),
440
+ @"SELECT x FROM t WHERE (a = '1' OR b = '1') AND c = '1' AND (a = '1' OR b = '1')"
441
+ );
442
+
443
+ // A single filter is the whole `WHERE` clause and needs no brackets.
444
+ let single = Query::table("t")
445
+ .filter(a_or_b())
446
+ .unwrap()
447
+ .select(vec![Expr::column("x")])
448
+ .unwrap();
449
+ assert_snapshot!(
450
+ validate(&single).unwrap(),
451
+ @"SELECT x FROM t WHERE a = '1' OR b = '1'"
452
+ );
453
+ }
454
+
455
+ #[test]
456
+ fn predicate_operands_keep_their_grouping() {
457
+ // `IS NULL`, `IN` and `BETWEEN` bind tighter than `AND` / `OR` and, in some dialects, looser
458
+ // than comparisons, so a boolean operand must be wrapped.
459
+ let a_or_b = || Expr::column("a").binary(BinaryOp::Or, Expr::column("b"));
460
+ let q = Query::table("t")
461
+ .select(vec![
462
+ a_or_b().is_null(false).alias("n"),
463
+ Expr::raw("x = 1")
464
+ .in_list(vec![Expr::lit(Scalar::Bool(true))], false)
465
+ .alias("i"),
466
+ Expr::column("x")
467
+ .between(
468
+ Expr::raw("lo OR hi"),
469
+ Expr::column("y").binary(BinaryOp::Plus, Expr::lit(Scalar::Int(1))),
470
+ false,
471
+ )
472
+ .alias("b"),
473
+ Expr::raw("attributes ->> 'k'")
474
+ .is_null(true)
475
+ .alias("unchanged"),
476
+ ])
477
+ .unwrap();
478
+ assert_snapshot!(
479
+ validate(&q).unwrap(),
480
+ @"SELECT (a OR b) IS NULL AS n, (x = 1) IN (true) AS i, x BETWEEN (lo OR hi) AND y + 1 AS b, attributes ->> 'k' IS NOT NULL AS unchanged FROM t"
481
+ );
482
+ }
483
+
484
+ #[test]
485
+ fn raw_fragment_cannot_close_a_bracket_it_did_not_open() {
486
+ // Without text wrapping there is no caller bracket to close, so an unbalanced fragment is a
487
+ // parse error instead of an expression that escapes its scope.
488
+ let q = Query::table("t")
489
+ .filter(eq("service_name", "svc"))
490
+ .unwrap()
491
+ .filter(Expr::raw("1=1) OR (1=1"))
492
+ .unwrap();
493
+ assert!(matches!(to_sql(&q), Err(BuildError::UnparsableSql(_))));
494
+ }
@@ -5,3 +5,4 @@
5
5
  # It is recommended to check this file in to source control so that
6
6
  # everyone who runs the test benefits from these saved cases.
7
7
  cc 7917b1826878898c3c20c818b8243678c7bedb47511b9b8d9f829083e4248df2 # shrinks to expr = Binary { left: Literal(Int(0)), op: Plus, right: Binary { left: Literal(Int(0)), op: Plus, right: Unary { op: Neg, expr: Literal(Int(-1)) } } }
8
+ cc 537d1664bbc206dafb87318db35f12f0d466a9c8ffdc6bcb99cdcdf082a8a489 # shrinks to (native, mixed) = (Binary { left: Binary { left: Binary { left: Literal(Bool(false)), op: Eq, right: Literal(Bool(false)) }, op: Or, right: Literal(Bool(false)) }, op: And, right: Binary { left: Literal(Bool(false)), op: And, right: Literal(Bool(false)) } }, Binary { left: Raw("false = false OR false"), op: And, right: Binary { left: Literal(Bool(false)), op: And, right: Literal(Bool(false)) } })
@@ -176,6 +176,56 @@ fn known_nasty_scalars_round_trip() {
176
176
  }
177
177
  }
178
178
 
179
+ #[test]
180
+ fn recursive_cte_executes_in_datafusion() {
181
+ let seed = Query::empty()
182
+ .select(vec![Expr::lit(Scalar::Int(1)).alias("n")])
183
+ .expect("select seed");
184
+ let step = Query::table("numbers")
185
+ .select(vec![
186
+ Expr::column("n")
187
+ .binary(BinaryOp::Plus, Expr::lit(Scalar::Int(1)))
188
+ .alias("n"),
189
+ ])
190
+ .expect("select recursive step")
191
+ .filter(Expr::column("n").binary(BinaryOp::Lt, Expr::lit(Scalar::Int(3))))
192
+ .expect("filter recursive step");
193
+ let query = Query::table("numbers").with_recursive_cte(
194
+ "numbers",
195
+ seed.set_op(datafusion_query_builder::query::SetOp::Union, true, step),
196
+ );
197
+ let sql = to_sql(&query).expect("render recursive query");
198
+
199
+ let values = runtime().block_on(async move {
200
+ let ctx = SessionContext::new();
201
+ let batches = ctx
202
+ .sql(&sql)
203
+ .await
204
+ .expect("plan recursive query")
205
+ .collect()
206
+ .await
207
+ .expect("execute recursive query");
208
+ batches
209
+ .iter()
210
+ .flat_map(|batch| {
211
+ (0..batch.num_rows()).map(|row| {
212
+ ScalarValue::try_from_array(batch.column(0), row)
213
+ .expect("extract recursive value")
214
+ })
215
+ })
216
+ .collect::<Vec<_>>()
217
+ });
218
+
219
+ assert_eq!(
220
+ values,
221
+ vec![
222
+ ScalarValue::Int64(Some(1)),
223
+ ScalarValue::Int64(Some(2)),
224
+ ScalarValue::Int64(Some(3)),
225
+ ]
226
+ );
227
+ }
228
+
179
229
  // ---- Arithmetic precedence, checked by execution -----------------------------------------------
180
230
 
181
231
  fn arb_arith_op() -> impl Strategy<Value = BinaryOp> {
@@ -309,3 +359,120 @@ proptest! {
309
359
  prop_assert_eq!(build(), build());
310
360
  }
311
361
  }
362
+
363
+ // ---- Raw-fragment grouping, checked by execution -----------------------------------------------
364
+
365
+ /// Render one expression on its own, as the text a caller would hand to `raw(...)`.
366
+ fn render_expr(expr: &Expr) -> String {
367
+ let query = Query::empty()
368
+ .select(vec![expr.clone()])
369
+ .expect("select on empty query");
370
+ let sql = to_sql(&query).expect("boolean trees always render");
371
+ sql.strip_prefix("SELECT ")
372
+ .expect("a bare projection renders as `SELECT <expr>`")
373
+ .to_string()
374
+ }
375
+
376
+ fn arb_bool_op() -> impl Strategy<Value = BinaryOp> {
377
+ prop_oneof![
378
+ Just(BinaryOp::And),
379
+ Just(BinaryOp::Or),
380
+ Just(BinaryOp::Eq),
381
+ Just(BinaryOp::NotEq),
382
+ ]
383
+ }
384
+
385
+ /// A boolean tree paired with an equivalent tree in which random subtrees were replaced by
386
+ /// `raw(<their rendered SQL>)`. The first is evaluated in Rust; the second is executed.
387
+ fn arb_bool_with_raw() -> impl Strategy<Value = (Expr, Expr)> {
388
+ let leaf = any::<bool>().prop_map(|b| {
389
+ let e = Expr::lit(Scalar::Bool(b));
390
+ (e.clone(), e)
391
+ });
392
+ leaf.prop_recursive(5, 40, 2, |inner| {
393
+ let node = prop_oneof![
394
+ 3 => (inner.clone(), arb_bool_op(), inner.clone())
395
+ .prop_map(|((ln, lm), op, (rn, rm))| (ln.binary(op, rn), lm.binary(op, rm))),
396
+ 1 => inner.prop_map(|(n, m)| (Expr::unary(UnaryOp::Not, n), Expr::unary(UnaryOp::Not, m))),
397
+ ];
398
+ (node, any::<bool>()).prop_map(|((native, mixed), as_raw)| {
399
+ if as_raw {
400
+ let raw = Expr::raw(render_expr(&mixed));
401
+ (native, raw)
402
+ } else {
403
+ (native, mixed)
404
+ }
405
+ })
406
+ })
407
+ }
408
+
409
+ fn eval_bool(expr: &Expr) -> bool {
410
+ match expr {
411
+ Expr::Literal(Scalar::Bool(b)) => *b,
412
+ Expr::Unary {
413
+ op: UnaryOp::Not,
414
+ expr,
415
+ } => !eval_bool(expr),
416
+ Expr::Binary { left, op, right } => {
417
+ let (l, r) = (eval_bool(left), eval_bool(right));
418
+ match op {
419
+ BinaryOp::And => l && r,
420
+ BinaryOp::Or => l || r,
421
+ BinaryOp::Eq => l == r,
422
+ BinaryOp::NotEq => l != r,
423
+ _ => unreachable!("arb_bool_op only emits AND, OR, =, <>"),
424
+ }
425
+ }
426
+ _ => unreachable!("the native tree holds only bool leaves, NOT, and arb_bool_op"),
427
+ }
428
+ }
429
+
430
+ /// Execute `SELECT 1 WHERE <f1> AND <f2> …` built from chained `filter()` calls, and report whether
431
+ /// the row survived.
432
+ fn row_survives(filters: Vec<Expr>) -> Result<bool, String> {
433
+ let mut query = Query::empty()
434
+ .select(vec![Expr::lit(Scalar::Int(1))])
435
+ .expect("select on empty query");
436
+ for f in filters {
437
+ query = query.filter(f).expect("filter on a select");
438
+ }
439
+ let sql = to_sql(&query).map_err(|e| format!("render: {e}"))?;
440
+ runtime().block_on(async move {
441
+ let ctx = SessionContext::new();
442
+ let frame = ctx
443
+ .sql(&sql)
444
+ .await
445
+ .map_err(|e| format!("plan: {e}\n sql: {sql}"))?;
446
+ let batches = frame
447
+ .collect()
448
+ .await
449
+ .map_err(|e| format!("exec: {e}\n sql: {sql}"))?;
450
+ Ok(batches.iter().map(|b| b.num_rows()).sum::<usize>() == 1)
451
+ })
452
+ }
453
+
454
+ proptest! {
455
+ #![proptest_config(ProptestConfig { cases: 96, ..ProptestConfig::default() })]
456
+
457
+ /// Replacing any subtree with `raw(<its SQL>)` must not change the computed value. This is the
458
+ /// guarantee a caller relies on when it passes a user-written predicate through `raw(...)`
459
+ /// without wrapping it in brackets itself.
460
+ #[test]
461
+ fn raw_subtrees_evaluate_like_the_tree_they_replace((native, mixed) in arb_bool_with_raw()) {
462
+ let want = eval_bool(&native);
463
+ let actual = eval_one(mixed).map_err(TestCaseError::fail)?;
464
+ prop_assert_eq!(actual, ScalarValue::Boolean(Some(want)));
465
+ }
466
+
467
+ /// Chained `filter()` calls mean the conjunction of every predicate, whatever each one's own
468
+ /// top-level operator is.
469
+ #[test]
470
+ fn chained_filters_mean_their_conjunction(
471
+ trees in proptest::collection::vec(arb_bool_with_raw(), 1..4),
472
+ ) {
473
+ let want = trees.iter().all(|(native, _)| eval_bool(native));
474
+ let mixed = trees.into_iter().map(|(_, mixed)| mixed).collect();
475
+ let got = row_survives(mixed).map_err(TestCaseError::fail)?;
476
+ prop_assert_eq!(got, want);
477
+ }
478
+ }
@@ -9,7 +9,9 @@ from datafusion_query_builder import (
9
9
  and_,
10
10
  col,
11
11
  f,
12
+ lit,
12
13
  param,
14
+ query,
13
15
  raw,
14
16
  table,
15
17
  when,
@@ -112,6 +114,18 @@ def test_cte_cross_join():
112
114
  assert 'CROSS JOIN short_window' in sql
113
115
 
114
116
 
117
+ def test_recursive_cte():
118
+ recursive = query().select(lit(1).alias('n')).union_all(
119
+ table('numbers').select((col('n') + 1).alias('n')).filter(col('n') < 3)
120
+ )
121
+ q = table('numbers').with_recursive_cte('numbers', recursive)
122
+
123
+ assert q.validate() == (
124
+ 'WITH RECURSIVE numbers AS (SELECT 1 AS n UNION ALL '
125
+ 'SELECT n + 1 AS n FROM numbers WHERE n < 3) SELECT * FROM numbers'
126
+ )
127
+
128
+
115
129
  def test_window_function():
116
130
  q = table('t').select(
117
131
  f.sum(col('v')).over(partition_by=[col('svc')], order_by=[col('t').asc()]).alias('running'),
@@ -162,6 +176,22 @@ def test_raw_json_operator_no_longer_silently_truncated():
162
176
  assert q.validate() == "SELECT attributes ? 'gen_ai.input.messages' AS x FROM records"
163
177
 
164
178
 
179
+ def test_raw_predicate_needs_no_hand_written_brackets():
180
+ # A caller used to wrap a user-written predicate as `raw(f'({predicate})')`, because an `OR`
181
+ # inside `raw()` lost its grouping once AND-combined. That text wrapping breaks on a trailing
182
+ # `-- comment` and lets `1=1) OR (1=1` close the bracket. The builder now groups the fragment.
183
+ q = (
184
+ table('records')
185
+ .filter(col('service_name') == 'svc')
186
+ .filter(raw('a = 1 OR b = 2 -- either marker'))
187
+ .select(f.count().filter(raw('c = 1 OR d = 2') & (col('ts') > 0)).alias('bad'))
188
+ )
189
+ assert q.validate() == (
190
+ 'SELECT count(*) FILTER (WHERE (c = 1 OR d = 2) AND ts > 0) AS bad '
191
+ "FROM records WHERE service_name = 'svc' AND (a = 1 OR b = 2)"
192
+ )
193
+
194
+
165
195
  def test_raw_fragment_with_trailing_tokens_raises():
166
196
  # A fragment that parses a leading expression but leaves tokens behind is now a loud error
167
197
  # rather than a silent truncation to the prefix.