sqliter-py 0.3.0__py3-none-any.whl → 0.4.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 sqliter-py might be problematic. Click here for more details.

sqliter/query/query.py CHANGED
@@ -1,4 +1,11 @@
1
- """Define the 'QueryBuilder' class for building SQL queries."""
1
+ """Implements the query building and execution logic for SQLiter.
2
+
3
+ This module defines the QueryBuilder class, which provides a fluent
4
+ interface for constructing SQL queries. It supports operations such
5
+ as filtering, ordering, limiting, and various data retrieval methods,
6
+ allowing for flexible and expressive database queries without writing
7
+ raw SQL.
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
@@ -37,7 +44,21 @@ FilterValue = Union[
37
44
 
38
45
 
39
46
  class QueryBuilder:
40
- """Functions to build and execute queries for a given model."""
47
+ """Builds and executes database queries for a specific model.
48
+
49
+ This class provides methods to construct SQL queries, apply filters,
50
+ set ordering, and execute the queries against the database.
51
+
52
+ Attributes:
53
+ db (SqliterDB): The database connection object.
54
+ model_class (type[BaseDBModel]): The Pydantic model class.
55
+ table_name (str): The name of the database table.
56
+ filters (list): List of applied filter conditions.
57
+ _limit (Optional[int]): The LIMIT clause value, if any.
58
+ _offset (Optional[int]): The OFFSET clause value, if any.
59
+ _order_by (Optional[str]): The ORDER BY clause, if any.
60
+ _fields (Optional[list[str]]): List of fields to select, if specified.
61
+ """
41
62
 
42
63
  def __init__(
43
64
  self,
@@ -45,13 +66,11 @@ class QueryBuilder:
45
66
  model_class: type[BaseDBModel],
46
67
  fields: Optional[list[str]] = None,
47
68
  ) -> None:
48
- """Initialize the query builder.
49
-
50
- Pass the database, model class, and optional fields.
69
+ """Initialize a new QueryBuilder instance.
51
70
 
52
71
  Args:
53
- db: The SqliterDB instance.
54
- model_class: The model class to query.
72
+ db: The database connection object.
73
+ model_class: The Pydantic model class for the table.
55
74
  fields: Optional list of field names to select. If None, all fields
56
75
  are selected.
57
76
  """
@@ -68,7 +87,11 @@ class QueryBuilder:
68
87
  self._validate_fields()
69
88
 
70
89
  def _validate_fields(self) -> None:
71
- """Validate that the specified fields exist in the model."""
90
+ """Validate that the specified fields exist in the model.
91
+
92
+ Raises:
93
+ ValueError: If any specified field is not in the model.
94
+ """
72
95
  if self._fields is None:
73
96
  return
74
97
  valid_fields = set(self.model_class.model_fields.keys())
@@ -80,7 +103,26 @@ class QueryBuilder:
80
103
  raise ValueError(err_message)
81
104
 
82
105
  def filter(self, **conditions: str | float | None) -> QueryBuilder:
83
- """Add filter conditions to the query."""
106
+ """Apply filter conditions to the query.
107
+
108
+ This method allows adding one or more filter conditions to the query.
109
+ Each condition is specified as a keyword argument, where the key is
110
+ the field name and the value is the condition to apply.
111
+
112
+ Args:
113
+ **conditions: Arbitrary keyword arguments representing filter
114
+ conditions. The key is the field name, and the value is the
115
+ condition to apply. Supported operators include equality,
116
+ comparison, and special operators like __in, __isnull, etc.
117
+
118
+ Returns:
119
+ QueryBuilder: The current QueryBuilder instance for method
120
+ chaining.
121
+
122
+ Examples:
123
+ >>> query.filter(name="John", age__gt=30)
124
+ >>> query.filter(status__in=["active", "pending"])
125
+ """
84
126
  valid_fields = self.model_class.model_fields
85
127
 
86
128
  for field, value in conditions.items():
@@ -93,14 +135,34 @@ class QueryBuilder:
93
135
  return self
94
136
 
