sqliter-py 0.3.0__py3-none-any.whl → 0.5.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.
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,28 +103,75 @@ 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():
87
129
  field_name, operator = self._parse_field_operator(field)
88
130
  self._validate_field(field_name, valid_fields)
89
131
 
90
- handler = self._get_operator_handler(operator)
91
- handler(field_name, value, operator)
132
+ if operator in ["__isnull", "__notnull"]:
133
+ self._handle_null(field_name, value, operator)
134
+ else:
135
+ handler = self._get_operator_handler(operator)
136
+ handler(field_name, value, operator)
92
137
 
93
138
  return self
94
139
 
95
140
  def fields(self, fields: Optional[list[str]] = None) -> QueryBuilder:
96
- """Select specific fields to return in the query."""
141
+ """Specify which fields to select in the query.
142
+
143
+ Args:
144
+ fields: List of field names to select. If None, all fields are
145
+ selected.
146
+
147
+ Returns:
148
+ The QueryBuilder instance for method chaining.
149
+ """
97
150
  if fields:
151
+ if "pk" not in fields:
152
+ fields.append("pk")
98
153
  self._fields = fields
99
154
  self._validate_fields()
100
155
  return self
101
156
 
102
157
  def exclude(self, fields: Optional[list[str]] = None) -> QueryBuilder:
103
- """Exclude specific fields from the query output."""
158
+ """Specify which fields to exclude from the query results.
159
+
160
+ Args:
161
+ fields: List of field names to exclude. If None, no fields are
162
+ excluded.
163
+
164
+ Returns:
165
+ The QueryBuilder instance for method chaining.
166
+
167
+ Raises:
168
+ ValueError: If exclusion results in no fields being selected or if
169
+ invalid fields are specified.
170
+ """
104
171
  if fields:
172
+ if "pk" in fields:
173
+ err = "The primary key 'pk' cannot be excluded."
174
+ raise ValueError(err)
105
175
  all_fields = set(self.model_class.model_fields.keys())
106
176
 
107
177
  # Check for invalid fields before subtraction
@@ -117,7 +187,7 @@ class QueryBuilder:
117
187
  self._fields = list(all_fields - set(fields))
118
188
 
119
189
  # Explicit check: raise an error if no fields remain
120
- if not self._fields:
190
+ if self._fields == ["pk"]:
121
191
  err = "Exclusion results in no fields being selected."
122
192
  raise ValueError(err)
123
193
 
@@ -127,7 +197,17 @@ class QueryBuilder:
127
197
  return self
128
198
 
129
199
  def only(self, field: str) -> QueryBuilder:
130
- """Return only the specified single field."""
200
+ """Specify a single field to select in the query.
201
+
202
+ Args:
203
+ field: The name of the field to select.
204
+
205
+ Returns:
206
+ The QueryBuilder instance for method chaining.
207
+
208
+ Raises:
209
+ ValueError: If the specified field is invalid.
210
+ """
131
211
  all_fields = set(self.model_class.model_fields.keys())
132
212
 
133
213
  # Validate that the field exists
@@ -136,12 +216,20 @@ class QueryBuilder:
136
216
  raise ValueError(err)
137
217
 
138
218
  # Set self._fields to just the single field
139
- self._fields = [field]
219
+ self._fields = [field, "pk"]
140
220
  return self
141
221
 
142
222
  def _get_operator_handler(
143
223
  self, operator: str
144
224
  ) -> Callable[[str, Any, str], None]:
