sqlspec 0.11.1__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 -621
  8. sqlspec/adapters/aiosqlite/__init__.py +2 -6
  9. sqlspec/adapters/aiosqlite/config.py +143 -57
  10. sqlspec/adapters/aiosqlite/driver.py +269 -431
  11. sqlspec/adapters/asyncmy/__init__.py +3 -8
  12. sqlspec/adapters/asyncmy/config.py +247 -202
  13. sqlspec/adapters/asyncmy/driver.py +218 -436
  14. sqlspec/adapters/asyncpg/__init__.py +4 -7
  15. sqlspec/adapters/asyncpg/config.py +329 -176
  16. sqlspec/adapters/asyncpg/driver.py +417 -487
  17. sqlspec/adapters/bigquery/__init__.py +2 -2
  18. sqlspec/adapters/bigquery/config.py +407 -0
  19. sqlspec/adapters/bigquery/driver.py +600 -553
  20. sqlspec/adapters/duckdb/__init__.py +4 -1
  21. sqlspec/adapters/duckdb/config.py +432 -321
  22. sqlspec/adapters/duckdb/driver.py +392 -406
  23. sqlspec/adapters/oracledb/__init__.py +3 -8
  24. sqlspec/adapters/oracledb/config.py +625 -0
  25. sqlspec/adapters/oracledb/driver.py +548 -921
  26. sqlspec/adapters/psqlpy/__init__.py +4 -7
  27. sqlspec/adapters/psqlpy/config.py +372 -203
  28. sqlspec/adapters/psqlpy/driver.py +197 -533
  29. sqlspec/adapters/psycopg/__init__.py +3 -8
  30. sqlspec/adapters/psycopg/config.py +741 -0
  31. sqlspec/adapters/psycopg/driver.py +734 -694
  32. sqlspec/adapters/sqlite/__init__.py +2 -6
  33. sqlspec/adapters/sqlite/config.py +146 -81
  34. sqlspec/adapters/sqlite/driver.py +242 -405
  35. sqlspec/base.py +220 -784
  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.1.dist-info → sqlspec-0.12.0.dist-info}/METADATA +97 -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 -331
  150. sqlspec/mixins.py +0 -305
  151. sqlspec/statement.py +0 -378
  152. sqlspec-0.11.1.dist-info/RECORD +0 -69
  153. {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/WHEEL +0 -0
  154. {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/licenses/LICENSE +0 -0
  155. {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,571 @@
1
+ """Collection filter datastructures."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections import abc
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, Protocol, Union, runtime_checkable
8
+
9
+ from sqlglot import exp
10
+ from typing_extensions import TypeAlias, TypeVar
11
+
12
+ if TYPE_CHECKING:
13
+ from sqlglot.expressions import Condition
14
+
15
+ from sqlspec.statement import SQL
16
+
17
+ __all__ = (
18
+ "AnyCollectionFilter",
19
+ "BeforeAfterFilter",
20
+ "FilterTypes",
21
+ "InAnyFilter",
22
+ "InCollectionFilter",
23
+ "LimitOffsetFilter",
24
+ "NotAnyCollectionFilter",
25
+ "NotInCollectionFilter",
26
+ "NotInSearchFilter",
27
+ "OnBeforeAfterFilter",
28
+ "OrderByFilter",
29
+ "PaginationFilter",
30
+ "SearchFilter",
31
+ "StatementFilter",
32
+ "apply_filter",
33
+ )
34
+
35
+ T = TypeVar("T")
36
+ FilterTypeT = TypeVar("FilterTypeT", bound="StatementFilter")
37
+ """Type variable for filter types.
38
+
39
+ :class:`~advanced_alchemy.filters.StatementFilter`
40
+ """
41
+
42
+
43
+ @runtime_checkable
44
+ class StatementFilter(Protocol):
45
+ """Protocol for filters that can be appended to a statement."""
46
+
47
+ @abstractmethod
48
+ def append_to_statement(self, statement: "SQL") -> "SQL":
49
+ """Append the filter to the statement.
50
+
51
+ This method should modify the SQL expression only, not the parameters.
52
+ Parameters should be provided via extract_parameters().
53
+ """
54
+ ...
55
+
56
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
57
+ """Extract parameters that this filter contributes.
58
+
59
+ Returns:
60
+ Tuple of (positional_params, named_params) where:
61
+ - positional_params: List of positional parameter values
62
+ - named_params: Dict of parameter name to value
63
+ """
64
+ return [], {}
65
+
66
+
67
+ @dataclass
68
+ class BeforeAfterFilter(StatementFilter):
69
+ """Data required to filter a query on a ``datetime`` column.
70
+
71
+ Note:
72
+ After applying this filter, only the filter's parameters (e.g., before/after) will be present in the resulting SQL statement's parameters. Original parameters from the statement are not preserved in the result.
73
+ """
74
+
75
+ field_name: str
76
+ """Name of the model attribute to filter on."""
77
+ before: Optional[datetime] = None
78
+ """Filter results where field earlier than this."""
79
+ after: Optional[datetime] = None
80
+ """Filter results where field later than this."""
81
+
82
+ def __post_init__(self) -> None:
83
+ """Initialize parameter names."""
84
+ self._param_name_before: Optional[str] = None
85
+ self._param_name_after: Optional[str] = None
86
+
87
+ if self.before:
88
+ self._param_name_before = f"{self.field_name}_before"
89
+ if self.after:
90
+ self._param_name_after = f"{self.field_name}_after"
91
+
92
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
93
+ """Extract filter parameters."""
94
+ named_params = {}
95
+ if self.before and self._param_name_before:
96
+ named_params[self._param_name_before] = self.before
97
+ if self.after and self._param_name_after:
98
+ named_params[self._param_name_after] = self.after
99
+ return [], named_params
100
+
101
+ def append_to_statement(self, statement: "SQL") -> "SQL":
102
+ """Apply filter to SQL expression only."""
103
+ conditions: list[Condition] = []
104
+ col_expr = exp.column(self.field_name)
105
+
106
+ if self.before and self._param_name_before:
107
+ conditions.append(exp.LT(this=col_expr, expression=exp.Placeholder(this=self._param_name_before)))
108
+ if self.after and self._param_name_after:
109
+ conditions.append(exp.GT(this=col_expr, expression=exp.Placeholder(this=self._param_name_after)))
110
+
111
+ if conditions:
112
+ final_condition = conditions[0]
113
+ for cond in conditions[1:]:
114
+ final_condition = exp.And(this=final_condition, expression=cond)
115
+ # Use the SQL object's where method which handles all cases
116
+ result = statement.where(final_condition)
117
+ # Add the filter's parameters to the result
118
+ _, named_params = self.extract_parameters()
119
+ for name, value in named_params.items():
120
+ result = result.add_named_parameter(name, value)
121
+ return result
122
+ return statement
123
+
124
+
125
+ @dataclass
126
+ class OnBeforeAfterFilter(StatementFilter):
127
+ """Data required to filter a query on a ``datetime`` column."""
128
+
129
+ field_name: str
130
+ """Name of the model attribute to filter on."""
131
+ on_or_before: Optional[datetime] = None
132
+ """Filter results where field is on or earlier than this."""
133
+ on_or_after: Optional[datetime] = None
134
+ """Filter results where field on or later than this."""
135
+
136
+ def __post_init__(self) -> None:
137
+ """Initialize parameter names."""
138
+ self._param_name_on_or_before: Optional[str] = None
139
+ self._param_name_on_or_after: Optional[str] = None
140
+
141
+ if self.on_or_before:
142
+ self._param_name_on_or_before = f"{self.field_name}_on_or_before"
143
+ if self.on_or_after:
144
+ self._param_name_on_or_after = f"{self.field_name}_on_or_after"
145
+
146
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
147
+ """Extract filter parameters."""
148
+ named_params = {}
149
+ if self.on_or_before and self._param_name_on_or_before:
150
+ named_params[self._param_name_on_or_before] = self.on_or_before
151
+ if self.on_or_after and self._param_name_on_or_after:
152
+ named_params[self._param_name_on_or_after] = self.on_or_after
153
+ return [], named_params
154
+
155
+ def append_to_statement(self, statement: "SQL") -> "SQL":
156
+ conditions: list[Condition] = []
157
+
158
+ if self.on_or_before and self._param_name_on_or_before:
159
+ conditions.append(
160
+ exp.LTE(
161
+ this=exp.column(self.field_name), expression=exp.Placeholder(this=self._param_name_on_or_before)
162
+ )
163
+ )
164
+ if self.on_or_after and self._param_name_on_or_after:
165
+ conditions.append(
166
+ exp.GTE(this=exp.column(self.field_name), expression=exp.Placeholder(this=self._param_name_on_or_after))
167
+ )
168
+
169
+ if conditions:
170
+ final_condition = conditions[0]
171
+ for cond in conditions[1:]:
172
+ final_condition = exp.And(this=final_condition, expression=cond)
173
+ result = statement.where(final_condition)
174
+ # Add the filter's parameters to the result
175
+ _, named_params = self.extract_parameters()
176
+ for name, value in named_params.items():
177
+ result = result.add_named_parameter(name, value)
178
+ return result
179
+ return statement
180
+
181
+
182
+ class InAnyFilter(StatementFilter, ABC, Generic[T]):
183
+ """Subclass for methods that have a `prefer_any` attribute."""
184
+
185
+ @abstractmethod
186
+ def append_to_statement(self, statement: "SQL") -> "SQL":
187
+ raise NotImplementedError
188
+
189
+
190
+ @dataclass
191
+ class InCollectionFilter(InAnyFilter[T]):
192
+ """Data required to construct a ``WHERE ... IN (...)`` clause.
193
+
194
+ Note:
195
+ After applying this filter, only the filter's parameters (e.g., the generated IN parameters) will be present in the resulting SQL statement's parameters. Original parameters from the statement are not preserved in the result.
196
+ """
197
+
198
+ field_name: str
199
+ """Name of the model attribute to filter on."""
200
+ values: Optional[abc.Collection[T]]
201
+ """Values for ``IN`` clause.
202
+
203
+ An empty list will return an empty result set, however, if ``None``, the filter is not applied to the query, and all rows are returned. """
204
+
205
+ def __post_init__(self) -> None:
206
+ """Initialize parameter names."""
207
+ self._param_names: list[str] = []
208
+ if self.values:
209
+ for i, _ in enumerate(self.values):
210
+ self._param_names.append(f"{self.field_name}_in_{i}")
211
+
212
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
213
+ """Extract filter parameters."""
214
+ named_params = {}
215
+ if self.values:
216
+ for i, value in enumerate(self.values):
217
+ named_params[self._param_names[i]] = value
218
+ return [], named_params
219
+
220
+ def append_to_statement(self, statement: "SQL") -> "SQL":
221
+ if self.values is None:
222
+ return statement
223
+
224
+ if not self.values:
225
+ return statement.where(exp.false())
226
+
227
+ placeholder_expressions: list[exp.Placeholder] = [
228
+ exp.Placeholder(this=param_name) for param_name in self._param_names
229
+ ]
230
+
231
+ result = statement.where(exp.In(this=exp.column(self.field_name), expressions=placeholder_expressions))
232
+ # Add the filter's parameters to the result
233
+ _, named_params = self.extract_parameters()
234
+ for name, value in named_params.items():
235
+ result = result.add_named_parameter(name, value)
236
+ return result
237
+
238
+
239
+ @dataclass
240
+ class NotInCollectionFilter(InAnyFilter[T]):
241
+ """Data required to construct a ``WHERE ... NOT IN (...)`` clause."""
242
+
243
+ field_name: str
244
+ """Name of the model attribute to filter on."""
245
+ values: Optional[abc.Collection[T]]
246
+ """Values for ``NOT IN`` clause.
247
+
248
+ An empty list or ``None`` will return all rows."""
249
+
250
+ def __post_init__(self) -> None:
251
+ """Initialize parameter names."""
252
+ self._param_names: list[str] = []
253
+ if self.values:
254
+ for i, _ in enumerate(self.values):
255
+ self._param_names.append(f"{self.field_name}_notin_{i}_{id(self)}")
256
+
257
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
258
+ """Extract filter parameters."""
259
+ named_params = {}
260
+ if self.values:
261
+ for i, value in enumerate(self.values):
262
+ named_params[self._param_names[i]] = value
263
+ return [], named_params
264
+
265
+ def append_to_statement(self, statement: "SQL") -> "SQL":
266
+ if self.values is None or not self.values:
267
+ return statement
268
+
269
+ placeholder_expressions: list[exp.Placeholder] = [
270
+ exp.Placeholder(this=param_name) for param_name in self._param_names
271
+ ]
272
+
273
+ result = statement.where(
274
+ exp.Not(this=exp.In(this=exp.column(self.field_name), expressions=placeholder_expressions))
275
+ )
276
+ # Add the filter's parameters to the result
277
+ _, named_params = self.extract_parameters()
278
+ for name, value in named_params.items():
279
+ result = result.add_named_parameter(name, value)
280
+ return result
281
+
282
+
283
+ @dataclass
284
+ class AnyCollectionFilter(InAnyFilter[T]):
285
+ """Data required to construct a ``WHERE column_name = ANY (array_expression)`` clause."""
286
+
287
+ field_name: str
288
+ """Name of the model attribute to filter on."""
289
+ values: Optional[abc.Collection[T]]
290
+ """Values for ``= ANY (...)`` clause.
291
+
292
+ An empty list will result in a condition that is always false (no rows returned).
293
+ If ``None``, the filter is not applied to the query, and all rows are returned.
294
+ """
295
+
296
+ def __post_init__(self) -> None:
297
+ """Initialize parameter names."""
298
+ self._param_names: list[str] = []
299
+ if self.values:
300
+ for i, _ in enumerate(self.values):
301
+ self._param_names.append(f"{self.field_name}_any_{i}")
302
+
303
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
304
+ """Extract filter parameters."""
305
+ named_params = {}
306
+ if self.values:
307
+ for i, value in enumerate(self.values):
308
+ named_params[self._param_names[i]] = value
309
+ return [], named_params
310
+
311
+ def append_to_statement(self, statement: "SQL") -> "SQL":
312
+ if self.values is None:
313
+ return statement
314
+
315
+ if not self.values:
316
+ # column = ANY (empty_array) is generally false
317
+ return statement.where(exp.false())
318
+
319
+ placeholder_expressions: list[exp.Expression] = [
320
+ exp.Placeholder(this=param_name) for param_name in self._param_names
321
+ ]
322
+
323
+ array_expr = exp.Array(expressions=placeholder_expressions)
324
+ # Generates SQL like: self.field_name = ANY(ARRAY[?, ?, ...])
325
+ result = statement.where(exp.EQ(this=exp.column(self.field_name), expression=exp.Any(this=array_expr)))
326
+ # Add the filter's parameters to the result
327
+ _, named_params = self.extract_parameters()
328
+ for name, value in named_params.items():
329
+ result = result.add_named_parameter(name, value)
330
+ return result
331
+
332
+
333
+ @dataclass
334
+ class NotAnyCollectionFilter(InAnyFilter[T]):
335
+ """Data required to construct a ``WHERE NOT (column_name = ANY (array_expression))`` clause."""
336
+
337
+ field_name: str
338
+ """Name of the model attribute to filter on."""
339
+ values: Optional[abc.Collection[T]]
340
+ """Values for ``NOT (... = ANY (...))`` clause.
341
+
342
+ An empty list will result in a condition that is always true (all rows returned, filter effectively ignored).
343
+ If ``None``, the filter is not applied to the query, and all rows are returned.
344
+ """
345
+
346
+ def __post_init__(self) -> None:
347
+ """Initialize parameter names."""
348
+ self._param_names: list[str] = []
349
+ if self.values:
350
+ for i, _ in enumerate(self.values):
351
+ self._param_names.append(f"{self.field_name}_notany_{i}")
352
+
353
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
354
+ """Extract filter parameters."""
355
+ named_params = {}
356
+ if self.values:
357
+ for i, value in enumerate(self.values):
358
+ named_params[self._param_names[i]] = value
359
+ return [], named_params
360
+
361
+ def append_to_statement(self, statement: "SQL") -> "SQL":
362
+ if self.values is None or not self.values:
363
+ # NOT (column = ANY (empty_array)) is generally true
364
+ # So, if values is empty or None, this filter should not restrict results.
365
+ return statement
366
+
367
+ placeholder_expressions: list[exp.Expression] = [
368
+ exp.Placeholder(this=param_name) for param_name in self._param_names
369
+ ]
370
+
371
+ array_expr = exp.Array(expressions=placeholder_expressions)
372
+ # Generates SQL like: NOT (self.field_name = ANY(ARRAY[?, ?, ...]))
373
+ condition = exp.EQ(this=exp.column(self.field_name), expression=exp.Any(this=array_expr))
374
+ result = statement.where(exp.Not(this=condition))
375
+ # Add the filter's parameters to the result
376
+ _, named_params = self.extract_parameters()
377
+ for name, value in named_params.items():
378
+ result = result.add_named_parameter(name, value)
379
+ return result
380
+
381
+
382
+ class PaginationFilter(StatementFilter, ABC):
383
+ """Subclass for methods that function as a pagination type."""
384
+
385
+ @abstractmethod
386
+ def append_to_statement(self, statement: "SQL") -> "SQL":
387
+ raise NotImplementedError
388
+
389
+
390
+ @dataclass
391
+ class LimitOffsetFilter(PaginationFilter):
392
+ """Data required to add limit/offset filtering to a query."""
393
+
394
+ limit: int
395
+ """Value for ``LIMIT`` clause of query."""
396
+ offset: int
397
+ """Value for ``OFFSET`` clause of query."""
398
+
399
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
400
+ """Extract filter parameters."""
401
+ # Return the limit and offset values as named parameters
402
+ return [], {"limit": self.limit, "offset": self.offset}
403
+
404
+ def append_to_statement(self, statement: "SQL") -> "SQL":
405
+ return statement.limit(self.limit, use_parameter=True).offset(self.offset, use_parameter=True)
406
+
407
+
408
+ @dataclass
409
+ class OrderByFilter(StatementFilter):
410
+ """Data required to construct a ``ORDER BY ...`` clause."""
411
+
412
+ field_name: str
413
+ """Name of the model attribute to sort on."""
414
+ sort_order: Literal["asc", "desc"] = "asc"
415
+ """Sort ascending or descending"""
416
+
417
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
418
+ """Extract filter parameters."""
419
+ # ORDER BY doesn't use parameters, only column names and sort direction
420
+ return [], {}
421
+
422
+ def append_to_statement(self, statement: "SQL") -> "SQL":
423
+ normalized_sort_order = self.sort_order.lower()
424
+ if normalized_sort_order not in {"asc", "desc"}:
425
+ normalized_sort_order = "asc"
426
+ if normalized_sort_order == "desc":
427
+ return statement.order_by(exp.column(self.field_name).desc())
428
+ return statement.order_by(exp.column(self.field_name).asc())
429
+
430
+
431
+ @dataclass
432
+ class SearchFilter(StatementFilter):
433
+ """Data required to construct a ``WHERE field_name LIKE '%' || :value || '%'`` clause.
434
+
435
+ Note:
436
+ After applying this filter, only the filter's parameters (e.g., the generated search parameter) will be present in the resulting SQL statement's parameters. Original parameters from the statement are not preserved in the result.
437
+ """
438
+
439
+ field_name: Union[str, set[str]]
440
+ """Name of the model attribute to search on."""
441
+ value: str
442
+ """Search value."""
443
+ ignore_case: Optional[bool] = False
444
+ """Should the search be case insensitive."""
445
+
446
+ def __post_init__(self) -> None:
447
+ """Initialize parameter names."""
448
+ self._param_name: Optional[str] = None
449
+ if self.value:
450
+ if isinstance(self.field_name, str):
451
+ self._param_name = f"{self.field_name}_search"
452
+ else:
453
+ # For multiple fields, use a generic search parameter name
454
+ self._param_name = "search_value"
455
+
456
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
457
+ """Extract filter parameters."""
458
+ named_params = {}
459
+ if self.value and self._param_name:
460
+ search_value_with_wildcards = f"%{self.value}%"
461
+ named_params[self._param_name] = search_value_with_wildcards
462
+ return [], named_params
463
+
464
+ def append_to_statement(self, statement: "SQL") -> "SQL":
465
+ if not self.value or not self._param_name:
466
+ return statement
467
+
468
+ pattern_expr = exp.Placeholder(this=self._param_name)
469
+ like_op = exp.ILike if self.ignore_case else exp.Like
470
+
471
+ result = statement
472
+ if isinstance(self.field_name, str):
473
+ result = statement.where(like_op(this=exp.column(self.field_name), expression=pattern_expr))
474
+ elif isinstance(self.field_name, set) and self.field_name:
475
+ field_conditions: list[Condition] = [
476
+ like_op(this=exp.column(field), expression=pattern_expr) for field in self.field_name
477
+ ]
478
+ if not field_conditions:
479
+ return statement
480
+
481
+ final_condition: Condition = field_conditions[0]
482
+ if len(field_conditions) > 1:
483
+ for cond in field_conditions[1:]:
484
+ final_condition = exp.Or(this=final_condition, expression=cond)
485
+ result = statement.where(final_condition)
486
+
487
+ # Add the filter's parameters to the result
488
+ _, named_params = self.extract_parameters()
489
+ for name, value in named_params.items():
490
+ result = result.add_named_parameter(name, value)
491
+ return result
492
+
493
+
494
+ @dataclass
495
+ class NotInSearchFilter(SearchFilter):
496
+ """Data required to construct a ``WHERE field_name NOT LIKE '%' || :value || '%'`` clause."""
497
+
498
+ def __post_init__(self) -> None:
499
+ """Initialize parameter names."""
500
+ self._param_name: Optional[str] = None
501
+ if self.value:
502
+ if isinstance(self.field_name, str):
503
+ self._param_name = f"{self.field_name}_not_search"
504
+ else:
505
+ # For multiple fields, use a generic search parameter name
506
+ self._param_name = "not_search_value"
507
+
508
+ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]:
509
+ """Extract filter parameters."""
510
+ named_params = {}
511
+ if self.value and self._param_name:
512
+ search_value_with_wildcards = f"%{self.value}%"
513
+ named_params[self._param_name] = search_value_with_wildcards
514
+ return [], named_params
515
+
516
+ def append_to_statement(self, statement: "SQL") -> "SQL":
517
+ if not self.value or not self._param_name:
518
+ return statement
519
+
520
+ pattern_expr = exp.Placeholder(this=self._param_name)
521
+ like_op = exp.ILike if self.ignore_case else exp.Like
522
+
523
+ result = statement
524
+ if isinstance(self.field_name, str):
525
+ result = statement.where(exp.Not(this=like_op(this=exp.column(self.field_name), expression=pattern_expr)))
526
+ elif isinstance(self.field_name, set) and self.field_name:
527
+ field_conditions: list[Condition] = [
528
+ exp.Not(this=like_op(this=exp.column(field), expression=pattern_expr)) for field in self.field_name
529
+ ]
530
+ if not field_conditions:
531
+ return statement
532
+
533
+ final_condition: Condition = field_conditions[0]
534
+ if len(field_conditions) > 1:
535
+ for cond in field_conditions[1:]:
536
+ final_condition = exp.And(this=final_condition, expression=cond)
537
+ result = statement.where(final_condition)
538
+
539
+ # Add the filter's parameters to the result
540
+ _, named_params = self.extract_parameters()
541
+ for name, value in named_params.items():
542
+ result = result.add_named_parameter(name, value)
543
+ return result
544
+
545
+
546
+ def apply_filter(statement: "SQL", filter_obj: StatementFilter) -> "SQL":
547
+ """Apply a statement filter to a SQL query object.
548
+
549
+ Args:
550
+ statement: The SQL query object to modify.
551
+ filter_obj: The filter to apply.
552
+
553
+ Returns:
554
+ The modified query object.
555
+ """
556
+ return filter_obj.append_to_statement(statement)
557
+
558
+
559
+ FilterTypes: TypeAlias = Union[
560
+ BeforeAfterFilter,
561
+ OnBeforeAfterFilter,
562
+ InCollectionFilter[Any],
563
+ LimitOffsetFilter,
564
+ OrderByFilter,
565
+ SearchFilter,
566
+ NotInCollectionFilter[Any],
567
+ NotInSearchFilter,
568
+ AnyCollectionFilter[Any],
569
+ NotAnyCollectionFilter[Any],
570
+ ]
571
+ """Aggregate type alias of the types supported for collection filtering."""