sqlspec 0.22.0__py3-none-any.whl → 0.24.0__py3-none-any.whl

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.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

@@ -14,7 +14,6 @@ from sqlspec.exceptions import SQLBuilderError
14
14
  from sqlspec.utils.type_guards import has_query_builder_parameters
15
15
 
16
16
  if TYPE_CHECKING:
17
- from sqlspec.builder._column import ColumnExpression
18
17
  from sqlspec.core.statement import SQL
19
18
  from sqlspec.protocols import SQLBuilderProtocol
20
19
 
@@ -36,74 +35,133 @@ class JoinClauseMixin:
36
35
  on: Optional[Union[str, exp.Expression, "SQL"]] = None,
37
36
  alias: Optional[str] = None,
38
37
  join_type: str = "INNER",
38
+ lateral: bool = False,
39
39
  ) -> Self:
40
40
  builder = cast("SQLBuilderProtocol", self)
41
+ self._validate_join_context(builder)
42
+
43
+ # Handle Join expressions directly (from JoinBuilder.on() calls)
44
+ if isinstance(table, exp.Join):
45
+ if builder._expression is not None and isinstance(builder._expression, exp.Select):
46
+ builder._expression = builder._expression.join(table, copy=False)
47
+ return cast("Self", builder)
48
+
49
+ table_expr = self._parse_table_expression(table, alias, builder)
50
+ on_expr = self._parse_on_condition(on, builder)
51
+ join_expr = self._create_join_expression(table_expr, on_expr, join_type)
52
+
53
+ if lateral:
54
+ self._apply_lateral_modifier(join_expr)
55
+
56
+ if builder._expression is not None and isinstance(builder._expression, exp.Select):
57
+ builder._expression = builder._expression.join(join_expr, copy=False)
58
+ return cast("Self", builder)
59
+
60
+ def _validate_join_context(self, builder: "SQLBuilderProtocol") -> None:
61
+ """Validate that the join can be applied to the current expression."""
41
62
  if builder._expression is None:
42
63
  builder._expression = exp.Select()
43
64
  if not isinstance(builder._expression, exp.Select):
44
65
  msg = "JOIN clause is only supported for SELECT statements."
45
66
  raise SQLBuilderError(msg)
46
- table_expr: exp.Expression
67
+
68
+ def _parse_table_expression(
69
+ self, table: Union[str, exp.Expression, Any], alias: Optional[str], builder: "SQLBuilderProtocol"
70
+ ) -> exp.Expression:
71
+ """Parse table parameter into a SQLGlot expression."""
47
72
  if isinstance(table, str):
48
- table_expr = parse_table_expression(table, alias)
49
- elif has_query_builder_parameters(table):
50
- if hasattr(table, "_expression") and getattr(table, "_expression", None) is not None:
51
- table_expr_value = getattr(table, "_expression", None)
52
- if table_expr_value is not None:
53
- subquery_exp = exp.paren(table_expr_value)
54
- else:
55
- subquery_exp = exp.paren(exp.Anonymous(this=""))
56
- table_expr = exp.alias_(subquery_exp, alias) if alias else subquery_exp
73
+ return parse_table_expression(table, alias)
74
+ if has_query_builder_parameters(table):
75
+ return self._handle_query_builder_table(table, alias, builder)
76
+ if isinstance(table, exp.Expression):
77
+ return table
78
+ return cast("exp.Expression", table)
79
+
80
+ def _handle_query_builder_table(
81
+ self, table: Any, alias: Optional[str], builder: "SQLBuilderProtocol"
82
+ ) -> exp.Expression:
83
+ """Handle table parameters that are query builders."""
84
+ if hasattr(table, "_expression") and getattr(table, "_expression", None) is not None:
85
+ table_expr_value = getattr(table, "_expression", None)
86
+ if table_expr_value is not None:
87
+ subquery_exp = exp.paren(table_expr_value)
57
88
  else:
58
- subquery = table.build()
59
- sql_str = subquery.sql if hasattr(subquery, "sql") and not callable(subquery.sql) else str(subquery)
60
- subquery_exp = exp.paren(exp.maybe_parse(sql_str, dialect=getattr(builder, "dialect", None)))
61
- table_expr = exp.alias_(subquery_exp, alias) if alias else subquery_exp
62
- else:
63
- table_expr = table
64
- on_expr: Optional[exp.Expression] = None
65
- if on is not None:
66
- if isinstance(on, str):
67
- on_expr = exp.condition(on)
68
- elif hasattr(on, "expression") and hasattr(on, "sql"):
69
- # Handle SQL objects (from sql.raw with parameters)
70
- expression = getattr(on, "expression", None)
71
- if expression is not None and isinstance(expression, exp.Expression):
72
- # Merge parameters from SQL object into builder
73
- if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
74
- sql_parameters = getattr(on, "parameters", {})
75
- for param_name, param_value in sql_parameters.items():
76
- builder.add_parameter(param_value, name=param_name)
77
- on_expr = expression
78
- else:
79
- # If expression is None, fall back to parsing the raw SQL
80
- sql_text = getattr(on, "sql", "")
81
- # Merge parameters even when parsing raw SQL
82
- if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
83
- sql_parameters = getattr(on, "parameters", {})
84
- for param_name, param_value in sql_parameters.items():
85
- builder.add_parameter(param_value, name=param_name)
86
- on_expr = exp.maybe_parse(sql_text) or exp.condition(str(sql_text))
87
- # For other types (should be exp.Expression)
88
- elif isinstance(on, exp.Expression):
89
- on_expr = on
90
- else:
91
- # Last resort - convert to string and parse
92
- on_expr = exp.condition(str(on))
89
+ subquery_exp = exp.paren(exp.Anonymous(this=""))
90
+ return exp.alias_(subquery_exp, alias) if alias else subquery_exp
91
+ subquery = table.build()
92
+ sql_str = subquery.sql if hasattr(subquery, "sql") and not callable(subquery.sql) else str(subquery)
93
+ subquery_exp = exp.paren(exp.maybe_parse(sql_str, dialect=getattr(builder, "dialect", None)))
94
+ return exp.alias_(subquery_exp, alias) if alias else subquery_exp
95
+
96
+ def _parse_on_condition(
97
+ self, on: Optional[Union[str, exp.Expression, "SQL"]], builder: "SQLBuilderProtocol"
98
+ ) -> Optional[exp.Expression]:
99
+ """Parse ON condition into a SQLGlot expression."""
100
+ if on is None:
101
+ return None
102
+
103
+ if isinstance(on, str):
104
+ return exp.condition(on)
105
+ if hasattr(on, "expression") and hasattr(on, "sql"):
106
+ return self._handle_sql_object_condition(on, builder)
107
+ if isinstance(on, exp.Expression):
108
+ return on
109
+ # Last resort - convert to string and parse
110
+ return exp.condition(str(on))
111
+
112
+ def _handle_sql_object_condition(self, on: Any, builder: "SQLBuilderProtocol") -> exp.Expression:
113
+ """Handle SQL object conditions with parameter binding."""
114
+ expression = getattr(on, "expression", None)
115
+ if expression is not None and isinstance(expression, exp.Expression):
116
+ # Merge parameters from SQL object into builder
117
+ if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
118
+ sql_parameters = getattr(on, "parameters", {})
119
+ for param_name, param_value in sql_parameters.items():
120
+ builder.add_parameter(param_value, name=param_name)
121
+ return cast("exp.Expression", expression)
122
+ # If expression is None, fall back to parsing the raw SQL
123
+ sql_text = getattr(on, "sql", "")
124
+ # Merge parameters even when parsing raw SQL
125
+ if hasattr(on, "parameters") and hasattr(builder, "add_parameter"):
126
+ sql_parameters = getattr(on, "parameters", {})
127
+ for param_name, param_value in sql_parameters.items():
128
+ builder.add_parameter(param_value, name=param_name)
129
+ parsed_expr = exp.maybe_parse(sql_text)
130
+ return parsed_expr if parsed_expr is not None else exp.condition(str(sql_text))
131
+
132
+ def _create_join_expression(
133
+ self, table_expr: exp.Expression, on_expr: Optional[exp.Expression], join_type: str
134
+ ) -> exp.Join:
135
+ """Create the appropriate JOIN expression based on join type."""
93
136
  join_type_upper = join_type.upper()
94
137
  if join_type_upper == "INNER":