225
+ """Get the appropriate handler function for the given operator.
226
+
227
+ Args:
228
+ operator: The filter operator string.
229
+
230
+ Returns:
231
+ A callable that handles the specific operator type.
232
+ """
145
233
  handlers = {
146
234
  "__isnull": self._handle_null,
147
235
  "__notnull": self._handle_null,
@@ -164,30 +252,70 @@ class QueryBuilder:
164
252
  def _validate_field(
165
253
  self, field_name: str, valid_fields: dict[str, FieldInfo]
166
254
  ) -> None:
255
+ """Validate that a field exists in the model.
256
+
257
+ Args:
258
+ field_name: The name of the field to validate.
259
+ valid_fields: Dictionary of valid fields from the model.
260
+
261
+ Raises:
262
+ InvalidFilterError: If the field is not in the model.
263
+ """
167
264
  if field_name not in valid_fields:
168
265
  raise InvalidFilterError(field_name)
169
266
 
170
267
  def _handle_equality(
171
268
  self, field_name: str, value: FilterValue, operator: str
172
269
  ) -> None:
270
+ """Handle equality filter conditions.
271
+
272
+ Args:
273
+ field_name: The name of the field to filter on.
274
+ value: The value to compare against.
275
+ operator: The operator string (usually '__eq').
276
+
277
+ This method adds an equality condition to the filters list, handling
278
+ NULL values separately.
279
+ """
173
280
  if value is None:
174
281
  self.filters.append((f"{field_name} IS NULL", None, "__isnull"))
175
282
  else:
176
283
  self.filters.append((field_name, value, operator))
177
284
 
178
285
  def _handle_null(
179
- self, field_name: str, _: FilterValue, operator: str
286
+ self, field_name: str, value: Union[str, float, None], operator: str
180
287
  ) -> None:
181
- condition = (
182
- f"{field_name} IS NOT NULL"
183
- if operator == "__notnull"
184
- else f"{field_name} IS NULL"
185
- )
288
+ """Handle IS NULL and IS NOT NULL filter conditions.
289
+
290
+ Args:
291
+ field_name: The name of the field to filter on. _: Placeholder for
292
+ unused value parameter.
293
+ operator: The operator string ('__isnull' or '__notnull').
294
+ value: The value to check for.
295
+
296
+ This method adds an IS NULL or IS NOT NULL condition to the filters
297
+ list.
298
+ """
299
+ is_null = operator == "__isnull"
300
+ check_null = bool(value) if is_null else not bool(value)
301
+ condition = f"{field_name} IS {'NOT ' if not check_null else ''}NULL"
186
302
  self.filters.append((condition, None, operator))
187
303
 
188
304
  def _handle_in(
189
305
  self, field_name: str, value: FilterValue, operator: str
190
306
  ) -> None:
307
+ """Handle IN and NOT IN filter conditions.
308
+
309
+ Args:
310
+ field_name: The name of the field to filter on.
311
+ value: A list of values to check against.
312
+ operator: The operator string ('__in' or '__not_in').
313
+
314
+ Raises:
315
+ TypeError: If the value is not a list.
316
+
317
+ This method adds an IN or NOT IN condition to the filters list.
318
+ """
191
319
  if not isinstance(value, list):
192
320
  err = f"{field_name} requires a list for '{operator}'"
193
321
  raise TypeError(err)
@@ -204,6 +332,19 @@ class QueryBuilder:
204
332
  def _handle_like(
205
333
  self, field_name: str, value: FilterValue, operator: str
206
334
  ) -> None:
335
+ """Handle LIKE and GLOB filter conditions.
336
+
337
+ Args:
338
+ field_name: The name of the field to filter on.
339
+ value: The pattern to match against.
340
+ operator: The operator string (e.g., '__startswith', '__contains').
341
+
342
+ Raises:
343
+ TypeError: If the value is not a string.
344
+
345
+ This method adds a LIKE or GLOB condition to the filters list, depending
346
+ on whether the operation is case-sensitive or not.
347
+ """
207
348
  if not isinstance(value, str):
208
349
  err = f"{field_name} requires a string value for '{operator}'"
209
350
  raise TypeError(err)
@@ -228,11 +369,29 @@ class QueryBuilder:
228
369
  def _handle_comparison(
229
370
  self, field_name: str, value: FilterValue, operator: str
230
371
  ) -> None:
372
+ """Handle comparison filter conditions.
373
+
374
+ Args:
375
+ field_name: The name of the field to filter on.
376
+ value: The value to compare against.
377
+ operator: The comparison operator string (e.g., '__lt', '__gte').
378
+
379
+ This method adds a comparison condition to the filters list.
380
+ """
231
381
  sql_operator = OPERATOR_MAPPING[operator]
232
382
  self.filters.append((f"{field_name} {sql_operator} ?", value, operator))
233
383
 
234
384
  # Helper method for parsing field and operator
235
385
  def _parse_field_operator(self, field: str) -> tuple[str, str]:
386
+ """Parse a field string to separate the field name and operator.
387
+
388
+ Args:
389
+ field: The field string, potentially including an operator.
390
+
391
+ Returns:
392
+ A tuple containing the field name and the operator (or '__eq' if
393
+ no operator was specified).
394
+ """
236
395
  for operator in OPERATOR_MAPPING:
237
396
  if field.endswith(operator):
238
397
  return field[: -len(operator)], operator
@@ -240,7 +399,15 @@ class QueryBuilder:
240
399
 
241
400
  # Helper method for formatting string operators (like startswith)
242
401
  def _format_string_for_operator(self, operator: str, value: str) -> str:
243
- # Mapping operators to their corresponding string format
402
+ """Format a string value based on the specified operator.
403
+
404
+ Args:
405
+ operator: The operator string (e.g., '__startswith', '__contains').
406
+ value: The original string value.
407
+
408
+ Returns:
409
+ The formatted string value suitable for the given operator.
410
+ """
244
411
  format_map = {
245
412
  "__startswith": f"{value}*",
246
413
  "__endswith": f"*{value}",
@@ -254,12 +421,29 @@ class QueryBuilder:
254
421
  return format_map.get(operator, value)
255
422
 
256
423
  def limit(self, limit_value: int) -> Self:
257
- """Limit the number of results returned by the query."""
424
+ """Limit the number of results returned by the query.
425
+
426
+ Args:
427
+ limit_value: The maximum number of records to return.
428
+
429
+ Returns:
430
+ The QueryBuilder instance for method chaining.
431
+ """
258
432
  self._limit = limit_value
259
433
  return self
260
434
 
261
435
  def offset(self, offset_value: int) -> Self:
262
- """Set an offset value for the query."""
436
+ """Set an offset value for the query.
437
+
438
+ Args:
439
+ offset_value: The number of records to skip.
440
+
441
+ Returns:
442
+ The QueryBuilder instance for method chaining.
443
+
444
+ Raises:
445
+ InvalidOffsetError: If the offset value is negative.
446
+ """
263
447
  if offset_value < 0:
264
448
  raise InvalidOffsetError(offset_value)
265
449
  self._offset = offset_value
@@ -270,7 +454,7 @@ class QueryBuilder:
270
454
 
271
455
  def order(
272
456
  self,
273
- order_by_field: str,
457
+ order_by_field: Optional[str] = None,
274
458
  direction: Optional[str] = None,
275
459
  *,
276
460
  reverse: bool = False,
@@ -278,19 +462,19 @@ class QueryBuilder:
278
462
  """Order the query results by the specified field.
279
463
 
280
464
  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).
465
+ order_by_field: The field to order by [optional].
466
+ direction: Deprecated. Use 'reverse' instead.
467
+ reverse: If True, sort in descending order.
468
+
469
+ Returns:
470
+ The QueryBuilder instance for method chaining.
286
471
 
287
472
  Raises:
288
- InvalidOrderError: If the field doesn't exist in the model fields
289
- or if both 'direction' and 'reverse' are specified.
473
+ InvalidOrderError: If the field doesn't exist or if both 'direction'
474
+ and 'reverse' are specified.
290
475
 
291
- Returns:
292
- QueryBuilder: The current query builder instance with updated
293
- ordering.
476
+ Warns:
477
+ DeprecationWarning: If 'direction' is used instead of 'reverse'.
294
478
  """
295
479
  if direction:
296
480
  warnings.warn(
@@ -300,6 +484,9 @@ class QueryBuilder:
300
484
  stacklevel=2,
301
485
  )
302
486
 
487
+ if order_by_field is None:
488
+ order_by_field = self.model_class.get_primary_key()
489
+
303
490
  if order_by_field not in self.model_class.model_fields:
304
491
  err = f"'{order_by_field}' does not exist in the model fields."
305
492
  raise InvalidOrderError(err)
@@ -331,10 +518,24 @@ class QueryBuilder:
331
518
  fetch_one: bool = False,
332
519
  count_only: bool = False,
333
520
  ) -> list[tuple[Any, ...]] | Optional[tuple[Any, ...]]:
334
- """Helper function to execute the query with filters."""
521
+ """Execute the constructed SQL query.
522
+
523
+ Args:
524
+ fetch_one: If True, fetch only one result.
525
+ count_only: If True, return only the count of results.
526
+
527
+ Returns:
528
+ A list of tuples (all results), a single tuple (one result),
529
+ or None if no results are found.
530
+
531
+ Raises:
532
+ RecordFetchError: If there's an error executing the query.
533
+ """
335
534
  if count_only:
336
535
  fields = "COUNT(*)"
337
536
  elif self._fields:
537
+ if "pk" not in self._fields:
538
+ self._fields.append("pk")
338
539
  fields = ", ".join(f'"{field}"' for field in self._fields)
339
540
  else:
340
541
  fields = ", ".join(
@@ -360,6 +561,11 @@ class QueryBuilder:
360
561
  sql += " OFFSET ?"
361
562
  values.append(self._offset)
362
563
 
564
+ # Print the raw SQL and values if debug is enabled
565
+ # Log the SQL if debug is enabled
566
+ if self.db.debug:
567
+ self.db._log_sql(sql, values) # noqa: SLF001
568
+
363
569
  try:
364
570
  with self.db.connect() as conn:
365
571
  cursor = conn.cursor()
@@ -369,7 +575,13 @@ class QueryBuilder:
369
575
  raise RecordFetchError(self.table_name) from exc
370
576
 
371
577
  def _parse_filter(self) -> tuple[list[Any], LiteralString]:
372
- """Actually parse the filters."""
578
+ """Parse the filter conditions into SQL clauses and values.
579
+
580
+ Returns:
581
+ A tuple containing:
582
+ - A list of values to be used in the SQL query.
583
+ - A string representing the WHERE clause of the SQL query.
584
+ """
373
585
  where_clauses = []
374
586
  values = []
375
587
  for field, value, operator in self.filters:
@@ -388,7 +600,14 @@ class QueryBuilder:
388
600
  return values, where_clause
389
601
 
390
602
  def _convert_row_to_model(self, row: tuple[Any, ...]) -> BaseDBModel:
391
- """Convert a result row tuple into a Pydantic model."""
603
+ """Convert a database row to a model instance.
604
+
605
+ Args:
606
+ row: A tuple representing a database row.
607
+
608
+ Returns:
609
+ An instance of the model class populated with the row data.
610
+ """
392
611
  if self._fields:
393
612
  return self.model_class.model_validate_partial(
394
613
  {field: row[idx] for idx, field in enumerate(self._fields)}
@@ -413,7 +632,15 @@ class QueryBuilder:
413
632
  def _fetch_result(
414
633
  self, *, fetch_one: bool = False
415
634
  ) -> Union[list[BaseDBModel], Optional[BaseDBModel]]:
416
- """Fetch one or all results and convert them to Pydantic models."""
635
+ """Fetch and convert query results to model instances.
636
+
637
+ Args:
638
+ fetch_one: If True, fetch only one result.
639
+
640
+ Returns:
641
+ A list of model instances, a single model instance, or None if no
642
+ results are found.
643
+ """
417
644
  result = self._execute_query(fetch_one=fetch_one)
418
645
 
419
646
  if not result:
@@ -432,30 +659,54 @@ class QueryBuilder:
432
659
  return [self._convert_row_to_model(row) for row in result]
433
660
 
434
661
  def fetch_all(self) -> list[BaseDBModel]:
435
- """Fetch all results matching the filters."""
662
+ """Fetch all results of the query.
663
+
664
+ Returns:
665
+ A list of model instances representing all query results.
666
+ """
436
667
  return self._fetch_result(fetch_one=False)
437
668
 
438
669
  def fetch_one(self) -> Optional[BaseDBModel]:
439
- """Fetch exactly one result."""
670
+ """Fetch a single result of the query.
671
+
672
+ Returns:
673
+ A single model instance or None if no result is found.
674
+ """
440
675
  return self._fetch_result(fetch_one=True)
441
676
 
442
677
  def fetch_first(self) -> Optional[BaseDBModel]:
443
- """Fetch the first result of the query."""
678
+ """Fetch the first result of the query.
679
+
680
+ Returns:
681
+ The first model instance or None if no result is found.
682
+ """
444
683
  self._limit = 1
445
684
  return self._fetch_result(fetch_one=True)
446
685
 
447
686
  def fetch_last(self) -> Optional[BaseDBModel]:
448
- """Fetch the last result of the query (based on the insertion order)."""
687
+ """Fetch the last result of the query.
688
+
689
+ Returns:
690
+ The last model instance or None if no result is found.
691
+ """
449
692
  self._limit = 1
450
693
  self._order_by = "rowid DESC"
451
694
  return self._fetch_result(fetch_one=True)
452
695
 
453
696
  def count(self) -> int:
454
- """Return the count of records matching the filters."""
697
+ """Count the number of results for the current query.
698
+
699
+ Returns:
700
+ The number of results that match the current query conditions.
701
+ """
455
702
  result = self._execute_query(count_only=True)
456
703
 
457
704
  return int(result[0][0]) if result else 0
458
705
 
459
706
  def exists(self) -> bool:
460
- """Return True if any record matches the filters."""
707
+ """Check if any results exist for the current query.
708
+
709
+ Returns:
710
+ True if at least one result exists, False otherwise.
711
+ """
461
712
  return self.count() > 0