hypern 0.3.2__cp312-cp312-win32.whl → 0.3.3__cp312-cp312-win32.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.
hypern/db/sql/query.py ADDED
@@ -0,0 +1,879 @@
1
+ from enum import Enum
2
+ from typing import Any, Dict, List, Tuple, Union
3
+
4
+
5
+ class JoinType(Enum):
6
+ INNER = "INNER JOIN"
7
+ LEFT = "LEFT JOIN"
8
+ RIGHT = "RIGHT JOIN"
9
+ FULL = "FULL JOIN"
10
+ CROSS = "CROSS JOIN"
11
+
12
+
13
+ class Operator(Enum):
14
+ EQ = "="
15
+ GT = ">"
16
+ LT = "<"
17
+ GTE = ">="
18
+ LTE = "<="
19
+ NEQ = "!="
20
+ IN = "IN"
21
+ NOT_IN = "NOT IN"
22
+ LIKE = "LIKE"
23
+ ILIKE = "ILIKE"
24
+ BETWEEN = "BETWEEN"
25
+ IS_NULL = "IS NULL"
26
+ IS_NOT_NULL = "IS NOT NULL"
27
+ REGEXP = "~"
28
+ IREGEXP = "~*"
29
+
30
+
31
+ class Expression:
32
+ """Class for representing SQL expressions with parameters"""
33
+
34
+ def __init__(self, sql: str, params: list):
35
+ self.sql = sql
36
+ self.params = params
37
+
38
+ def over(self, partition_by=None, order_by=None, frame=None, window_name=None): # NOSONAR
39
+ """
40
+ Add OVER clause for window functions with support for:
41
+ - Named windows
42
+ - Custom frame definitions
43
+ - Flexible partitioning and ordering
44
+ """
45
+ if window_name:
46
+ self.sql = f"{self.sql} OVER {window_name}"
47
+ return self
48
+
49
+ parts = ["OVER("]
50
+ clauses = []
51
+
52
+ if partition_by:
53
+ if isinstance(partition_by, str):
54
+ partition_by = [partition_by]
55
+ # Handle both raw SQL and Django-style field references
56
+ formatted_fields = []
57
+ for field in partition_by:
58
+ if "__" in field: # Django-style field reference
59
+ field = field.replace("__", ".")
60
+ formatted_fields.append(field)
61
+ clauses.append(f"PARTITION BY {', '.join(formatted_fields)}")
62
+
63
+ if order_by:
64
+ if isinstance(order_by, str):
65
+ order_by = [order_by]
66
+ # Handle both raw SQL and Django-style ordering
67
+ formatted_order = []
68
+ for field in order_by:
69
+ if isinstance(field, str):
70
+ if field.startswith("-"):
71
+ field = f"{field[1:]} DESC"
72
+ elif field.startswith("+"):
73
+ field = f"{field[1:]} ASC"
74
+ if "__" in field: # Django-style field reference
75
+ field = field.replace("__", ".")
76
+ formatted_order.append(field)
77
+ clauses.append(f"ORDER BY {', '.join(formatted_order)}")
78
+
79
+ if frame:
80
+ if isinstance(frame, str):
81
+ clauses.append(frame)
82
+ elif isinstance(frame, (list, tuple)):
83
+ frame_type = "ROWS" # Default frame type
84
+ if len(frame) == 3 and frame[0].upper() in ("ROWS", "RANGE", "GROUPS"):
85
+ frame_type = frame[0].upper()
86
+ frame = frame[1:]
87
+ frame_clause = f"{frame_type} BETWEEN {frame[0]} AND {frame[1]}"
88
+ clauses.append(frame_clause)
89
+
90
+ parts.append(" ".join(clauses))
91
+ parts.append(")")
92
+
93
+ self.sql = f"{self.sql} {' '.join(parts)}"
94
+ return self
95
+
96
+
97
+ class F:
98
+ """Class for creating SQL expressions and column references"""
99
+
100
+ def __init__(self, field: str):
101
+ self.field = field.replace("__", ".") # Handle Django-style field references
102
+
103
+ def __add__(self, other):
104
+ if isinstance(other, F):
105
+ return Expression(f"{self.field} + {other.field}", [])
106
+ return Expression(f"{self.field} + ?", [other])
107
+
108
+ def __sub__(self, other):
109
+ if isinstance(other, F):
110
+ return Expression(f"{self.field} - {other.field}", [])
111
+ return Expression(f"{self.field} - ?", [other])
112
+
113
+ def __mul__(self, other):
114
+ if isinstance(other, F):
115
+ return Expression(f"{self.field} * {other.field}", [])
116
+ return Expression(f"{self.field} * ?", [other])
117
+
118
+ def __truediv__(self, other):
119
+ if isinstance(other, F):
120
+ return Expression(f"{self.field} / {other.field}", [])
121
+ return Expression(f"{self.field} / ?", [other])
122
+
123
+ # Window function methods
124
+ def sum(self):
125
+ """SUM window function"""
126
+ return Expression(f"SUM({self.field})", [])
127
+
128
+ def avg(self):
129
+ """AVG window function"""
130
+ return Expression(f"AVG({self.field})", [])
131
+
132
+ def count(self):
133
+ """COUNT window function"""
134
+ return Expression(f"COUNT({self.field})", [])
135
+
136
+ def max(self):
137
+ """MAX window function"""
138
+ return Expression(f"MAX({self.field})", [])
139
+
140
+ def min(self):
141
+ """MIN window function"""
142
+ return Expression(f"MIN({self.field})", [])
143
+
144
+ def lag(self, offset=1, default=None):
145
+ """LAG window function"""
146
+ if default is None:
147
+ return Expression(f"LAG({self.field}, {offset})", [])
148
+ return Expression(f"LAG({self.field}, {offset}, ?)", [default])
149
+
150
+ def lead(self, offset=1, default=None):
151
+ """LEAD window function"""
152
+ if default is None:
153
+ return Expression(f"LEAD({self.field}, {offset})", [])
154
+ return Expression(f"LEAD({self.field}, {offset}, ?)", [default])
155
+
156
+ def row_number(self):
157
+ """ROW_NUMBER window function"""
158
+ return Expression("ROW_NUMBER()", [])
159
+
160
+ def rank(self):
161
+ """RANK window function"""
162
+ return Expression("RANK()", [])
163
+
164
+ def dense_rank(self):
165
+ """DENSE_RANK window function"""
166
+ return Expression("DENSE_RANK()", [])
167
+
168
+
169
+ class Window:
170
+ """Class for defining named windows"""
171
+
172
+ def __init__(self, name: str, partition_by=None, order_by=None, frame=None):
173
+ self.name = name
174
+ self.partition_by = partition_by
175
+ self.order_by = order_by
176
+ self.frame = frame
177
+
178
+ def to_sql(self): # NOSONAR
179
+ """Convert window definition to SQL"""
180
+ parts = [f"{self.name} AS ("]
181
+ clauses = []
182
+
183
+ if self.partition_by:
184
+ if isinstance(self.partition_by, str):
185
+ self.partition_by = [self.partition_by]
186
+ formatted_fields = [f.replace("__", ".") for f in self.partition_by]
187
+ clauses.append(f"PARTITION BY {', '.join(formatted_fields)}")
188
+
189
+ if self.order_by:
190
+ if isinstance(self.order_by, str):
191
+ self.order_by = [self.order_by]
192
+ formatted_order = []
193
+ for field in self.order_by:
194
+ if field.startswith("-"):
195
+ field = f"{field[1:].replace('__', '.')} DESC"
196
+ elif field.startswith("+"):
197
+ field = f"{field[1:].replace('__', '.')} ASC"
198
+ else:
199
+ field = field.replace("__", ".")
200
+ formatted_order.append(field)
201
+ clauses.append(f"ORDER BY {', '.join(formatted_order)}")
202
+
203
+ if self.frame:
204
+ if isinstance(self.frame, str):
205
+ clauses.append(self.frame)
206
+ elif isinstance(self.frame, (list, tuple)):
207
+ frame_type = "ROWS"
208
+ if len(self.frame) == 3 and self.frame[0].upper() in ("ROWS", "RANGE", "GROUPS"):
209
+ frame_type = self.frame[0].upper()
210
+ self.frame = self.frame[1:]
211
+ frame_clause = f"{frame_type} BETWEEN {self.frame[0]} AND {self.frame[1]}"
212
+ clauses.append(frame_clause)
213
+
214
+ parts.append(" ".join(clauses))
215
+ parts.append(")")
216
+ return " ".join(parts)
217
+
218
+
219
+ class Q:
220
+ """Class for complex WHERE conditions with AND/OR operations"""
221
+
222
+ def __init__(self, *args, **kwargs):
223
+ self.children = list(args)
224
+ self.connector = "AND"
225
+ self.negated = False
226
+
227
+ if kwargs:
228
+ # Convert kwargs to Q objects and add them
229
+ for key, value in kwargs.items():
230
+ condition = {key: value}
231
+ self.children.append(condition)
232
+
233
+ def __and__(self, other):
234
+ if getattr(other, "connector", "AND") == "AND" and not other.negated:
235
+ # If other is also an AND condition and not negated,
236
+ # we can merge their children
237
+ clone = self._clone()
238
+ clone.children.extend(other.children)
239
+ return clone
240
+ else:
241
+ q = Q()
242
+ q.connector = "AND"
243
+ q.children = [self, other]
244
+ return q
245
+
246
+ def __or__(self, other):
247
+ if getattr(other, "connector", "OR") == "OR" and not other.negated:
248
+ # If other is also an OR condition and not negated,
249
+ # we can merge their children
250
+ clone = self._clone()
251
+ clone.connector = "OR"
252
+ clone.children.extend(other.children)
253
+ return clone
254
+ else:
255
+ q = Q()
256
+ q.connector = "OR"
257
+ q.children = [self, other]
258
+ return q
259
+
260
+ def __invert__(self):
261
+ clone = self._clone()
262
+ clone.negated = not self.negated
263
+ return clone
264
+
265
+ def _clone(self):
266
+ """Create a copy of the current Q object"""
267
+ clone = Q()
268
+ clone.connector = self.connector
269
+ clone.negated = self.negated
270
+ clone.children = self.children[:]
271
+ return clone
272
+
273
+ def add(self, child, connector):
274
+ """Add a child node, updating connector if necessary"""
275
+ if connector != self.connector:
276
+ # If connectors don't match, we need to nest the existing children
277
+ self.children = [Q(*self.children, connector=self.connector)]
278
+ self.connector = connector
279
+
280
+ if isinstance(child, Q):
281
+ if child.connector == connector and not child.negated:
282
+ # If child has same connector and is not negated,
283
+ # we can merge its children directly
284
+ self.children.extend(child.children)
285
+ else:
286
+ self.children.append(child)
287
+ else:
288
+ self.children.append(child)
289
+
290
+ def _combine(self, other, connector):
291
+ """
292
+ Combine this Q object with another one using the given connector.
293
+ This is an internal method used by __and__ and __or__.
294
+ """
295
+ if not other:
296
+ return self._clone()
297
+
298
+ if not self:
299
+ return other._clone() if isinstance(other, Q) else Q(other)
300
+
301
+ q = Q()
302
+ q.connector = connector
303
+ q.children = [self, other]
304
+ return q
305
+
306
+ def __bool__(self):
307
+ """Return True if this Q object has any children"""
308
+ return bool(self.children)
309
+
310
+ def __str__(self):
311
+ """
312
+ Return a string representation of the Q object,
313
+ useful for debugging
314
+ """
315
+ if self.negated:
316
+ return f"NOT ({self._str_inner()})"
317
+ return self._str_inner()
318
+
319
+ def _str_inner(self):
320
+ """Helper method for __str__"""
321
+ if not self.children:
322
+ return ""
323
+
324
+ children_str = []
325
+ for child in self.children:
326
+ if isinstance(child, Q):
327
+ child_str = str(child)
328
+ elif isinstance(child, dict):
329
+ child_str = " AND ".join(f"{k}={v}" for k, v in child.items()) # NOSONAR
330
+ else:
331
+ child_str = str(child)
332
+ children_str.append(f"({child_str})")
333
+
334
+ return f" {self.connector} ".join(children_str)
335
+
336
+
337
+ class QuerySet:
338
+ def __init__(self, model):
339
+ self.model = model
340
+ self.query_parts = {
341
+ "select": ["*"],
342
+ "where": [],
343
+ "order_by": [],
344
+ "limit": None,
345
+ "offset": None,
346
+ "joins": [],
347
+ "group_by": [],
348
+ "having": [],
349
+ "with": [],
350
+ "window": [],
351
+ }
352
+ self.params = []
353
+ self._distinct = False
354
+ self._for_update = False
355
+ self._for_share = False
356
+ self._nowait = False
357
+ self._skip_locked = False
358
+ self._param_counter = 1
359
+
360
+ def __get_next_param(self):
361
+ param_name = f"${self._param_counter}"
362
+ self._param_counter += 1
363
+ return param_name
364
+
365
+ def clone(self) -> "QuerySet":
366
+ new_qs = QuerySet(self.model)
367
+ new_qs.query_parts = {k: v[:] if isinstance(v, list) else v for k, v in self.query_parts.items()}
368
+ new_qs.params = self.params[:]
369
+ new_qs._distinct = self._distinct
370
+ new_qs._for_update = self._for_update
371
+ new_qs._for_share = self._for_share
372
+ new_qs._nowait = self._nowait
373
+ new_qs._skip_locked = self._skip_locked
374
+ new_qs._param_counter = self._param_counter
375
+ return new_qs
376
+
377
+ def select(self, *fields, distinct: bool = False) -> "QuerySet":
378
+ qs = self.clone()
379
+ qs.query_parts["select"] = list(fields)
380
+ qs._distinct = distinct
381
+ return qs
382
+
383
+ def _process_q_object(self, q_obj: Q, params: List = None) -> Tuple[str, List]:
384
+ if params is None:
385
+ params = []
386
+
387
+ if not q_obj.children:
388
+ return "", params
389
+
390
+ sql_parts = []
391
+ local_params = []
392
+
393
+ for child in q_obj.children:
394
+ if isinstance(child, Q):
395
+ inner_sql, inner_params = self._process_q_object(child)
396
+ sql_parts.append(f"({inner_sql})")
397
+ local_params.extend(inner_params)
398
+ elif isinstance(child, dict):
399
+ for key, value in child.items():
400
+ field_sql, field_params = self._process_where_item(key, value)
401
+ sql_parts.append(field_sql)
402
+ local_params.extend(field_params)
403
+ elif isinstance(child, tuple):
404
+ field_sql, field_params = self._process_where_item(child[0], child[1])
405
+ sql_parts.append(field_sql)
406
+ local_params.extend(field_params)
407
+
408
+ joined = f" {q_obj.connector} ".join(sql_parts)
409
+ if q_obj.negated:
410
+ joined = f"NOT ({joined})"
411
+
412
+ params.extend(local_params)
413
+ return joined, params
414
+
415
+ def _process_where_item(self, key: str, value: Any) -> Tuple[str, List]:
416
+ parts = key.split("__")
417
+ field = parts[0]
418
+ op = "=" if len(parts) == 1 else parts[1]
419
+
420
+ if isinstance(value, F):
421
+ return self._process_f_value(field, op, value)
422
+
423
+ if isinstance(value, Expression):
424
+ return self._process_expression_value(field, op, value)
425
+
426
+ return self._process_standard_value(field, op, value)
427
+
428
+ def _process_f_value(self, field: str, op: str, value: F) -> Tuple[str, List]:
429
+ return f"{field} {op} {value.field}", []
430
+
431
+ def _process_expression_value(self, field: str, op: str, value: Expression) -> Tuple[str, List]:
432
+ return f"{field} {op} {value.sql}", value.params
433
+
434
+ def _process_standard_value(self, field: str, op: str, value: Any) -> Tuple[str, List]:
435
+ op_map = {
436
+ "gt": Operator.GT.value,
437
+ "lt": Operator.LT.value,
438
+ "gte": Operator.GTE.value,
439
+ "lte": Operator.LTE.value,
440
+ "contains": Operator.LIKE.value,
441
+ "icontains": Operator.ILIKE.value,
442
+ "startswith": Operator.LIKE.value,
443
+ "endswith": Operator.LIKE.value,
444
+ "in": Operator.IN.value,
445
+ "not_in": Operator.NOT_IN.value,
446
+ "isnull": Operator.IS_NULL.value,
447
+ "between": Operator.BETWEEN.value,
448
+ "regex": Operator.REGEXP.value,
449
+ "iregex": Operator.IREGEXP.value,
450
+ }
451
+
452
+ if op in op_map:
453
+ return self._process_op_map_value(field, op, value, op_map)
454
+ else:
455
+ param_name = self.__get_next_param()
456
+ return f"{field} = {param_name}", [value]
457
+
458
+ def _process_op_map_value(self, field: str, op: str, value: Any, op_map: dict) -> Tuple[str, List]:
459
+ param_name = self.__get_next_param()
460
+ if op in ("contains", "icontains"):
461
+ return f"{field} {op_map[op]} {param_name}", [f"%{value}%"]
462
+ elif op == "startswith":
463
+ return f"{field} {op_map[op]} {param_name}", [f"{value}%"]
464
+ elif op == "endswith":
465
+ return f"{field} {op_map[op]} {param_name}", [f"%{value}"]
466
+ elif op == "isnull":
467
+ return f"{field} {Operator.IS_NULL.value if value else Operator.IS_NOT_NULL.value}", []
468
+ elif op == "between":
469
+ return f"{field} {op_map[op]} {param_name} AND {param_name}", [value[0], value[1]]
470
+ elif op in ("in", "not_in"):
471
+ placeholders = ",".join(["{param_name}" for _ in value])
472
+ return f"{field} {op_map[op]} ({placeholders})", list(value)
473
+ else:
474
+ return f"{field} {op_map[op]} {param_name}", [value]
475
+
476
+ def where(self, *args, **kwargs) -> "QuerySet":
477
+ qs = self.clone()
478
+
479
+ # Process Q objects
480
+ for arg in args:
481
+ if isinstance(arg, Q):
482
+ sql, params = qs._process_q_object(arg, [])
483
+ if sql:
484
+ qs.query_parts["where"].append(sql)
485
+ qs.params.extend(params)
486
+ elif isinstance(arg, Expression):
487
+ qs.query_parts["where"].append(arg.sql)
488
+ qs.params.extend(arg.params)
489
+ else:
490
+ qs.query_parts["where"].append(str(arg))
491
+
492
+ # Process keyword arguments
493
+ if kwargs:
494
+ q = Q(**kwargs)
495
+ sql, params = qs._process_q_object(q, [])
496
+ if sql:
497
+ qs.query_parts["where"].append(sql)
498
+ qs.params.extend(params)
499
+ return qs
500
+
501
+ def annotate(self, **annotations) -> "QuerySet":
502
+ qs = self.clone()
503
+ select_parts = []
504
+
505
+ for alias, expression in annotations.items():
506
+ if isinstance(expression, F):
507
+ select_parts.append(f"{expression.field} AS {alias}")
508
+ elif isinstance(expression, Expression):
509
+ select_parts.append(f"({expression.sql.replace('?', qs.__get_next_param())}) AS {alias}")
510
+ qs.params.extend(expression.params)
511
+ else:
512
+ select_parts.append(f"{expression} AS {alias}")
513
+
514
+ qs.query_parts["select"].extend(select_parts)
515
+ return qs
516
+
517
+ def values(self, *fields) -> "QuerySet":
518
+ return self.select(*fields)
519
+
520
+ def values_list(self, *fields, flat: bool = False) -> "QuerySet":
521
+ if flat and len(fields) > 1:
522
+ raise ValueError("'flat' is not valid when values_list is called with more than one field.")
523
+ return self.select(*fields)
524
+
525
+ def order_by(self, *fields) -> "QuerySet":
526
+ qs = self.clone()
527
+ order_parts = []
528
+
529
+ for field in fields:
530
+ if isinstance(field, F):
531
+ order_parts.append(field.field)
532
+ elif isinstance(field, Expression):
533
+ order_parts.append(field.sql)
534
+ qs.params.extend(field.params)
535
+ elif field.startswith("-"):
536
+ order_parts.append(f"{field[1:]} DESC")
537
+ else:
538
+ order_parts.append(f"{field} ASC")
539
+
540
+ qs.query_parts["order_by"] = order_parts
541
+ return qs
542
+
543
+ def join(self, table: str, on: Union[str, Expression], join_type: Union[str, JoinType] = JoinType.INNER) -> "QuerySet":
544
+ qs = self.clone()
545
+
546
+ if isinstance(join_type, JoinType):
547
+ join_type = join_type.value
548
+
549
+ if isinstance(on, Expression):
550
+ qs.query_parts["joins"].append(f"{join_type} {table} ON {on.sql}")
551
+ qs.params.extend(on.params)
552
+ else:
553
+ qs.query_parts["joins"].append(f"{join_type} {table} ON {on}")
554
+
555
+ return qs
556
+
557
+ def group_by(self, *fields) -> "QuerySet":
558
+ qs = self.clone()
559
+ group_parts = []
560
+
561
+ for field in fields:
562
+ if isinstance(field, F):
563
+ group_parts.append(field.field)
564
+ elif isinstance(field, Expression):
565
+ group_parts.append(field.sql)
566
+ qs.params.extend(field.params)
567
+ else:
568
+ group_parts.append(str(field))
569
+
570
+ qs.query_parts["group_by"] = group_parts
571
+ return qs
572
+
573
+ def having(self, *conditions) -> "QuerySet":
574
+ qs = self.clone()
575
+ having_parts = []
576
+
577
+ for condition in conditions:
578
+ if isinstance(condition, Expression):
579
+ having_parts.append(condition.sql)
580
+ qs.params.extend(condition.params)
581
+ else:
582
+ having_parts.append(str(condition))
583
+
584
+ qs.query_parts["having"] = having_parts
585
+ return qs
586
+
587
+ def window(self, alias: str, partition_by: List = None, order_by: List = None) -> "QuerySet":
588
+ qs = self.clone()
589
+ parts = [f"{alias} AS ("]
590
+
591
+ if partition_by:
592
+ parts.append(qs._process_partition_by(partition_by, qs))
593
+
594
+ if order_by:
595
+ parts.append(qs._process_order_by(order_by, qs))
596
+
597
+ parts.append(")")
598
+ qs.query_parts["window"].append(" ".join(parts))
599
+ return qs
600
+
601
+ def _process_partition_by(self, partition_by: List, qs: "QuerySet") -> str:
602
+ partition_parts = []
603
+ for field in partition_by:
604
+ if isinstance(field, F):
605
+ partition_parts.append(field.field)
606
+ elif isinstance(field, Expression):
607
+ partition_parts.append(field.sql)
608
+ qs.params.extend(field.params)
609
+ else:
610
+ partition_parts.append(str(field))
611
+ return f"PARTITION BY {', '.join(partition_parts)}"
612
+
613
+ def _process_order_by(self, order_by: List, qs: "QuerySet") -> str:
614
+ order_parts = []
615
+ for field in order_by:
616
+ if isinstance(field, F):
617
+ order_parts.append(field.field)
618
+ elif isinstance(field, Expression):
619
+ order_parts.append(field.sql)
620
+ qs.params.extend(field.params)
621
+ elif field.startswith("-"):
622
+ order_parts.append(f"{field[1:]} DESC")
623
+ else:
624
+ order_parts.append(f"{field} ASC")
625
+ return f"ORDER BY {', '.join(order_parts)}"
626
+
627
+ def limit(self, limit: int) -> "QuerySet":
628
+ qs = self.clone()
629
+ qs.query_parts["limit"] = limit
630
+ return qs
631
+
632
+ def offset(self, offset: int) -> "QuerySet":
633
+ qs = self.clone()
634
+ qs.query_parts["offset"] = offset
635
+ return qs
636
+
637
+ def for_update(self, nowait: bool = False, skip_locked: bool = False) -> "QuerySet":
638
+ qs = self.clone()
639
+ qs._for_update = True
640
+ qs._nowait = nowait
641
+ qs._skip_locked = skip_locked
642
+ return qs
643
+
644
+ def for_share(self, nowait: bool = False, skip_locked: bool = False) -> "QuerySet":
645
+ qs = self.clone()
646
+ qs._for_share = True
647
+ qs._nowait = nowait
648
+ qs._skip_locked = skip_locked
649
+ return qs
650
+
651
+ def with_recursive(self, name: str, initial_query: str, recursive_query: str) -> "QuerySet":
652
+ qs = self.clone()
653
+ cte = f"WITH RECURSIVE {name} AS ({initial_query} UNION ALL {recursive_query})"
654
+ qs.query_parts["with"].append(cte)
655
+ return qs
656
+
657
+ def union(self, other_qs: "QuerySet", all: bool = False) -> "QuerySet":
658
+ sql1, params1 = self.to_sql()
659
+ sql2, params2 = other_qs.to_sql()
660
+ union_type = "UNION ALL" if all else "UNION"
661
+ combined_sql = f"({sql1}) {union_type} ({sql2})"
662
+ combined_params = params1 + params2
663
+
664
+ new_qs = self.clone()
665
+ new_qs.query_parts["raw_sql"] = combined_sql
666
+ new_qs.params = combined_params
667
+ return new_qs
668
+
669
+ def intersect(self, other_qs: "QuerySet", all: bool = False) -> "QuerySet":
670
+ sql1, params1 = self.to_sql()
671
+ sql2, params2 = other_qs.to_sql()
672
+ intersect_type = "INTERSECT ALL" if all else "INTERSECT"
673
+ combined_sql = f"({sql1}) {intersect_type} ({sql2})"
674
+ combined_params = params1 + params2
675
+
676
+ new_qs = self.clone()
677
+ new_qs.query_parts["raw_sql"] = combined_sql
678
+ new_qs.params = combined_params
679
+ return new_qs
680
+
681
+ def except_(self, other_qs: "QuerySet", all: bool = False) -> "QuerySet":
682
+ sql1, params1 = self.to_sql()
683
+ sql2, params2 = other_qs.to_sql()
684
+ except_type = "EXCEPT ALL" if all else "EXCEPT"
685
+ combined_sql = f"({sql1}) {except_type} ({sql2})"
686
+ combined_params = params1 + params2
687
+
688
+ new_qs = self.clone()
689
+ new_qs.query_parts["raw_sql"] = combined_sql
690
+ new_qs.params = combined_params
691
+ return new_qs
692
+
693
+ def subquery(self, alias: str) -> Expression:
694
+ """Convert this queryset into a subquery expression"""
695
+ sql, params = self.to_sql()
696
+ return Expression(f"({sql}) AS {alias}", params)
697
+
698
+ def to_sql(self) -> Tuple[str, List]:
699
+ """Convert the QuerySet into an SQL query string and parameters"""
700
+ if "raw_sql" in self.query_parts:
701
+ return self.query_parts["raw_sql"], self.params
702
+
703
+ parts = []
704
+ self._build_sql_parts(parts)
705
+ return " ".join(parts), self.params
706
+
707
+ def _build_sql_parts(self, parts):
708
+ self._add_with_clause(parts)
709
+ self._add_select_clause(parts)
710
+ self._add_from_clause(parts)
711
+ self._add_joins_clause(parts)
712
+ self._add_where_clause(parts)
713
+ self._add_group_by_clause(parts)
714
+ self._add_having_clause(parts)
715
+ self._add_window_clause(parts)
716
+ self._add_order_by_clause(parts)
717
+ self._add_limit_clause(parts)
718
+ self._add_offset_clause(parts)
719
+ self._add_locking_clauses(parts)
720
+
721
+ def _add_with_clause(self, parts):
722
+ if self.query_parts["with"]:
723
+ parts.append(" ".join(self.query_parts["with"]))
724
+
725
+ def _add_select_clause(self, parts):
726
+ select_clause = "SELECT"
727
+ if self._distinct:
728
+ select_clause += " DISTINCT"
729
+ select_clause += " " + ", ".join(self.query_parts["select"])
730
+ parts.append(select_clause)
731
+
732
+ def _add_from_clause(self, parts):
733
+ parts.append(f"FROM {self.model.Meta.table_name}")
734
+
735
+ def _add_joins_clause(self, parts):
736
+ if self.query_parts["joins"]:
737
+ parts.extend(self.query_parts["joins"])
738
+
739
+ def _add_where_clause(self, parts):
740
+ if self.query_parts["where"]:
741
+ parts.append("WHERE " + " AND ".join(f"({condition})" for condition in self.query_parts["where"]))
742
+
743
+ def _add_group_by_clause(self, parts):
744
+ if self.query_parts["group_by"]:
745
+ parts.append("GROUP BY " + ", ".join(self.query_parts["group_by"]))
746
+
747
+ def _add_having_clause(self, parts):
748
+ if self.query_parts["having"]:
749
+ parts.append("HAVING " + " AND ".join(self.query_parts["having"]))
750
+
751
+ def _add_window_clause(self, parts):
752
+ if self.query_parts["window"]:
753
+ parts.append("WINDOW " + ", ".join(self.query_parts["window"]))
754
+
755
+ def _add_order_by_clause(self, parts):
756
+ if self.query_parts["order_by"]:
757
+ parts.append("ORDER BY " + ", ".join(self.query_parts["order_by"]))
758
+
759
+ def _add_limit_clause(self, parts):
760
+ if self.query_parts["limit"] is not None:
761
+ parts.append(f"LIMIT {self.query_parts['limit']}")
762
+
763
+ def _add_offset_clause(self, parts):
764
+ if self.query_parts["offset"] is not None:
765
+ parts.append(f"OFFSET {self.query_parts['offset']}")
766
+
767
+ def _add_locking_clauses(self, parts):
768
+ if self._for_update:
769
+ parts.append("FOR UPDATE")
770
+ if self._nowait:
771
+ parts.append("NOWAIT")
772
+ elif self._skip_locked:
773
+ parts.append("SKIP LOCKED")
774
+ elif self._for_share:
775
+ parts.append("FOR SHARE")
776
+ if self._nowait:
777
+ parts.append("NOWAIT")
778
+ elif self._skip_locked:
779
+ parts.append("SKIP LOCKED")
780
+
781
+ def execute(self) -> List[Tuple]:
782
+ """Execute the query and return results"""
783
+ sql, params = self.to_sql()
784
+ result = self.model.get_session().fetch_all(sql, params)
785
+ return result
786
+
787
+ def count(self) -> int:
788
+ """Return the count of rows that would be returned by this query"""
789
+ qs = self.clone()
790
+ qs.query_parts["select"] = ["COUNT(*)"]
791
+ qs.query_parts["order_by"] = [] # Clear order_by as it's unnecessary for count
792
+ sql, params = qs.to_sql()
793
+
794
+ # Execute count query
795
+ result = self.model.get_session().fetch_all(sql, params)
796
+ return result
797
+
798
+ def exists(self) -> bool:
799
+ """Return True if the query would return any results"""
800
+ qs = self.clone()
801
+ qs.query_parts["select"] = ["1"]
802
+ qs.query_parts["order_by"] = []
803
+ qs = qs.limit(1)
804
+ sql, params = qs.to_sql()
805
+
806
+ result = self.model.get_session().fetch_all(sql, params)
807
+ return result
808
+
809
+ def update(self, **kwargs) -> int:
810
+ """Update records that match the query conditions"""
811
+ updates = []
812
+ params = []
813
+
814
+ for field, value in kwargs.items():
815
+ param_name = self.__get_next_param()
816
+ if isinstance(value, F):
817
+ updates.append(f"{field} = {value.field}")
818
+ elif isinstance(value, Expression):
819
+ updates.append(f"{field} = {value.sql}")
820
+ params.extend(value.params)
821
+ else:
822
+ updates.append(f"{field} = {param_name}")
823
+ params.append(value)
824
+
825
+ where_sql = " AND ".join(f"({condition})" for condition in self.query_parts["where"])
826
+
827
+ sql = f"UPDATE {self.model.Meta.table_name} SET {', '.join(updates)}"
828
+ if where_sql:
829
+ sql += f" WHERE {where_sql}"
830
+ params = self.params + params
831
+ result = self.model.get_session().bulk_change(sql, [params], 1)
832
+ return result
833
+
834
+ def delete(self) -> int:
835
+ """Delete records that match the query conditions"""
836
+ where_sql = " AND ".join(f"({condition})" for condition in self.query_parts["where"])
837
+
838
+ sql = f"DELETE FROM {self.model.Meta.table_name}"
839
+ if where_sql:
840
+ sql += f" WHERE {where_sql}"
841
+
842
+ return self.model.get_session().bulk_change(sql, [self.params], 1)
843
+
844
+ def bulk_create(self, objs: List[Any], batch_size: int = None) -> int | None:
845
+ """Insert multiple records in an efficient way"""
846
+ if not objs:
847
+ return
848
+
849
+ # Get fields from the first object
850
+ fields = [name for name, f in self.model._fields.items() if not f.auto_increment]
851
+ placeholders = ",".join([self.__get_next_param() for _ in fields])
852
+
853
+ sql = f"INSERT INTO {self.model.Meta.table_name} ({','.join(fields)}) VALUES ({placeholders})"
854
+
855
+ values = []
856
+ for obj in objs:
857
+ values.append([obj._data[i] for i in fields])
858
+
859
+ return self.model.get_session().bulk_change(sql, values, batch_size or len(values))
860
+
861
+ def explain(self, analyze: bool = False, verbose: bool = False, costs: bool = False, buffers: bool = False, timing: bool = False) -> Dict:
862
+ """Get the query execution plan"""
863
+ options = []
864
+ if analyze:
865
+ options.append("ANALYZE")
866
+ if verbose:
867
+ options.append("VERBOSE")
868
+ if costs:
869
+ options.append("COSTS")
870
+ if buffers:
871
+ options.append("BUFFERS")
872
+ if timing:
873
+ options.append("TIMING")
874
+
875
+ sql, params = self.to_sql()
876
+ explain_sql = f"EXPLAIN ({' '.join(options)}) {sql}"
877
+
878
+ result = self.model.get_session().fetch_all(explain_sql, params)
879
+ return result