sqlspec 0.11.0__py3-none-any.whl → 0.12.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.

Files changed (155) hide show
  1. sqlspec/__init__.py +16 -3
  2. sqlspec/_serialization.py +3 -10
  3. sqlspec/_sql.py +1147 -0
  4. sqlspec/_typing.py +343 -41
  5. sqlspec/adapters/adbc/__init__.py +2 -6
  6. sqlspec/adapters/adbc/config.py +474 -149
  7. sqlspec/adapters/adbc/driver.py +330 -644
  8. sqlspec/adapters/aiosqlite/__init__.py +2 -6
  9. sqlspec/adapters/aiosqlite/config.py +143 -57
  10. sqlspec/adapters/aiosqlite/driver.py +269 -462
  11. sqlspec/adapters/asyncmy/__init__.py +3 -8
  12. sqlspec/adapters/asyncmy/config.py +247 -202
  13. sqlspec/adapters/asyncmy/driver.py +217 -451
  14. sqlspec/adapters/asyncpg/__init__.py +4 -7
  15. sqlspec/adapters/asyncpg/config.py +329 -176
  16. sqlspec/adapters/asyncpg/driver.py +418 -498
  17. sqlspec/adapters/bigquery/__init__.py +2 -2
  18. sqlspec/adapters/bigquery/config.py +407 -0
  19. sqlspec/adapters/bigquery/driver.py +592 -634
  20. sqlspec/adapters/duckdb/__init__.py +4 -1
  21. sqlspec/adapters/duckdb/config.py +432 -321
  22. sqlspec/adapters/duckdb/driver.py +393 -436
  23. sqlspec/adapters/oracledb/__init__.py +3 -8
  24. sqlspec/adapters/oracledb/config.py +625 -0
  25. sqlspec/adapters/oracledb/driver.py +549 -942
  26. sqlspec/adapters/psqlpy/__init__.py +4 -7
  27. sqlspec/adapters/psqlpy/config.py +372 -203
  28. sqlspec/adapters/psqlpy/driver.py +197 -550
  29. sqlspec/adapters/psycopg/__init__.py +3 -8
  30. sqlspec/adapters/psycopg/config.py +741 -0
  31. sqlspec/adapters/psycopg/driver.py +732 -733
  32. sqlspec/adapters/sqlite/__init__.py +2 -6
  33. sqlspec/adapters/sqlite/config.py +146 -81
  34. sqlspec/adapters/sqlite/driver.py +243 -426
  35. sqlspec/base.py +220 -825
  36. sqlspec/config.py +354 -0
  37. sqlspec/driver/__init__.py +22 -0
  38. sqlspec/driver/_async.py +252 -0
  39. sqlspec/driver/_common.py +338 -0
  40. sqlspec/driver/_sync.py +261 -0
  41. sqlspec/driver/mixins/__init__.py +17 -0
  42. sqlspec/driver/mixins/_pipeline.py +523 -0
  43. sqlspec/driver/mixins/_result_utils.py +122 -0
  44. sqlspec/driver/mixins/_sql_translator.py +35 -0
  45. sqlspec/driver/mixins/_storage.py +993 -0
  46. sqlspec/driver/mixins/_type_coercion.py +131 -0
  47. sqlspec/exceptions.py +299 -7
  48. sqlspec/extensions/aiosql/__init__.py +10 -0
  49. sqlspec/extensions/aiosql/adapter.py +474 -0
  50. sqlspec/extensions/litestar/__init__.py +1 -6
  51. sqlspec/extensions/litestar/_utils.py +1 -5
  52. sqlspec/extensions/litestar/config.py +5 -6
  53. sqlspec/extensions/litestar/handlers.py +13 -12
  54. sqlspec/extensions/litestar/plugin.py +22 -24
  55. sqlspec/extensions/litestar/providers.py +37 -55
  56. sqlspec/loader.py +528 -0
  57. sqlspec/service/__init__.py +3 -0
  58. sqlspec/service/base.py +24 -0
  59. sqlspec/service/pagination.py +26 -0
  60. sqlspec/statement/__init__.py +21 -0
  61. sqlspec/statement/builder/__init__.py +54 -0
  62. sqlspec/statement/builder/_ddl_utils.py +119 -0
  63. sqlspec/statement/builder/_parsing_utils.py +135 -0
  64. sqlspec/statement/builder/base.py +328 -0
  65. sqlspec/statement/builder/ddl.py +1379 -0
  66. sqlspec/statement/builder/delete.py +80 -0
  67. sqlspec/statement/builder/insert.py +274 -0
  68. sqlspec/statement/builder/merge.py +95 -0
  69. sqlspec/statement/builder/mixins/__init__.py +65 -0
  70. sqlspec/statement/builder/mixins/_aggregate_functions.py +151 -0
  71. sqlspec/statement/builder/mixins/_case_builder.py +91 -0
  72. sqlspec/statement/builder/mixins/_common_table_expr.py +91 -0
  73. sqlspec/statement/builder/mixins/_delete_from.py +34 -0
  74. sqlspec/statement/builder/mixins/_from.py +61 -0
  75. sqlspec/statement/builder/mixins/_group_by.py +119 -0
  76. sqlspec/statement/builder/mixins/_having.py +35 -0
  77. sqlspec/statement/builder/mixins/_insert_from_select.py +48 -0
  78. sqlspec/statement/builder/mixins/_insert_into.py +36 -0
  79. sqlspec/statement/builder/mixins/_insert_values.py +69 -0
  80. sqlspec/statement/builder/mixins/_join.py +110 -0
  81. sqlspec/statement/builder/mixins/_limit_offset.py +53 -0
  82. sqlspec/statement/builder/mixins/_merge_clauses.py +405 -0
  83. sqlspec/statement/builder/mixins/_order_by.py +46 -0
  84. sqlspec/statement/builder/mixins/_pivot.py +82 -0
  85. sqlspec/statement/builder/mixins/_returning.py +37 -0
  86. sqlspec/statement/builder/mixins/_select_columns.py +60 -0
  87. sqlspec/statement/builder/mixins/_set_ops.py +122 -0
  88. sqlspec/statement/builder/mixins/_unpivot.py +80 -0
  89. sqlspec/statement/builder/mixins/_update_from.py +54 -0
  90. sqlspec/statement/builder/mixins/_update_set.py +91 -0
  91. sqlspec/statement/builder/mixins/_update_table.py +29 -0
  92. sqlspec/statement/builder/mixins/_where.py +374 -0
  93. sqlspec/statement/builder/mixins/_window_functions.py +86 -0
  94. sqlspec/statement/builder/protocols.py +20 -0
  95. sqlspec/statement/builder/select.py +206 -0
  96. sqlspec/statement/builder/update.py +178 -0
  97. sqlspec/statement/filters.py +571 -0
  98. sqlspec/statement/parameters.py +736 -0
  99. sqlspec/statement/pipelines/__init__.py +67 -0
  100. sqlspec/statement/pipelines/analyzers/__init__.py +9 -0
  101. sqlspec/statement/pipelines/analyzers/_analyzer.py +649 -0
  102. sqlspec/statement/pipelines/base.py +315 -0
  103. sqlspec/statement/pipelines/context.py +119 -0
  104. sqlspec/statement/pipelines/result_types.py +41 -0
  105. sqlspec/statement/pipelines/transformers/__init__.py +8 -0
  106. sqlspec/statement/pipelines/transformers/_expression_simplifier.py +256 -0
  107. sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +623 -0
  108. sqlspec/statement/pipelines/transformers/_remove_comments.py +66 -0
  109. sqlspec/statement/pipelines/transformers/_remove_hints.py +81 -0
  110. sqlspec/statement/pipelines/validators/__init__.py +23 -0
  111. sqlspec/statement/pipelines/validators/_dml_safety.py +275 -0
  112. sqlspec/statement/pipelines/validators/_parameter_style.py +297 -0
  113. sqlspec/statement/pipelines/validators/_performance.py +703 -0
  114. sqlspec/statement/pipelines/validators/_security.py +990 -0
  115. sqlspec/statement/pipelines/validators/base.py +67 -0
  116. sqlspec/statement/result.py +527 -0
  117. sqlspec/statement/splitter.py +701 -0
  118. sqlspec/statement/sql.py +1198 -0
  119. sqlspec/storage/__init__.py +15 -0
  120. sqlspec/storage/backends/__init__.py +0 -0
  121. sqlspec/storage/backends/base.py +166 -0
  122. sqlspec/storage/backends/fsspec.py +315 -0
  123. sqlspec/storage/backends/obstore.py +464 -0
  124. sqlspec/storage/protocol.py +170 -0
  125. sqlspec/storage/registry.py +315 -0
  126. sqlspec/typing.py +157 -36
  127. sqlspec/utils/correlation.py +155 -0
  128. sqlspec/utils/deprecation.py +3 -6
  129. sqlspec/utils/fixtures.py +6 -11
  130. sqlspec/utils/logging.py +135 -0
  131. sqlspec/utils/module_loader.py +45 -43
  132. sqlspec/utils/serializers.py +4 -0
  133. sqlspec/utils/singleton.py +6 -8
  134. sqlspec/utils/sync_tools.py +15 -27
  135. sqlspec/utils/text.py +58 -26
  136. {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/METADATA +100 -26
  137. sqlspec-0.12.0.dist-info/RECORD +145 -0
  138. sqlspec/adapters/bigquery/config/__init__.py +0 -3
  139. sqlspec/adapters/bigquery/config/_common.py +0 -40
  140. sqlspec/adapters/bigquery/config/_sync.py +0 -87
  141. sqlspec/adapters/oracledb/config/__init__.py +0 -9
  142. sqlspec/adapters/oracledb/config/_asyncio.py +0 -186
  143. sqlspec/adapters/oracledb/config/_common.py +0 -131
  144. sqlspec/adapters/oracledb/config/_sync.py +0 -186
  145. sqlspec/adapters/psycopg/config/__init__.py +0 -19
  146. sqlspec/adapters/psycopg/config/_async.py +0 -169
  147. sqlspec/adapters/psycopg/config/_common.py +0 -56
  148. sqlspec/adapters/psycopg/config/_sync.py +0 -168
  149. sqlspec/filters.py +0 -330
  150. sqlspec/mixins.py +0 -306
  151. sqlspec/statement.py +0 -378
  152. sqlspec-0.11.0.dist-info/RECORD +0 -69
  153. {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/WHEEL +0 -0
  154. {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/licenses/LICENSE +0 -0
  155. {sqlspec-0.11.0.dist-info → sqlspec-0.12.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,206 @@
1
+ """Safe SQL query builder with validation and parameter binding.
2
+
3
+ This module provides a fluent interface for building SQL queries safely,
4
+ with automatic parameter binding and validation.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional, Union, cast
10
+
11
+ from sqlglot import exp
12
+ from typing_extensions import Self
13
+
14
+ from sqlspec.statement.builder.base import QueryBuilder, SafeQuery
15
+ from sqlspec.statement.builder.mixins import (
16
+ AggregateFunctionsMixin,
17
+ CaseBuilderMixin,
18
+ CommonTableExpressionMixin,
19
+ FromClauseMixin,
20
+ GroupByClauseMixin,
21
+ HavingClauseMixin,
22
+ JoinClauseMixin,
23
+ LimitOffsetClauseMixin,
24
+ OrderByClauseMixin,
25
+ PivotClauseMixin,
26
+ SelectColumnsMixin,
27
+ SetOperationMixin,
28
+ UnpivotClauseMixin,
29
+ WhereClauseMixin,
30
+ WindowFunctionsMixin,
31
+ )
32
+ from sqlspec.statement.result import SQLResult
33
+ from sqlspec.typing import RowT
34
+
35
+ __all__ = ("SelectBuilder",)
36
+
37
+
38
+ @dataclass
39
+ class SelectBuilder(
40
+ QueryBuilder[RowT],
41
+ WhereClauseMixin,
42
+ OrderByClauseMixin,
43
+ LimitOffsetClauseMixin,
44
+ SelectColumnsMixin,
45
+ JoinClauseMixin,
46
+ FromClauseMixin,
47
+ GroupByClauseMixin,
48
+ HavingClauseMixin,
49
+ SetOperationMixin,
50
+ CommonTableExpressionMixin,
51
+ AggregateFunctionsMixin,
52
+ WindowFunctionsMixin,
53
+ CaseBuilderMixin,
54
+ PivotClauseMixin,
55
+ UnpivotClauseMixin,
56
+ ):
57
+ """Type-safe builder for SELECT queries with schema/model integration.
58
+
59
+ This builder provides a fluent, safe interface for constructing SQL SELECT statements.
60
+ It supports type-safe result mapping via the `as_schema()` method, allowing users to
61
+ associate a schema/model (such as a Pydantic model, dataclass, or msgspec.Struct) with
62
+ the query for static type checking and IDE support.
63
+
64
+ Example:
65
+ >>> class User(BaseModel):
66
+ ... id: int
67
+ ... name: str
68
+ >>> builder = (
69
+ ... SelectBuilder()
70
+ ... .select("id", "name")
71
+ ... .from_("users")
72
+ ... .as_schema(User)
73
+ ... )
74
+ >>> result: list[User] = driver.execute(builder)
75
+
76
+ Attributes:
77
+ _schema: The schema/model class for row typing, if set via as_schema().
78
+ """
79
+
80
+ _with_parts: "dict[str, Union[exp.CTE, SelectBuilder]]" = field(default_factory=dict, init=False)
81
+ _expression: Optional[exp.Expression] = field(default=None, init=False, repr=False, compare=False, hash=False)
82
+ _schema: Optional[type[RowT]] = None
83
+ _hints: "list[dict[str, object]]" = field(default_factory=list, init=False, repr=False)
84
+
85
+ def __post_init__(self) -> "None":
86
+ super().__post_init__()
87
+ if self._expression is None:
88
+ self._create_base_expression()
89
+
90
+ @property
91
+ def _expected_result_type(self) -> "type[SQLResult[RowT]]":
92
+ """Get the expected result type for SELECT operations.
93
+
94
+ Returns:
95
+ type: The SelectResult type.
96
+ """
97
+ return SQLResult[RowT]
98
+
99
+ def _create_base_expression(self) -> "exp.Select":
100
+ if self._expression is None or not isinstance(self._expression, exp.Select):
101
+ self._expression = exp.Select()
102
+ # At this point, self._expression is exp.Select
103
+ return self._expression
104
+
105
+ def as_schema(self, schema: "type[RowT]") -> "SelectBuilder[RowT]":
106
+ """Return a new SelectBuilder instance parameterized with the given schema/model type.
107
+
108
+ This enables type-safe result mapping: the returned builder will carry the schema type
109
+ for static analysis and IDE autocompletion. The schema should be a class such as a Pydantic
110
+ model, dataclass, or msgspec.Struct that describes the expected row shape.
111
+
112
+ Args:
113
+ schema: The schema/model class to use for row typing (e.g., a Pydantic model, dataclass, or msgspec.Struct).
114
+
115
+ Returns:
116
+ SelectBuilder[RowT]: A new SelectBuilder instance with RowT set to the provided schema/model type.
117
+ """
118
+ new_builder = SelectBuilder()
119
+ new_builder._expression = self._expression.copy() if self._expression is not None else None
120
+ new_builder._parameters = self._parameters.copy()
121
+ new_builder._parameter_counter = self._parameter_counter
122
+ new_builder.dialect = self.dialect
123
+ new_builder._schema = schema # type: ignore[assignment]
124
+ return cast("SelectBuilder[RowT]", new_builder)
125
+
126
+ def with_hint(
127
+ self,
128
+ hint: "str",
129
+ *,
130
+ location: "str" = "statement",
131
+ table: "Optional[str]" = None,
132
+ dialect: "Optional[str]" = None,
133
+ ) -> "Self":
134
+ """Attach an optimizer or dialect-specific hint to the query.
135
+
136
+ Args:
137
+ hint: The raw hint string (e.g., 'INDEX(users idx_users_name)').
138
+ location: Where to apply the hint ('statement', 'table').
139
+ table: Table name if the hint is for a specific table.
140
+ dialect: Restrict the hint to a specific dialect (optional).
141
+
142
+ Returns:
143
+ The current builder instance for method chaining.
144
+ """
145
+ self._hints.append({"hint": hint, "location": location, "table": table, "dialect": dialect})
146
+ return self
147
+
148
+ def build(self) -> "SafeQuery":
149
+ """Builds the SQL query string and parameters with hint injection.
150
+
151
+ Returns:
152
+ SafeQuery: A dataclass containing the SQL string and parameters.
153
+ """
154
+ # Call parent build method which handles CTEs and optimization
155
+ safe_query = super().build()
156
+
157
+ # Apply hints using SQLGlot's proper hint support (more robust than regex)
158
+ if hasattr(self, "_hints") and self._hints:
159
+ modified_expr = self._expression.copy() if self._expression else None
160
+
161
+ if modified_expr and isinstance(modified_expr, exp.Select):
162
+ # Apply statement-level hints using SQLGlot's Hint expression
163
+ statement_hints = [h["hint"] for h in self._hints if h.get("location") == "statement"]
164
+ if statement_hints:
165
+ # Parse each hint and create proper hint expressions
166
+ hint_expressions = []
167
+ for hint in statement_hints:
168
+ try:
169
+ # Try to parse hint as an expression (e.g., "INDEX(users idx_name)")
170
+ hint_str = str(hint) # Ensure hint is a string
171
+ hint_expr: Optional[exp.Expression] = exp.maybe_parse(hint_str, dialect=self.dialect_name)
172
+ if hint_expr:
173
+ hint_expressions.append(hint_expr)
174
+ else:
175
+ # Create a raw identifier for unparsable hints
176
+ hint_expressions.append(exp.Anonymous(this=hint_str))
177
+ except Exception: # noqa: PERF203
178
+ hint_expressions.append(exp.Anonymous(this=str(hint)))
179
+
180
+ # Create a Hint node and attach to SELECT
181
+ if hint_expressions:
182
+ hint_node = exp.Hint(expressions=hint_expressions)
183
+ modified_expr.set("hint", hint_node)
184
+
185
+ # For table-level hints, we'll fall back to comment injection in SQL
186
+ # since SQLGlot doesn't have a standard way to attach hints to individual tables
187
+ modified_sql = modified_expr.sql(dialect=self.dialect_name, pretty=True)
188
+
189
+ # Apply table-level hints via string manipulation (as fallback)
190
+ table_hints = [h for h in self._hints if h.get("location") == "table" and h.get("table")]
191
+ if table_hints:
192
+ for th in table_hints:
193
+ table = str(th["table"])
194
+ hint = th["hint"]
195
+ # More precise regex that captures the table and optional alias
196
+ pattern = rf"\b{re.escape(table)}\b(\s+AS\s+\w+)?"
197
+
198
+ def replacement_func(match: re.Match[str]) -> str:
199
+ alias_part = match.group(1) or ""
200
+ return f"/*+ {hint} */ {table}{alias_part}" # noqa: B023
201
+
202
+ modified_sql = re.sub(pattern, replacement_func, modified_sql, flags=re.IGNORECASE, count=1)
203
+
204
+ return SafeQuery(sql=modified_sql, parameters=safe_query.parameters, dialect=safe_query.dialect)
205
+
206
+ return safe_query
@@ -0,0 +1,178 @@
1
+ """Safe SQL query builder with validation and parameter binding.
2
+
3
+ This module provides a fluent interface for building SQL queries safely,
4
+ with automatic parameter binding and validation.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Optional, Union
9
+
10
+ from sqlglot import exp
11
+ from typing_extensions import Self
12
+
13
+ from sqlspec.exceptions import SQLBuilderError
14
+ from sqlspec.statement.builder.base import QueryBuilder, SafeQuery
15
+ from sqlspec.statement.builder.mixins import (
16
+ ReturningClauseMixin,
17
+ UpdateFromClauseMixin,
18
+ UpdateSetClauseMixin,
19
+ UpdateTableClauseMixin,
20
+ WhereClauseMixin,
21
+ )
22
+ from sqlspec.statement.result import SQLResult
23
+ from sqlspec.typing import RowT
24
+
25
+ if TYPE_CHECKING:
26
+ from sqlspec.statement.builder.select import SelectBuilder
27
+
28
+ __all__ = ("UpdateBuilder",)
29
+
30
+
31
+ @dataclass(unsafe_hash=True)
32
+ class UpdateBuilder(
33
+ QueryBuilder[RowT],
34
+ WhereClauseMixin,
35
+ ReturningClauseMixin,
36
+ UpdateSetClauseMixin,
37
+ UpdateFromClauseMixin,
38
+ UpdateTableClauseMixin,
39
+ ):
40
+ """Builder for UPDATE statements.
41
+
42
+ This builder provides a fluent interface for constructing SQL UPDATE statements
43
+ with automatic parameter binding and validation.
44
+
45
+ Example:
46
+ ```python
47
+ # Basic UPDATE
48
+ update_query = (
49
+ UpdateBuilder()
50
+ .table("users")
51
+ .set(name="John Doe")
52
+ .set(email="john@example.com")
53
+ .where("id = 1")
54
+ )
55
+
56
+ # UPDATE with parameterized conditions
57
+ update_query = (
58
+ UpdateBuilder()
59
+ .table("users")
60
+ .set(status="active")
61
+ .where_eq("id", 123)
62
+ )
63
+
64
+ # UPDATE with FROM clause (PostgreSQL style)
65
+ update_query = (
66
+ UpdateBuilder()
67
+ .table("users", "u")
68
+ .set(name="Updated Name")
69
+ .from_("profiles", "p")
70
+ .where("u.id = p.user_id AND p.is_verified = true")
71
+ )
72
+ ```
73
+ """
74
+
75
+ @property
76
+ def _expected_result_type(self) -> "type[SQLResult[RowT]]":
77
+ """Return the expected result type for this builder."""
78
+ return SQLResult[RowT]
79
+
80
+ def _create_base_expression(self) -> exp.Update:
81
+ """Create a base UPDATE expression.
82
+
83
+ Returns:
84
+ A new sqlglot Update expression with empty clauses.
85
+ """
86
+ return exp.Update(this=None, expressions=[], joins=[])
87
+
88
+ def join(
89
+ self,
90
+ table: "Union[str, exp.Expression, SelectBuilder[RowT]]",
91
+ on: "Union[str, exp.Expression]",
92
+ alias: "Optional[str]" = None,
93
+ join_type: str = "INNER",
94
+ ) -> "Self":
95
+ """Add JOIN clause to the UPDATE statement.
96
+
97
+ Args:
98
+ table: The table name, expression, or subquery to join.
99
+ on: The JOIN condition.
100
+ alias: Optional alias for the joined table.
101
+ join_type: Type of join (INNER, LEFT, RIGHT, FULL).
102
+
103
+ Returns:
104
+ The current builder instance for method chaining.
105
+
106
+ Raises:
107
+ SQLBuilderError: If the current expression is not an UPDATE statement.
108
+ """
109
+ if self._expression is None or not isinstance(self._expression, exp.Update):
110
+ msg = "Cannot add JOIN clause to non-UPDATE expression."
111
+ raise SQLBuilderError(msg)
112
+
113
+ table_expr: exp.Expression
114
+ if isinstance(table, str):
115
+ table_expr = exp.table_(table, alias=alias)
116
+ elif isinstance(table, QueryBuilder):
117
+ subquery = table.build()
118
+ subquery_exp = exp.paren(exp.maybe_parse(subquery.sql, dialect=self.dialect))
119
+ table_expr = exp.alias_(subquery_exp, alias) if alias else subquery_exp
120
+
121
+ # Merge parameters
122
+ subquery_params = table._parameters
123
+ if subquery_params:
124
+ for p_name, p_value in subquery_params.items():
125
+ self.add_parameter(p_value, name=p_name)
126
+ else:
127
+ table_expr = table
128
+
129
+ on_expr: exp.Expression = exp.condition(on) if isinstance(on, str) else on
130
+
131
+ join_type_upper = join_type.upper()
132
+ if join_type_upper == "INNER":
133
+ join_expr = exp.Join(this=table_expr, on=on_expr)
134
+ elif join_type_upper == "LEFT":
135
+ join_expr = exp.Join(this=table_expr, on=on_expr, side="LEFT")
136
+ elif join_type_upper == "RIGHT":
137
+ join_expr = exp.Join(this=table_expr, on=on_expr, side="RIGHT")
138
+ elif join_type_upper == "FULL":
139
+ join_expr = exp.Join(this=table_expr, on=on_expr, side="FULL", kind="OUTER")
140
+ else:
141
+ msg = f"Unsupported join type: {join_type}"
142
+ raise SQLBuilderError(msg)
143
+
144
+ # Add join to the UPDATE expression
145
+ if not self._expression.args.get("joins"):
146
+ self._expression.set("joins", [])
147
+ self._expression.args["joins"].append(join_expr)
148
+
149
+ return self
150
+
151
+ def build(self) -> "SafeQuery":
152
+ """Build the UPDATE query with validation.
153
+
154
+ Returns:
155
+ SafeQuery: The built query with SQL and parameters.
156
+
157
+ Raises:
158
+ SQLBuilderError: If no table is set or expression is not an UPDATE.
159
+ """
160
+ if self._expression is None:
161
+ msg = "UPDATE expression not initialized."
162
+ raise SQLBuilderError(msg)
163
+
164
+ if not isinstance(self._expression, exp.Update):
165
+ msg = "No UPDATE expression to build or expression is of the wrong type."
166
+ raise SQLBuilderError(msg)
167
+
168
+ # Check that the table is set
169
+ if getattr(self._expression, "this", None) is None:
170
+ msg = "No table specified for UPDATE statement."
171
+ raise SQLBuilderError(msg)
172
+
173
+ # Check that at least one SET expression exists
174
+ if not self._expression.args.get("expressions"):
175
+ msg = "At least one SET clause must be specified for UPDATE statement."
176
+ raise SQLBuilderError(msg)
177
+
178
+ return super().build()