95
- join_expr = exp.Join(this=table_expr, on=on_expr)
96
- elif join_type_upper == "LEFT":
97
- join_expr = exp.Join(this=table_expr, on=on_expr, side="LEFT")
98
- elif join_type_upper == "RIGHT":
99
- join_expr = exp.Join(this=table_expr, on=on_expr, side="RIGHT")
100
- elif join_type_upper == "FULL":
101
- join_expr = exp.Join(this=table_expr, on=on_expr, side="FULL", kind="OUTER")
138
+ return exp.Join(this=table_expr, on=on_expr)
139
+ if join_type_upper == "LEFT":
140
+ return exp.Join(this=table_expr, on=on_expr, side="LEFT")
141
+ if join_type_upper == "RIGHT":
142
+ return exp.Join(this=table_expr, on=on_expr, side="RIGHT")
143
+ if join_type_upper == "FULL":
144
+ return exp.Join(this=table_expr, on=on_expr, side="FULL", kind="OUTER")
145
+ if join_type_upper == "CROSS":
146
+ return exp.Join(this=table_expr, kind="CROSS")
147
+ msg = f"Unsupported join type: {join_type}"
148
+ raise SQLBuilderError(msg)
149
+
150
+ def _apply_lateral_modifier(self, join_expr: exp.Join) -> None:
151
+ """Apply LATERAL modifier to the join expression."""
152
+ current_kind = join_expr.args.get("kind")
153
+ current_side = join_expr.args.get("side")
154
+
155
+ if current_kind == "CROSS":
156
+ join_expr.set("kind", "CROSS LATERAL")
157
+ elif current_kind == "OUTER" and current_side == "FULL":
158
+ join_expr.set("side", "FULL") # Keep side
159
+ join_expr.set("kind", "OUTER LATERAL")
160
+ elif current_side:
161
+ join_expr.set("kind", f"{current_side} LATERAL")
162
+ join_expr.set("side", None) # Clear side to avoid duplication
102
163
  else:
103
- msg = f"Unsupported join type: {join_type}"
104
- raise SQLBuilderError(msg)
105
- builder._expression = builder._expression.join(join_expr, copy=False)
106
- return cast("Self", builder)
164
+ join_expr.set("kind", "LATERAL")
107
165
 