95
137
  def fields(self, fields: Optional[list[str]] = None) -> QueryBuilder:
96
- """Select specific fields to return in the query."""
138
+ """Specify which fields to select in the query.
139
+
140
+ Args:
141
+ fields: List of field names to select. If None, all fields are
142
+ selected.
143
+
144
+ Returns:
145
+ The QueryBuilder instance for method chaining.
146
+ """
97
147
  if fields:
98
148
  self._fields = fields
99
149
  self._validate_fields()
100
150
  return self
101
151
 
102
152
  def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder:
103
- """Exclude specific fields from the query output."""
153
+ """Specify which fields to exclude from the query results.
154
+
155
+ Args:
156
+ fields: List of field names to exclude. If None, no fields are
157
+ excluded.
158
+
159
+ Returns:
160
+ The QueryBuilder instance for method chaining.
161
+
162
+ Raises:
163
+ ValueError: If exclusion results in no fields being selected or if
164
+ invalid fields are specified.
165
+ """
104
166
  if fields:
105
167
  all_fields = set(self.model_class.model_fields.keys())
106
168
 
@@ -127,7 +189,17 @@ class QueryBuilder:
127
189
  return self
128
190
 
129
191
  def only(self, field: str) -> QueryBuilder:
130
- """Return only the specified single field."""
192
+ """Specify a single field to select in the query.
193
+
194
+ Args:
195
+ field: The name of the field to select.
196
+
197
+ Returns:
198
+ The QueryBuilder instance for method chaining.
199
+
200
+ Raises:
201
+ ValueError: If the specified field is invalid.
202
+ """
131
203
  all_fields = set(self.model_class.model_fields.keys())
132
204
 
133
205
  # Validate that the field exists
@@ -142,6 +214,14 @@ class QueryBuilder:
142
214
  def _get_operator_handler(
143
215
  self, operator: str
144
216
  ) -> Callable[[str, Any, str], None]:
