criteria-pattern 0.1.1__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.
@@ -0,0 +1,11 @@
1
+ __version__ = '0.1.1'
2
+
3
+ from .models import Criteria, Filter, FilterOperator, Order, OrderDirection
4
+
5
+ __all__ = (
6
+ 'Criteria',
7
+ 'Filter',
8
+ 'FilterOperator',
9
+ 'Order',
10
+ 'OrderDirection',
11
+ )
@@ -0,0 +1,3 @@
1
+ from .sql_converter import SqlConverter
2
+
3
+ __all__ = ('SqlConverter',)
@@ -0,0 +1,387 @@
1
+ """
2
+ Raw SQL converter module.
3
+ """
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any, assert_never
7
+
8
+ from criteria_pattern import Criteria, FilterOperator, OrderDirection
9
+ from criteria_pattern.exceptions import InvalidColumnError, InvalidTableError
10
+ from criteria_pattern.models.criteria import AndCriteria, NotCriteria, OrCriteria
11
+
12
+
13
+ class SqlConverter:
14
+ """
15
+ Raw SQL converter.
16
+
17
+ Example:
18
+ ```python
19
+ from criteria_pattern import Criteria, Filter, FilterOperator
20
+ from criteria_pattern.converter import SqlConverter
21
+
22
+ is_adult = Criteria(filters=[Filter('age', FilterOperator.GREATER_OR_EQUAL, 18)])
23
+ email_is_gmail = Criteria(filters=[Filter('email', FilterOperator.ENDS_WITH, '@gmail.com')])
24
+ email_is_yahoo = Criteria(filters=[Filter('email', FilterOperator.ENDS_WITH, '@yahoo.com')])
25
+
26
+ query, parameters = SqlConverter.convert(criteria=is_adult & (email_is_gmail | email_is_yahoo), table='user')
27
+ print(query)
28
+ print(parameters)
29
+ # >>> SELECT * FROM user WHERE (age >= %(parameter_0)s AND (email LIKE '%%' || %(parameter_1)s OR email LIKE '%%' || %(parameter_2)s));
30
+ # >>> {'parameter_0': 18, 'parameter_1': '@gmail.com', 'parameter_2': '@yahoo.com'}
31
+ ```
32
+ """ # noqa: E501
33
+
34
+ @classmethod
35
+ def convert(
36
+ cls,
37
+ criteria: Criteria,
38
+ table: str,
39
+ columns: Sequence[str] | None = None,
40
+ columns_mapping: Mapping[str, str] | None = None,
41
+ check_table_injection: bool = False,
42
+ check_column_injection: bool = False,
43
+ check_criteria_injection: bool = False,
44
+ valid_tables: Sequence[str] | None = None,
45
+ valid_columns: Sequence[str] | None = None,
46
+ ) -> tuple[str, dict[str, Any]]:
47
+ """
48
+ Convert the Criteria object to a raw SQL query.
49
+
50
+ Args:
51
+ criteria (Criteria): Criteria to convert.
52
+ table (str): Name of the table to query.
53
+ columns (Sequence[str], optional): Columns of the table to select. Default to *. Default to *.
54
+ columns_mapping (Mapping[str, str], optional): Mapping of column names to aliases. Default to empty dict.
55
+ check_criteria_injection (bool, optional): Raise an error if the criteria field is not in the list of valid
56
+ columns. Default to False.
57
+ check_table_injection (bool, optional): Raise an error if the table is not in the list of valid tables.
58
+ Default to False.
59
+ check_column_injection (bool, optional): Raise an error if the column is not in the list of valid columns.
60
+ Default to False.
61
+ valid_tables (Sequence[str], optional): List of valid tables to query. Default to empty list.
62
+ valid_columns (Sequence[str], optional): List of valid columns to select. Default to empty list.
63
+
64
+ Raises:
65
+ InvalidTableError: If the table is not in the list of valid tables (only if check_table_injection=True).
66
+ InvalidColumnError: If the column is not in the list of valid columns (only if check_column_injection=True).
67
+
68
+ Returns:
69
+ tuple[str, dict[str, Any]]: The raw SQL query string and the query parameters.
70
+
71
+ Example:
72
+ ```python
73
+ from criteria_pattern import Criteria, Filter, FilterOperator
74
+ from criteria_pattern.converter import SqlConverter
75
+
76
+ is_adult = Criteria(filters=[Filter('age', FilterOperator.GREATER_OR_EQUAL, 18)])
77
+ email_is_gmail = Criteria(filters=[Filter('email', FilterOperator.ENDS_WITH, '@gmail.com')])
78
+ email_is_yahoo = Criteria(filters=[Filter('email', FilterOperator.ENDS_WITH, '@yahoo.com')])
79
+
80
+ query, parameters = SqlConverter.convert(criteria=is_adult & (email_is_gmail | email_is_yahoo), table='user')
81
+ print(query)
82
+ print(parameters)
83
+ # >>> SELECT * FROM user WHERE (age >= %(parameter_0)s AND (email LIKE '%%' || %(parameter_1)s OR email LIKE '%%' || %(parameter_2)s));
84
+ # >>> {'parameter_0': 18, 'parameter_1': '@gmail.com', 'parameter_2': '@yahoo.com'}
85
+ ```
86
+ """ # noqa: E501
87
+ columns = columns or ['*']
88
+ columns_mapping = columns_mapping or {}
89
+ valid_tables = valid_tables or []
90
+ valid_columns = valid_columns or []
91
+
92
+ if check_table_injection:
93
+ cls._validate_table(table=table, valid_tables=valid_tables)
94
+
95
+ if check_column_injection:
96
+ cls._validate_columns(columns=columns, columns_mapping=columns_mapping, valid_columns=valid_columns)
97
+
98
+ if check_criteria_injection:
99
+ cls._validate_criteria(criteria=criteria, valid_columns=valid_columns)
100
+
101
+ query = f'SELECT {", ".join(columns)} FROM {table}' # noqa: S608 # nosec
102
+ parameters: dict[str, Any] = {}
103
+
104
+ if criteria.has_filters():
105
+ where_clause, parameters = cls._process_filters(criteria=criteria, columns_mapping=columns_mapping)
106
+ query += f' WHERE {where_clause}'
107
+
108
+ if criteria.has_orders():
109
+ order_clause = cls._process_orders(criteria=criteria, columns_mapping=columns_mapping)
110
+ query += f' ORDER BY {order_clause}'
111
+
112
+ return f'{query};', parameters
113
+
114
+ @classmethod
115
+ def _validate_table(cls, table: str, valid_tables: Sequence[str]) -> None:
116
+ """
117
+ Validate the table name to prevent SQL injection.
118
+
119
+ Args:
120
+ table (str): Name of the table to query.
121
+ valid_tables (Sequence[str]): List of valid tables to query.
122
+
123
+ Raises:
124
+ InvalidTableError: If the table is not in the list of valid tables.
125
+ """
126
+ if table not in valid_tables:
127
+ raise InvalidTableError(table=table, valid_tables=valid_tables)
128
+
129
+ @classmethod
130
+ def _validate_columns(
131
+ cls,
132
+ columns: Sequence[str],
133
+ columns_mapping: Mapping[str, str],
134
+ valid_columns: Sequence[str],
135
+ ) -> None:
136
+ """
137
+ Validate the column names to prevent SQL injection.
138
+
139
+ Args:
140
+ columns (Sequence[str]): Columns of the table to select.
141
+ columns_mapping (Mapping[str, str]): Mapping of column names to aliases.
142
+ valid_columns (Sequence[str]): List of valid columns to select.
143
+
144
+ Raises:
145
+ InvalidColumnError: If the column is not in the list of valid columns.
146
+ """
147
+ for column in columns:
148
+ if column not in valid_columns:
149
+ raise InvalidColumnError(column=column, valid_columns=valid_columns)
150
+
151
+ for column in columns_mapping.values():
152
+ if column not in valid_columns:
153
+ raise InvalidColumnError(column=column, valid_columns=valid_columns)
154
+
155
+ @classmethod
156
+ def _validate_criteria(cls, criteria: Criteria, valid_columns: Sequence[str]) -> None:
157
+ """
158
+ Validate the Criteria object to prevent SQL injection.
159
+
160
+ Args:
161
+ criteria (Criteria): Criteria to validate.
162
+ valid_columns (Sequence[str]): List of valid columns to select.
163
+
164
+ Raises:
165
+ InvalidColumnError: If the column is not in the list of valid columns.
166
+ """
167
+ for filter in criteria.filters:
168
+ if filter.field not in valid_columns:
169
+ raise InvalidColumnError(column=filter.field, valid_columns=valid_columns)
170
+
171
+ for order in criteria.orders:
172
+ if order.field not in valid_columns:
173
+ raise InvalidColumnError(column=order.field, valid_columns=valid_columns)
174
+
175
+ @classmethod
176
+ def _process_filters(cls, criteria: Criteria, columns_mapping: Mapping[str, str]) -> tuple[str, dict[str, Any]]:
177
+ """
178
+ Process the Criteria object to return an SQL WHERE clause.
179
+
180
+ Args:
181
+ criteria (Criteria): Criteria to process.
182
+ columns_mapping (Mapping[str, str]): Mapping of column names to aliases.
183
+
184
+ Returns:
185
+ tuple[str, dict[str, Any]]: Processed filter string for SQL WHERE clause and parameters for the SQL query.
186
+ """
187
+ return cls._process_filters_recursive(criteria=criteria, columns_mapping=columns_mapping)
188
+
189
+ @classmethod
190
+ def _process_filters_recursive( # noqa: C901
191
+ cls,
192
+ criteria: Criteria,
193
+ columns_mapping: Mapping[str, str],
194
+ parameters_counter: int = 0,
195
+ ) -> tuple[str, dict[str, Any]]:
196
+ """
197
+ Process the Criteria object to return an SQL WHERE clause.
198
+
199
+ Args:
200
+ criteria (Criteria): Criteria to process.
201
+ columns_mapping (Mapping[str, str]): Mapping of column names to aliases.
202
+ parameters_counter (int): Counter for parameter names to ensure uniqueness.
203
+
204
+ Returns:
205
+ tuple[str, dict[str, Any]]: Processed filter string for SQL WHERE clause and parameters for the SQL query.
206
+ """
207
+ filters = ''
208
+ parameters: dict[str, Any] = {}
209
+
210
+ if isinstance(criteria, AndCriteria):
211
+ left_conditions, left_parameters = cls._process_filters_recursive(
212
+ criteria=criteria.left,
213
+ columns_mapping=columns_mapping,
214
+ parameters_counter=parameters_counter,
215
+ )
216
+ parameters_counter += len(left_parameters)
217
+ parameters.update(left_parameters)
218
+
219
+ right_conditions, right_parameters = cls._process_filters_recursive(
220
+ criteria=criteria.right,
221
+ columns_mapping=columns_mapping,
222
+ parameters_counter=parameters_counter,
223
+ )
224
+ parameters_counter += len(right_parameters)
225
+ parameters.update(right_parameters)
226
+
227
+ filters += f'({left_conditions} AND {right_conditions})'
228
+
229
+ return filters, parameters
230
+
231
+ if isinstance(criteria, OrCriteria):
232
+ left_conditions, left_parameters = cls._process_filters_recursive(
233
+ criteria=criteria.left,
234
+ columns_mapping=columns_mapping,
235
+ parameters_counter=parameters_counter,
236
+ )
237
+ parameters_counter += len(left_parameters)
238
+ parameters.update(left_parameters)
239
+
240
+ right_conditions, right_parameters = cls._process_filters_recursive(
241
+ criteria=criteria.right,
242
+ columns_mapping=columns_mapping,
243
+ parameters_counter=parameters_counter,
244
+ )
245
+ parameters_counter += len(right_parameters)
246
+ parameters.update(right_parameters)
247
+
248
+ filters += f'({left_conditions} OR {right_conditions})'
249
+
250
+ return filters, parameters
251
+
252
+ if isinstance(criteria, NotCriteria):
253
+ not_conditions, not_parameters = cls._process_filters_recursive(
254
+ criteria=criteria.criteria,
255
+ columns_mapping=columns_mapping,
256
+ parameters_counter=parameters_counter,
257
+ )
258
+ parameters_counter += len(not_parameters)
259
+ parameters.update(not_parameters)
260
+
261
+ filters += f'NOT ({not_conditions})'
262
+
263
+ return filters, parameters
264
+
265
+ for filter in criteria.filters:
266
+ filter_field = columns_mapping.get(filter.field, filter.field)
267
+ parameter_name = f'parameter_{parameters_counter}'
268
+ parameters[parameter_name] = filter.value
269
+ placeholder = f'%({parameter_name})s'
270
+ parameters_counter += 1
271
+
272
+ match filter.operator:
273
+ case FilterOperator.EQUAL:
274
+ filters += f'{filter_field} = {placeholder}'
275
+
276
+ case FilterOperator.NOT_EQUAL:
277
+ filters += f'{filter_field} != {placeholder}'
278
+
279
+ case FilterOperator.GREATER:
280
+ filters += f'{filter_field} > {placeholder}'
281
+
282
+ case FilterOperator.GREATER_OR_EQUAL:
283
+ filters += f'{filter_field} >= {placeholder}'
284
+
285
+ case FilterOperator.LESS:
286
+ filters += f'{filter_field} < {placeholder}'
287
+
288
+ case FilterOperator.LESS_OR_EQUAL:
289
+ filters += f'{filter_field} <= {placeholder}'
290
+
291
+ case FilterOperator.LIKE:
292
+ filters += f'{filter_field} LIKE {placeholder}'
293
+
294
+ case FilterOperator.NOT_LIKE:
295
+ filters += f'{filter_field} NOT LIKE {placeholder}'
296
+
297
+ case FilterOperator.CONTAINS:
298
+ filters += f"{filter_field} LIKE '%%' || {placeholder} || '%%'"
299
+
300
+ case FilterOperator.NOT_CONTAINS:
301
+ filters += f"{filter_field} NOT LIKE '%%' || {placeholder} || '%%'"
302
+
303
+ case FilterOperator.STARTS_WITH:
304
+ filters += f"{filter_field} LIKE {placeholder} || '%%'"
305
+
306
+ case FilterOperator.NOT_STARTS_WITH:
307
+ filters += f"{filter_field} NOT LIKE {placeholder} || '%%'"
308
+
309
+ case FilterOperator.ENDS_WITH:
310
+ filters += f"{filter_field} LIKE '%%' || {placeholder}"
311
+
312
+ case FilterOperator.NOT_ENDS_WITH:
313
+ filters += f"{filter_field} NOT LIKE '%%' || {placeholder}"
314
+
315
+ case FilterOperator.BETWEEN:
316
+ parameters.pop(parameter_name)
317
+ parameters_counter -= 1
318
+
319
+ start_parameter_name = f'parameter_{parameters_counter}'
320
+ end_parameter_name = f'parameter_{parameters_counter + 1}'
321
+ parameters[start_parameter_name] = filter.value[0]
322
+ parameters[end_parameter_name] = filter.value[1]
323
+ start_placeholder = f'%({start_parameter_name})s'
324
+ end_placeholder = f'%({end_parameter_name})s'
325
+ parameters_counter += 2
326
+
327
+ filters += f'{filter_field} BETWEEN {start_placeholder} AND {end_placeholder}'
328
+
329
+ case FilterOperator.NOT_BETWEEN:
330
+ parameters.pop(parameter_name)
331
+ parameters_counter -= 1
332
+
333
+ start_parameter_name = f'parameter_{parameters_counter}'
334
+ end_parameter_name = f'parameter_{parameters_counter + 1}'
335
+ parameters[start_parameter_name] = filter.value[0]
336
+ parameters[end_parameter_name] = filter.value[1]
337
+ start_placeholder = f'%({start_parameter_name})s'
338
+ end_placeholder = f'%({end_parameter_name})s'
339
+ parameters_counter += 2
340
+
341
+ filters += f'{filter_field} NOT BETWEEN {start_placeholder} AND {end_placeholder}'
342
+
343
+ case FilterOperator.IS_NULL:
344
+ parameters.pop(parameter_name)
345
+ parameters_counter -= 1
346
+
347
+ filters += f'{filter_field} IS NULL'
348
+
349
+ case FilterOperator.IS_NOT_NULL:
350
+ parameters.pop(parameter_name)
351
+ parameters_counter -= 1
352
+
353
+ filters += f'{filter_field} IS NOT NULL'
354
+
355
+ case _: # pragma: no cover
356
+ assert_never(filter.operator)
357
+
358
+ return filters, parameters
359
+
360
+ @classmethod
361
+ def _process_orders(cls, criteria: Criteria, columns_mapping: Mapping[str, str]) -> str:
362
+ """
363
+ Process the Criteria object to return an SQL ORDER BY clause.
364
+
365
+ Args:
366
+ criteria (Criteria): Criteria to process.
367
+ columns_mapping (Mapping[str, str]): Mapping of column names to aliases.
368
+
369
+ Returns:
370
+ str: Processed order string for SQL ORDER BY clause.
371
+ """
372
+ orders = ''
373
+
374
+ for order in criteria.orders:
375
+ order_field = columns_mapping.get(order.field, order.field)
376
+
377
+ match order.direction:
378
+ case OrderDirection.ASC:
379
+ orders += f'{order_field} ASC, '
380
+
381
+ case OrderDirection.DESC:
382
+ orders += f'{order_field} DESC, '
383
+
384
+ case _: # pragma: no cover
385
+ assert_never(order.direction)
386
+
387
+ return orders.rstrip(', ')
@@ -0,0 +1,9 @@
1
+ from .invalid_column_error import InvalidColumnError
2
+ from .invalid_table_error import InvalidTableError
3
+ from .sql_converter_error import SqlConverterError
4
+
5
+ __all__ = (
6
+ 'InvalidColumnError',
7
+ 'InvalidTableError',
8
+ 'SqlConverterError',
9
+ )
@@ -0,0 +1,53 @@
1
+ """
2
+ Invalid column error exception.
3
+ """
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from .sql_converter_error import SqlConverterError
8
+
9
+
10
+ class InvalidColumnError(SqlConverterError):
11
+ """
12
+ Exception raised when an invalid column is specified in a SQL operation.
13
+
14
+ This exception is used to indicate that a column name provided by the user is not among the allowed valid columns,
15
+ helping to prevent SQL injection attacks and ensuring data integrity.
16
+ """
17
+
18
+ __column: str
19
+ __valid_columns: set[str]
20
+
21
+ def __init__(self, column: str, valid_columns: Sequence[str]) -> None:
22
+ """
23
+ Initialize the InvalidColumnError with the specified column and valid columns.
24
+
25
+ Args:
26
+ column (str): The name of the invalid column that caused the error.
27
+ valid_columns (Sequence[str]): A sequence of valid column names to reference.
28
+ """
29
+ self.__column = column
30
+ self.__valid_columns = set(valid_columns)
31
+
32
+ message = f'Invalid column specified: <<<{column}>>>. Valid columns are: <<<{", ".join(valid_columns)}>>>.'
33
+ super().__init__(message)
34
+
35
+ @property
36
+ def column(self) -> str:
37
+ """
38
+ Get the name of the invalid column that caused the error.
39
+
40
+ Returns:
41
+ str: The name of the column that was invalid.
42
+ """
43
+ return self.__column # pragma: no cover
44
+
45
+ @property
46
+ def valid_columns(self) -> set[str]:
47
+ """
48
+ Get the list of valid columns that can be referenced.
49
+
50
+ Returns:
51
+ set[str]: A set of valid column names.
52
+ """
53
+ return self.__valid_columns # pragma: no cover
@@ -0,0 +1,53 @@
1
+ """
2
+ Invalid table error exception.
3
+ """
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from .sql_converter_error import SqlConverterError
8
+
9
+
10
+ class InvalidTableError(SqlConverterError):
11
+ """
12
+ Exception raised when an invalid table is specified in a SQL operation.
13
+
14
+ This exception is used to indicate that a table name provided by the user is not among the allowed valid tables,
15
+ helping to prevent SQL injection attacks.
16
+ """
17
+
18
+ __table: str
19
+ __valid_tables: set[str]
20
+
21
+ def __init__(self, table: str, valid_tables: Sequence[str]) -> None:
22
+ """
23
+ Initialize the InvalidTableError with the specified table and valid tables.
24
+
25
+ Args:
26
+ table (str): The name of the invalid table that caused the error.
27
+ valid_tables (Sequence[str]): A sequence of valid table names to reference.
28
+ """
29
+ self.__table = table
30
+ self.__valid_tables = set(valid_tables)
31
+
32
+ message = f'Invalid table specified: <<<{table}>>>. Valid tables are: <<<{", ".join(valid_tables)}>>>.'
33
+ super().__init__(message)
34
+
35
+ @property
36
+ def table(self) -> str:
37
+ """
38
+ Get the name of the invalid table that caused the error.
39
+
40
+ Returns:
41
+ str: The name of the table that was invalid.
42
+ """
43
+ return self.__table # pragma: no cover
44
+
45
+ @property
46
+ def valid_tables(self) -> set[str]:
47
+ """
48
+ Get the list of valid tables that can be referenced.
49
+
50
+ Returns:
51
+ set[str]: A set of valid table names.
52
+ """
53
+ return self.__valid_tables # pragma: no cover
@@ -0,0 +1,26 @@
1
+ """
2
+ SQL converter base exception.
3
+ """
4
+
5
+
6
+ class SqlConverterError(Exception):
7
+ """
8
+ SQL converter base exception.
9
+ """
10
+
11
+ __message: str
12
+
13
+ def __init__(self, message: str) -> None:
14
+ """
15
+ SQL converter base exception constructor.
16
+ """
17
+ self.__message = message
18
+
19
+ super().__init__(message)
20
+
21
+ @property
22
+ def message(self) -> str:
23
+ """
24
+ Get the exception message.
25
+ """
26
+ return self.__message # pragma: no cover
@@ -0,0 +1,13 @@
1
+ from .criteria import Criteria
2
+ from .filter import Filter
3
+ from .filter_operator import FilterOperator
4
+ from .order import Order
5
+ from .order_direction import OrderDirection
6
+
7
+ __all__ = (
8
+ 'Criteria',
9
+ 'Filter',
10
+ 'FilterOperator',
11
+ 'Order',
12
+ 'OrderDirection',
13
+ )