108
166
  def inner_join(
109
167
  self, table: Union[str, exp.Expression, Any], on: Union[str, exp.Expression, "SQL"], alias: Optional[str] = None
@@ -154,6 +212,63 @@ class JoinClauseMixin:
154
212
  builder._expression = builder._expression.join(join_expr, copy=False)
155
213
  return cast("Self", builder)
156
214
 
215
+ def lateral_join(
216
+ self,
217
+ table: Union[str, exp.Expression, Any],
218
+ on: Optional[Union[str, exp.Expression, "SQL"]] = None,
219
+ alias: Optional[str] = None,
220
+ ) -> Self:
221
+ """Create a LATERAL JOIN.
222
+
223
+ Args:
224
+ table: Table, subquery, or table function to join
225
+ on: Optional join condition (for LATERAL JOINs with ON clause)
226
+ alias: Optional alias for the joined table/subquery
227
+
228
+ Returns:
229
+ Self for method chaining
230
+
231
+ Example:
232
+ ```python
233
+ query = (
234
+ sql.select("u.name", "arr.value")
235
+ .from_("users u")
236
+ .lateral_join("UNNEST(u.tags)", alias="arr")
237
+ )
238
+ ```
239
+ """
240
+ return self.join(table, on=on, alias=alias, join_type="INNER", lateral=True)
241
+
242
+ def left_lateral_join(
243
+ self,
244
+ table: Union[str, exp.Expression, Any],
245
+ on: Optional[Union[str, exp.Expression, "SQL"]] = None,
246
+ alias: Optional[str] = None,
247
+ ) -> Self:
248
+ """Create a LEFT LATERAL JOIN.
249
+
250
+ Args:
251
+ table: Table, subquery, or table function to join
252
+ on: Optional join condition
253
+ alias: Optional alias for the joined table/subquery
254
+
255
+ Returns:
256
+ Self for method chaining
257
+ """
258
+ return self.join(table, on=on, alias=alias, join_type="LEFT", lateral=True)
259
+
260
+ def cross_lateral_join(self, table: Union[str, exp.Expression, Any], alias: Optional[str] = None) -> Self:
261
+ """Create a CROSS LATERAL JOIN (no ON condition).
262
+
263
+ Args:
264
+ table: Table, subquery, or table function to join
265
+ alias: Optional alias for the joined table/subquery
266
+
267
+ Returns:
268
+ Self for method chaining
269
+ """
270
+ return self.join(table, on=None, alias=alias, join_type="CROSS", lateral=True)
271
+
157
272
 
158
273
  @trait
159
274
  class JoinBuilder:
@@ -181,32 +296,19 @@ class JoinBuilder:
181
296
  ```
182
297
  """
183
298
 
184
- def __init__(self, join_type: str) -> None:
299
+ def __init__(self, join_type: str, lateral: bool = False) -> None:
185
300
  """Initialize the join builder.
186
301
 
187
302
  Args:
188
- join_type: Type of join (inner, left, right, full, cross)
303
+ join_type: Type of join (inner, left, right, full, cross, lateral)
304
+ lateral: Whether this is a LATERAL join
189
305
  """
190
306
  self._join_type = join_type.upper()
307
+ self._lateral = lateral
191
308
  self._table: Optional[Union[str, exp.Expression]] = None
192
309
  self._condition: Optional[exp.Expression] = None
193
310
  self._alias: Optional[str] = None
194
311
 
195
- def __eq__(self, other: object) -> "ColumnExpression": # type: ignore[override]
196
- """Equal to (==) - not typically used but needed for type consistency."""
197
- from sqlspec.builder._column import ColumnExpression
198
-
199
- # JoinBuilder doesn't have a direct expression, so this is a placeholder
200
- # In practice, this shouldn't be called as joins are used differently
201
- placeholder_expr = exp.Literal.string(f"join_{self._join_type.lower()}")
202
- if other is None:
203
- return ColumnExpression(exp.Is(this=placeholder_expr, expression=exp.Null()))
204
- return ColumnExpression(exp.EQ(this=placeholder_expr, expression=exp.convert(other)))
205
-
206
- def __hash__(self) -> int:
207
- """Make JoinBuilder hashable."""
208
- return hash(id(self))
209
-
210
312
  def __call__(self, table: Union[str, exp.Expression], alias: Optional[str] = None) -> Self:
211
313
  """Set the table to join.
212
314
 
@@ -254,15 +356,33 @@ class JoinBuilder:
254
356
  table_expr = exp.alias_(table_expr, self._alias)
255
357
 
256
358
  # Create the appropriate join type using same pattern as existing JoinClauseMixin
257
- if self._join_type == "INNER JOIN":
258
- return exp.Join(this=table_expr, on=condition_expr)
259
- if self._join_type == "LEFT JOIN":
260
- return exp.Join(this=table_expr, on=condition_expr, side="LEFT")
261
- if self._join_type == "RIGHT JOIN":
262
- return exp.Join(this=table_expr, on=condition_expr, side="RIGHT")
263
- if self._join_type == "FULL JOIN":
264
- return exp.Join(this=table_expr, on=condition_expr, side="FULL", kind="OUTER")
265
- if self._join_type == "CROSS JOIN":
359
+ if self._join_type in {"INNER JOIN", "INNER", "LATERAL JOIN"}:
360
+ join_expr = exp.Join(this=table_expr, on=condition_expr)
361
+ elif self._join_type in {"LEFT JOIN", "LEFT"}:
362
+ join_expr = exp.Join(this=table_expr, on=condition_expr, side="LEFT")
363
+ elif self._join_type in {"RIGHT JOIN", "RIGHT"}:
364
+ join_expr = exp.Join(this=table_expr, on=condition_expr, side="RIGHT")
365
+ elif self._join_type in {"FULL JOIN", "FULL"}:
366
+ join_expr = exp.Join(this=table_expr, on=condition_expr, side="FULL", kind="OUTER")
367
+ elif self._join_type in {"CROSS JOIN", "CROSS"}:
266
368
  # CROSS JOIN doesn't use ON condition
267
- return exp.Join(this=table_expr, kind="CROSS")
268
- return exp.Join(this=table_expr, on=condition_expr)
369
+ join_expr = exp.Join(this=table_expr, kind="CROSS")
370
+ else:
371
+ join_expr = exp.Join(this=table_expr, on=condition_expr)
372
+
373
+ if self._lateral or self._join_type == "LATERAL JOIN":
374
+ current_kind = join_expr.args.get("kind")
375
+ current_side = join_expr.args.get("side")
376
+
377
+ if current_kind == "CROSS":
378
+ join_expr.set("kind", "CROSS LATERAL")
379
+ elif current_kind == "OUTER" and current_side == "FULL":
380
+ join_expr.set("side", "FULL") # Keep side
381
+ join_expr.set("kind", "OUTER LATERAL")
382
+ elif current_side:
383
+ join_expr.set("kind", f"{current_side} LATERAL")
384
+ join_expr.set("side", None) # Clear side to avoid duplication
385
+ else:
386
+ join_expr.set("kind", "LATERAL")
387
+
388
+ return join_expr