217
+ """Get the appropriate handler function for the given operator.
218
+
219
+ Args:
220
+ operator: The filter operator string.
221
+
222
+ Returns:
223
+ A callable that handles the specific operator type.
224
+ """
145
225
  handlers = {
146
226
  "__isnull": self._handle_null,
147
227
  "__notnull": self._handle_null,
@@ -164,12 +244,31 @@ class QueryBuilder:
164
244
  def _validate_field(
165
245
  self, field_name: str, valid_fields: dict[str, FieldInfo]
166
246
  ) -> None:
247
+ """Validate that a field exists in the model.
248
+
249
+ Args:
250
+ field_name: The name of the field to validate.
251
+ valid_fields: Dictionary of valid fields from the model.
252
+
253
+ Raises:
254
+ InvalidFilterError: If the field is not in the model.
255
+ """
167
256
  if field_name not in valid_fields:
168
257
  raise InvalidFilterError(field_name)
169
258
 
170
259
  def _handle_equality(
171
260
  self, field_name: str, value: FilterValue, operator: str
172
261
  ) -> None:
262
+ """Handle equality filter conditions.
263
+
264
+ Args:
265
+ field_name: The name of the field to filter on.
266
+ value: The value to compare against.
267
+ operator: The operator string (usually '__eq').
268
+
269
+ This method adds an equality condition to the filters list, handling
270
+ NULL values separately.
271
+ """
173
272
  if value is None:
174
273
  self.filters.append((f"{field_name} IS NULL", None, "__isnull"))
175
274
  else:
@@ -178,6 +277,16 @@ class QueryBuilder:
178
277
  def _handle_null(
179
278
  self, field_name: str, _: FilterValue, operator: str
180
279
  ) -> None:
280
+ """Handle IS NULL and IS NOT NULL filter conditions.
281
+
282
+ Args:
283
+ field_name: The name of the field to filter on. _: Placeholder for
284
+ unused value parameter.
285
+ operator: The operator string ('__isnull' or '__notnull').
286
+
287
+ This method adds an IS NULL or IS NOT NULL condition to the filters
288
+ list.
289
+ """
181
290
  condition = (
182
291
  f"{field_name} IS NOT NULL"
183
292
  if operator == "__notnull"
@@ -188,6 +297,18 @@ class QueryBuilder:
188
297
  def _handle_in(
189
298
  self, field_name: str, value: FilterValue, operator: str
190
299
  ) -> None:
300
+ """Handle IN and NOT IN filter conditions.
301
+
302
+ Args:
303
+ field_name: The name of the field to filter on.
304
+ value: A list of values to check against.
305
+ operator: The operator string ('__in' or '__not_in').
306
+
307
+ Raises:
308
+ TypeError: If the value is not a list.
309
+
310
+ This method adds an IN or NOT IN condition to the filters list.
311
+ """
191
312
  if not isinstance(value, list):
192
313
  err = f"{field_name} requires a list for '{operator}'"
193
314
  raise TypeError(err)
@@ -204,6 +325,19 @@ class QueryBuilder:
204
325
  def _handle_like(
205
326
  self, field_name: str, value: FilterValue, operator: str
206
327
  ) -> None:
328
+ """Handle LIKE and GLOB filter conditions.
329
+
330
+ Args:
331
+ field_name: The name of the field to filter on.
332
+ value: The pattern to match against.
333
+ operator: The operator string (e.g., '__startswith', '__contains').
334
+
335
+ Raises:
336
+ TypeError: If the value is not a string.
337
+
338
+ This method adds a LIKE or GLOB condition to the filters list, depending
339
+ on whether the operation is case-sensitive or not.
340
+ """
207
341
  if not isinstance(value, str):
208
342
  err = f"{field_name} requires a string value for '{operator}'"
209
343
  raise TypeError(err)
@@ -228,11 +362,29 @@ class QueryBuilder:
228
362
  def _handle_comparison(
229
363
  self, field_name: str, value: FilterValue, operator: str
230
364
  ) -> None:
365
+ """Handle comparison filter conditions.
366
+
367
+ Args:
368
+ field_name: The name of the field to filter on.
369
+ value: The value to compare against.
370
+ operator: The comparison operator string (e.g., '__lt', '__gte').
371
+
372
+ This method adds a comparison condition to the filters list.
373
+ """
231
374
  sql_operator = OPERATOR_MAPPING[operator]
232
375
  self.filters.append((f"{field_name} {sql_operator} ?", value, operator))
233
376
 
234
377
  # Helper method for parsing field and operator
235
378
  def _parse_field_operator(self, field: str) -> tuple[str, str]:
379
+ """Parse a field string to separate the field name and operator.
380
+
381
+ Args:
382
+ field: The field string, potentially including an operator.
383
+
384
+ Returns:
385
+ A tuple containing the field name and the operator (or '__eq' if
386
+ no operator was specified).
387
+ """
236
388
  for operator in OPERATOR_MAPPING:
237
389
  if field.endswith(operator):
238
390
  return field[: -len(operator)], operator
@@ -240,7 +392,15 @@ class QueryBuilder:
240
392
 
241
393
  # Helper method for formatting string operators (like startswith)
242
394
  def _format_string_for_operator(self, operator: str, value: str) -> str:
243
- # Mapping operators to their corresponding string format
395
+ """Format a string value based on the specified operator.
396
+
397
+ Args:
398
+ operator: The operator string (e.g., '__startswith', '__contains').
399
+ value: The original string value.
400
+
401
+ Returns:
402
+ The formatted string value suitable for the given operator.
403
+ """
244
404
  format_map = {
245
405
  "__startswith": f"{value}*",
246
406
  "__endswith": f"*{value}",
@@ -254,12 +414,29 @@ class QueryBuilder:
254
414
  return format_map.get(operator, value)
255
415
 
256
416
  def limit(self, limit_value: int) -> Self:
257
- """Limit the number of results returned by the query."""
417
+ """Limit the number of results returned by the query.
418
+
419
+ Args:
420
+ limit_value: The maximum number of records to return.
421
+
422
+ Returns:
423
+ The QueryBuilder instance for method chaining.
424
+ """
258
425
  self._limit = limit_value
259
426
  return self
260
427
 
261
428
  def offset(self, offset_value: int) -> Self:
262
- """Set an offset value for the query."""
429
+ """Set an offset value for the query.
430
+
431
+ Args:
432
+ offset_value: The number of records to skip.
433
+
434
+ Returns:
435
+ The QueryBuilder instance for method chaining.
436
+
437
+ Raises:
438
+ InvalidOffsetError: If the offset value is negative.
439
+ """
263
440
  if offset_value < 0:
264
441
  raise InvalidOffsetError(offset_value)
265
442
  self._offset = offset_value
@@ -270,7 +447,7 @@ class QueryBuilder:
270
447
 
271
448
  def order(
272
449
  self,
273
- order_by_field: str,
450
+ order_by_field: Optional[str] = None,
274
451
  direction: Optional[str] = None,
275
452
  *,
276
453
  reverse: bool = False,
@@ -278,19 +455,19 @@ class QueryBuilder:
278
455
  """Order the query results by the specified field.
279
456
 
280
457
  Args:
281
- order_by_field (str): The field to order by.
282
- direction (Optional[str]): The ordering direction ('ASC' or 'DESC').
283
- This is deprecated in favor of 'reverse'.
284
- reverse (bool): Whether to reverse the order (True for descending,
285
- False for ascending).
458
+ order_by_field: The field to order by [optional].
459
+ direction: Deprecated. Use 'reverse' instead.
460
+ reverse: If True, sort in descending order.
461
+
462
+ Returns:
463
+ The QueryBuilder instance for method chaining.
286
464
 
287
465
  Raises:
288
- InvalidOrderError: If the field doesn't exist in the model fields
289
- or if both 'direction' and 'reverse' are specified.
466
+ InvalidOrderError: If the field doesn't exist or if both 'direction'
467
+ and 'reverse' are specified.
290
468
 
291
- Returns:
292
- QueryBuilder: The current query builder instance with updated
293
- ordering.
469
+ Warns:
470
+ DeprecationWarning: If 'direction' is used instead of 'reverse'.
294
471
  """
295
472
  if direction:
296
473
  warnings.warn(
@@ -300,6 +477,9 @@ class QueryBuilder:
300
477
  stacklevel=2,
301
478
  )
302
479
 
480
+ if order_by_field is None:
481
+ order_by_field = self.model_class.get_primary_key()
482
+
303
483
  if order_by_field not in self.model_class.model_fields:
304
484
  err = f"'{order_by_field}' does not exist in the model fields."
305
485
  raise InvalidOrderError(err)
@@ -331,7 +511,19 @@ class QueryBuilder:
331
511
  fetch_one: bool = False,
332
512
  count_only: bool = False,
333
513
  ) -> list[tuple[Any, ...]] | Optional[tuple[Any, ...]]:
334
- """Helper function to execute the query with filters."""
514
+ """Execute the constructed SQL query.
515
+
516
+ Args:
517
+ fetch_one: If True, fetch only one result.
518
+ count_only: If True, return only the count of results.
519
+
520
+ Returns:
521
+ A list of tuples (all results), a single tuple (one result),
522
+ or None if no results are found.
523
+
524
+ Raises:
525
+ RecordFetchError: If there's an error executing the query.
526
+ """
335
527
  if count_only:
336
528
  fields = "COUNT(*)"
337
529
  elif self._fields:
@@ -360,6 +552,11 @@ class QueryBuilder:
360
552
  sql += " OFFSET ?"
361
553
  values.append(self._offset)
362
554
 
555
+ # Print the raw SQL and values if debug is enabled
556
+ # Log the SQL if debug is enabled
557
+ if self.db.debug:
558
+ self.db._log_sql(sql, values) # noqa: SLF001
559
+
363
560
  try:
364
561
  with self.db.connect() as conn:
365
562
  cursor = conn.cursor()
@@ -369,7 +566,13 @@ class QueryBuilder:
369
566
  raise RecordFetchError(self.table_name) from exc
370
567
 
371
568
  def _parse_filter(self) -> tuple[list[Any], LiteralString]:
372
- """Actually parse the filters."""
569
+ """Parse the filter conditions into SQL clauses and values.
570
+
571
+ Returns:
572
+ A tuple containing:
573
+ - A list of values to be used in the SQL query.
574
+ - A string representing the WHERE clause of the SQL query.
575
+ """
373
576
  where_clauses = []
374
577
  values = []
375
578
  for field, value, operator in self.filters:
@@ -388,7 +591,14 @@ class QueryBuilder:
388
591
  return values, where_clause
389
592
 
390
593
  def _convert_row_to_model(self, row: tuple[Any, ...]) -> BaseDBModel:
391
- """Convert a result row tuple into a Pydantic model."""
594
+ """Convert a database row to a model instance.
595
+
596
+ Args:
597
+ row: A tuple representing a database row.
598
+
599
+ Returns:
600
+ An instance of the model class populated with the row data.
601
+ """
392
602
  if self._fields:
393
603
  return self.model_class.model_validate_partial(
394
604
  {field: row[idx] for idx, field in enumerate(self._fields)}
@@ -413,7 +623,15 @@ class QueryBuilder:
413
623
  def _fetch_result(
414
624
  self, *, fetch_one: bool = False
415
625
  ) -> Union[list[BaseDBModel], Optional[BaseDBModel]]:
416
- """Fetch one or all results and convert them to Pydantic models."""
626
+ """Fetch and convert query results to model instances.
627
+
628
+ Args:
629
+ fetch_one: If True, fetch only one result.
630
+
631
+ Returns:
632
+ A list of model instances, a single model instance, or None if no
633
+ results are found.
634
+ """
417
635
  result = self._execute_query(fetch_one=fetch_one)
418
636
 
419
637
  if not result:
@@ -432,30 +650,54 @@ class QueryBuilder:
432
650
  return [self._convert_row_to_model(row) for row in result]
433
651
 
434
652
  def fetch_all(self) -> list[BaseDBModel]:
435
- """Fetch all results matching the filters."""
653
+ """Fetch all results of the query.
654
+
655
+ Returns:
656
+ A list of model instances representing all query results.
657
+ """
436
658
  return self._fetch_result(fetch_one=False)
437
659
 
438
660
  def fetch_one(self) -> Optional[BaseDBModel]:
439
- """Fetch exactly one result."""
661
+ """Fetch a single result of the query.
662
+
663
+ Returns:
664
+ A single model instance or None if no result is found.
665
+ """
440
666
  return self._fetch_result(fetch_one=True)
441
667
 
442
668
  def fetch_first(self) -> Optional[BaseDBModel]:
443
- """Fetch the first result of the query."""
669
+ """Fetch the first result of the query.
670
+
671
+ Returns:
672
+ The first model instance or None if no result is found.
673
+ """
444
674
  self._limit = 1
445
675
  return self._fetch_result(fetch_one=True)
446
676
 
447
677
  def fetch_last(self) -> Optional[BaseDBModel]:
448
- """Fetch the last result of the query (based on the insertion order)."""
678
+ """Fetch the last result of the query.
679
+
680
+ Returns:
681
+ The last model instance or None if no result is found.
682
+ """
449
683
  self._limit = 1
450
684
  self._order_by = "rowid DESC"
451
685
  return self._fetch_result(fetch_one=True)
452
686
 
453
687
  def count(self) -> int:
454
- """Return the count of records matching the filters."""
688
+ """Count the number of results for the current query.
689
+
690
+ Returns:
691
+ The number of results that match the current query conditions.
692
+ """
455
693
  result = self._execute_query(count_only=True)
456
694
 
457
695
  return int(result[0][0]) if result else 0
458
696
 
459
697
  def exists(self) -> bool:
460
- """Return True if any record matches the filters."""
698
+ """Check if any results exist for the current query.
699
+
700
+ Returns:
701
+ True if at least one result exists, False otherwise.
702
+ """
461
703
  return self.count() > 0