sql_fusion 1.0.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.
sql_fusion/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .composite_table import Alias, Table, func
2
+ from .query.delete import delete
3
+ from .query.insert import insert
4
+ from .query.select import select
5
+ from .query.update import update
6
+
7
+ __all__ = [
8
+ "Alias",
9
+ "Table",
10
+ "delete",
11
+ "func",
12
+ "insert",
13
+ "select",
14
+ "update",
15
+ ]
@@ -0,0 +1,689 @@
1
+ from copy import copy
2
+ from typing import Any, Callable, Self
3
+
4
+ from sql_fusion.operators import (
5
+ AbstractOperator,
6
+ EqualOperator,
7
+ GreaterThanOperator,
8
+ GreaterThanOrEqualOperator,
9
+ IlikeOperator,
10
+ InOperator,
11
+ LessThanOperator,
12
+ LessThanOrEqualOperator,
13
+ LikeOperator,
14
+ NotEqualOperator,
15
+ NotInOperator,
16
+ )
17
+
18
+ CompileExpression = Callable[
19
+ [str, tuple[Any, ...]],
20
+ tuple[str, tuple[Any, ...]],
21
+ ]
22
+
23
+
24
+ class AliasRegistry:
25
+ """Registry for managing unique table aliases."""
26
+
27
+ def __init__(self) -> None:
28
+ self._counter: int = 0
29
+ self._mapping: dict[Table, Alias] = {}
30
+
31
+ def get_next_alias(self) -> str:
32
+ """Generate the next unique alias (a, b, c, ..., z, aa, ab, ...)."""
33
+ alias = ""
34
+ n = self._counter
35
+ while True:
36
+ alias = chr(ord("a") + (n % 26)) + alias
37
+ n //= 26
38
+ if n == 0:
39
+ break
40
+ self._counter += 1
41
+ return alias
42
+
43
+ def get_alias_for_table(self, table: Table) -> Alias:
44
+ if table not in self._mapping:
45
+ self._mapping[table] = Alias(self.get_next_alias())
46
+ return self._mapping[table]
47
+
48
+ def reset(self) -> None:
49
+ self._counter = 0
50
+ self._mapping.clear()
51
+
52
+
53
+ class AbstractQuery:
54
+ def __init__(
55
+ self,
56
+ table: Table | None,
57
+ columns: tuple[Column | Alias | FunctionCall, ...] = (),
58
+ ) -> None:
59
+ self._table: Table | None = table
60
+ self._columns: tuple[Column | Alias | FunctionCall, ...] = columns
61
+ self._where_condition: Condition | None = None
62
+ self._ctes: list[tuple[str, AbstractQuery]] = []
63
+ self._with_recursive: bool = False
64
+ self._compile_expressions: list[CompileExpression] = []
65
+ self._before_clause_comments: dict[str, list[tuple[str, bool]]] = {}
66
+ self._after_clause_comments: dict[str, list[tuple[str, bool]]] = {}
67
+ self._alias_registry: AliasRegistry = AliasRegistry()
68
+
69
+ def _get_table(self) -> Table:
70
+ if self._table is None:
71
+ raise ValueError("FROM clause is required")
72
+ return self._table
73
+
74
+ def where(
75
+ self,
76
+ *conditions: Condition,
77
+ ) -> Self:
78
+ qs = copy(self)
79
+ combined_condition: Condition | None = None
80
+
81
+ for condition in conditions:
82
+ if combined_condition is None:
83
+ combined_condition = condition
84
+ else:
85
+ combined_condition = combined_condition & condition
86
+
87
+ if combined_condition:
88
+ if qs._where_condition is None:
89
+ qs._where_condition = combined_condition
90
+ else:
91
+ qs._where_condition = qs._where_condition & combined_condition
92
+
93
+ return qs
94
+
95
+ def compile_expression(self, expression: CompileExpression) -> Self:
96
+ qs = copy(self)
97
+ qs._compile_expressions = self._compile_expressions.copy()
98
+ qs._compile_expressions.append(expression)
99
+ return qs
100
+
101
+ def comment(self, text: str, *, hint: bool = False) -> Self:
102
+ def _add_comment(
103
+ sql: str,
104
+ params: tuple[Any, ...],
105
+ ) -> tuple[str, tuple[Any, ...]]:
106
+ prefix = "+ " if hint else " "
107
+ return f"/*{prefix}{text} */\n{sql}", params
108
+
109
+ return self.compile_expression(_add_comment)
110
+
111
+ def explain(
112
+ self,
113
+ *,
114
+ analyze: bool = False,
115
+ verbose: bool = False,
116
+ ) -> Self:
117
+ def _add_explain(
118
+ sql: str,
119
+ params: tuple[Any, ...],
120
+ ) -> tuple[str, tuple[Any, ...]]:
121
+ explain_parts = ["EXPLAIN"]
122
+ if analyze:
123
+ explain_parts.append("ANALYZE")
124
+ if verbose:
125
+ explain_parts.append("VERBOSE")
126
+ explain_parts.append(sql)
127
+ return " ".join(explain_parts), params
128
+
129
+ return self.compile_expression(_add_explain)
130
+
131
+ def analyze(self, *, verbose: bool = False) -> Self:
132
+ return self.explain(analyze=True, verbose=verbose)
133
+
134
+ def before_clause(
135
+ self,
136
+ clause: str,
137
+ text: str,
138
+ *,
139
+ hint: bool = False,
140
+ ) -> Self:
141
+ qs = copy(self)
142
+ qs._before_clause_comments = {
143
+ key: value.copy()
144
+ for key, value in self._before_clause_comments.items()
145
+ }
146
+ qs._after_clause_comments = {
147
+ key: value.copy()
148
+ for key, value in self._after_clause_comments.items()
149
+ }
150
+ clause_key = clause.upper()
151
+ qs._before_clause_comments.setdefault(clause_key, []).append(
152
+ (text, hint),
153
+ )
154
+ return qs
155
+
156
+ def after_clause(
157
+ self,
158
+ clause: str,
159
+ text: str,
160
+ *,
161
+ hint: bool = False,
162
+ ) -> Self:
163
+ qs = copy(self)
164
+ qs._before_clause_comments = {
165
+ key: value.copy()
166
+ for key, value in self._before_clause_comments.items()
167
+ }
168
+ qs._after_clause_comments = {
169
+ key: value.copy()
170
+ for key, value in self._after_clause_comments.items()
171
+ }
172
+ clause_key = clause.upper()
173
+ qs._after_clause_comments.setdefault(clause_key, []).append(
174
+ (text, hint),
175
+ )
176
+ return qs
177
+
178
+ def where_by(
179
+ self,
180
+ **kwargs: Any,
181
+ ) -> Self:
182
+ qs = copy(self)
183
+ combined_condition: Condition | None = None
184
+ table = self._get_table()
185
+ self._alias_registry.get_alias_for_table(table)
186
+
187
+ for key, value in kwargs.items():
188
+ col: Column = Column(key)
189
+ col._attach_table(table) # pyright: ignore[reportPrivateUsage]
190
+ condition = Condition(
191
+ column=col,
192
+ operator=EqualOperator,
193
+ value=value,
194
+ )
195
+ if combined_condition is None:
196
+ combined_condition = condition
197
+ else:
198
+ combined_condition = combined_condition & condition
199
+
200
+ if combined_condition:
201
+ if qs._where_condition is None:
202
+ qs._where_condition = combined_condition
203
+ else:
204
+ qs._where_condition = qs._where_condition & combined_condition
205
+
206
+ return qs
207
+
208
+ def with_(self, *, recursive: bool = False, **ctes: AbstractQuery) -> Self:
209
+ if not ctes:
210
+ raise ValueError("No CTEs provided for with_")
211
+
212
+ qs = copy(self)
213
+ qs._ctes = self._ctes.copy()
214
+ qs._with_recursive = self._with_recursive or recursive
215
+ qs._ctes.extend(ctes.items())
216
+
217
+ return qs
218
+
219
+ def _build_with_clause(
220
+ self,
221
+ alias_registry: AliasRegistry | None = None,
222
+ ) -> tuple[str, list[Any]]:
223
+ if not self._ctes:
224
+ return "", []
225
+
226
+ registry = alias_registry or self._alias_registry
227
+ with_parts: list[str] = []
228
+ params: list[Any] = []
229
+
230
+ for name, query in self._ctes:
231
+ query_sql, query_params = query.build_query(registry)
232
+ with_parts.append(f'"{name}" AS ({query_sql})')
233
+ params.extend(query_params)
234
+
235
+ recursive_part = " RECURSIVE" if self._with_recursive else ""
236
+ keyword = f"WITH{recursive_part}"
237
+ return self._build_clause(
238
+ "WITH",
239
+ keyword,
240
+ ", ".join(with_parts),
241
+ ), params
242
+
243
+ def _apply_compile_expressions(
244
+ self,
245
+ sql: str,
246
+ params: tuple[Any, ...],
247
+ ) -> tuple[str, tuple[Any, ...]]:
248
+ for expression in self._compile_expressions:
249
+ sql, params = expression(sql, params)
250
+
251
+ return sql, params
252
+
253
+ def _build_clause(
254
+ self,
255
+ clause: str,
256
+ keyword: str,
257
+ body: str = "",
258
+ ) -> str:
259
+ before_comments = self._render_clause_comments(
260
+ self._before_clause_comments.get(clause.upper(), []),
261
+ leading=False,
262
+ )
263
+ after_comments = self._render_clause_comments(
264
+ self._after_clause_comments.get(clause.upper(), []),
265
+ leading=True,
266
+ )
267
+
268
+ if body:
269
+ separator = "" if after_comments else " "
270
+ return (
271
+ f"{before_comments}{keyword}{after_comments}{separator}{body}"
272
+ )
273
+
274
+ return f"{before_comments}{keyword}{after_comments}"
275
+
276
+ @staticmethod
277
+ def _render_clause_comments(
278
+ comments: list[tuple[str, bool]],
279
+ *,
280
+ leading: bool,
281
+ ) -> str:
282
+ if not comments:
283
+ return ""
284
+
285
+ rendered = [
286
+ f"/*+ {text} */" if hint else f"/* {text} */"
287
+ for text, hint in comments
288
+ ]
289
+ if leading:
290
+ return "".join(f" {comment}\n" for comment in rendered)
291
+ return "".join(f"{comment}\n" for comment in rendered)
292
+
293
+ def build_query(
294
+ self,
295
+ alias_registry: AliasRegistry | None = None,
296
+ ) -> tuple[str, tuple[Any, ...]]:
297
+ raise NotImplementedError()
298
+
299
+ def compile(self) -> tuple[str, tuple[Any, ...]]:
300
+ return self.build_query()
301
+
302
+
303
+ class ComparableExpression:
304
+ def _cond(
305
+ self,
306
+ operator: type[AbstractOperator],
307
+ other: object,
308
+ ) -> Condition:
309
+ return Condition(column=self, operator=operator, value=other)
310
+
311
+ def __eq__(self, other: object) -> Condition: # type: ignore[override]
312
+ return self._cond(EqualOperator, other)
313
+
314
+ def __ne__(self, other: object) -> Condition: # type: ignore[override]
315
+ return self._cond(NotEqualOperator, other)
316
+
317
+ def __lt__(self, other: Any) -> Condition:
318
+ return self._cond(LessThanOperator, other)
319
+
320
+ def __gt__(self, other: Any) -> Condition:
321
+ return self._cond(GreaterThanOperator, other)
322
+
323
+ def __le__(self, other: Any) -> Condition:
324
+ return self._cond(LessThanOrEqualOperator, other)
325
+
326
+ def __ge__(self, other: Any) -> Condition:
327
+ return self._cond(GreaterThanOrEqualOperator, other)
328
+
329
+ def __hash__(self) -> int:
330
+ raise TypeError(f"unhashable type: '{type(self).__name__}'")
331
+
332
+ def get_ref(self, alias_registry: AliasRegistry) -> str:
333
+ raise NotImplementedError()
334
+
335
+ def _binary_expression(
336
+ self,
337
+ operator: str,
338
+ other: Any,
339
+ *,
340
+ reverse: bool = False,
341
+ ) -> BinaryExpression:
342
+ if reverse:
343
+ return BinaryExpression(other, operator, self)
344
+ return BinaryExpression(self, operator, other)
345
+
346
+ def __add__(self, other: Any) -> BinaryExpression:
347
+ return self._binary_expression("+", other)
348
+
349
+ def __radd__(self, other: Any) -> BinaryExpression:
350
+ return self._binary_expression("+", other, reverse=True)
351
+
352
+ def __sub__(self, other: Any) -> BinaryExpression:
353
+ return self._binary_expression("-", other)
354
+
355
+ def __rsub__(self, other: Any) -> BinaryExpression:
356
+ return self._binary_expression("-", other, reverse=True)
357
+
358
+ def __mul__(self, other: Any) -> BinaryExpression:
359
+ return self._binary_expression("*", other)
360
+
361
+ def __rmul__(self, other: Any) -> BinaryExpression:
362
+ return self._binary_expression("*", other, reverse=True)
363
+
364
+ def __truediv__(self, other: Any) -> BinaryExpression:
365
+ return self._binary_expression("/", other)
366
+
367
+ def __rtruediv__(self, other: Any) -> BinaryExpression:
368
+ return self._binary_expression("/", other, reverse=True)
369
+
370
+
371
+ class BinaryExpression(ComparableExpression):
372
+ def __init__(self, left: Any, operator: str, right: Any) -> None:
373
+ self.left: Any = left
374
+ self.operator: str = operator
375
+ self.right: Any = right
376
+
377
+ @staticmethod
378
+ def _render_operand(
379
+ operand: Any,
380
+ alias_registry: AliasRegistry,
381
+ ) -> tuple[str, tuple[Any, ...]]:
382
+ if isinstance(operand, BinaryExpression):
383
+ sql, params = operand.to_sql(alias_registry)
384
+ return f"({sql})", params
385
+ if isinstance(operand, FunctionCall):
386
+ return operand.to_sql(alias_registry)
387
+ if isinstance(operand, Column | Alias):
388
+ return operand.get_ref(alias_registry), tuple()
389
+ if isinstance(operand, ComparableExpression):
390
+ return operand.get_ref(alias_registry), tuple()
391
+ return "?", (operand,)
392
+
393
+ def to_sql(
394
+ self,
395
+ alias_registry: AliasRegistry,
396
+ ) -> tuple[str, tuple[Any, ...]]:
397
+ left_sql, left_params = self._render_operand(self.left, alias_registry)
398
+ right_sql, right_params = self._render_operand(
399
+ self.right,
400
+ alias_registry,
401
+ )
402
+ return (
403
+ f"{left_sql} {self.operator} {right_sql}",
404
+ left_params + right_params,
405
+ )
406
+
407
+ def get_ref(self, alias_registry: AliasRegistry) -> str:
408
+ return self.to_sql(alias_registry)[0]
409
+
410
+
411
+ class Condition:
412
+ def __init__( # noqa: PLR0913
413
+ self,
414
+ column: ComparableExpression | FunctionCall | None = None,
415
+ operator: type[AbstractOperator] | None = None,
416
+ value: object | None = None,
417
+ is_and: bool = True,
418
+ left: Condition | None = None,
419
+ right: Condition | None = None,
420
+ negated: bool = False,
421
+ ) -> None:
422
+ self.column: ComparableExpression | FunctionCall | None = column
423
+ self.operator: type[AbstractOperator] | None = operator
424
+ self.value: object | None = value
425
+ self.is_and: bool = is_and
426
+ self.left: Condition | None = left
427
+ self.right: Condition | None = right
428
+ self.negated: bool = negated
429
+
430
+ @staticmethod
431
+ def _render_expression(
432
+ value: ComparableExpression | FunctionCall,
433
+ alias_registry: AliasRegistry,
434
+ ) -> tuple[str, tuple[Any, ...]]:
435
+ if isinstance(value, (BinaryExpression, FunctionCall)):
436
+ return value.to_sql(alias_registry)
437
+ return value.get_ref(alias_registry), tuple()
438
+
439
+ def __and__(self, other: Condition) -> Condition:
440
+ return Condition(is_and=True, left=self, right=other)
441
+
442
+ def __or__(self, other: Condition) -> Condition:
443
+ return Condition(is_and=False, left=self, right=other)
444
+
445
+ def __invert__(self) -> Condition:
446
+ result = copy(self)
447
+ result.negated = not self.negated
448
+ return result
449
+
450
+ def to_sql(
451
+ self,
452
+ alias_registry: AliasRegistry,
453
+ ) -> tuple[str, tuple[Any, ...]]:
454
+ def apply_negation(
455
+ sql: str,
456
+ params: tuple[Any, ...],
457
+ ) -> tuple[str, tuple[Any, ...]]:
458
+ if self.negated:
459
+ return (f"NOT ({sql})" if sql else "NOT", params)
460
+ return sql, params
461
+
462
+ if self.left and self.right:
463
+ left_sql, left_params = self.left.to_sql(alias_registry)
464
+ right_sql, right_params = self.right.to_sql(alias_registry)
465
+ operator_str: str = "AND" if self.is_and else "OR"
466
+ return apply_negation(
467
+ f"({left_sql} {operator_str} {right_sql})",
468
+ left_params + right_params,
469
+ )
470
+
471
+ if not self.column:
472
+ return apply_negation("", tuple())
473
+
474
+ col_ref, col_params = self._render_expression(
475
+ self.column,
476
+ alias_registry,
477
+ )
478
+ operator_class = self.operator
479
+ if operator_class is None:
480
+ return apply_negation(col_ref, col_params)
481
+
482
+ if isinstance(self.value, (ComparableExpression, FunctionCall)):
483
+ value_sql, value_params = self._render_expression(
484
+ self.value,
485
+ alias_registry,
486
+ )
487
+ sql, op_params = operator_class(col_ref).to_sql_ref(value_sql)
488
+ return apply_negation(sql, col_params + value_params + op_params)
489
+
490
+ if isinstance(self.value, AbstractQuery):
491
+ subquery_sql, subquery_params = self.value.build_query(
492
+ alias_registry,
493
+ )
494
+ return apply_negation(
495
+ f"{col_ref} {operator_class.sql_symbol} ({subquery_sql})",
496
+ col_params + subquery_params,
497
+ )
498
+
499
+ sql, op_params = operator_class(col_ref).to_sql(self.value)
500
+ return apply_negation(sql, col_params + op_params)
501
+
502
+
503
+ class Alias(ComparableExpression):
504
+ """Represents a named SQL alias."""
505
+
506
+ def __init__(self, name: str) -> None:
507
+ self.name: str = name
508
+
509
+ def get_ref(self, alias_registry: AliasRegistry) -> str: # noqa: ARG002
510
+ return f'"{self.name}"'
511
+
512
+ def __repr__(self) -> str:
513
+ return f"Alias({self.name!r})"
514
+
515
+ def to_sql(self, alias_registry: AliasRegistry) -> str:
516
+ return self.get_ref(alias_registry)
517
+
518
+
519
+ class FunctionCall(ComparableExpression):
520
+ """Represents a SQL function call with arguments."""
521
+
522
+ def __init__(self, name: str, *args: Any) -> None:
523
+ """Initialize a function call.
524
+
525
+ Args:
526
+ name: The SQL function name (e.g., 'SUM', 'COUNT', 'MAX').
527
+ *args: Arguments to pass to the function.
528
+
529
+ """
530
+ self.name: str = name
531
+ self.args: tuple[Any, ...] = args
532
+ self._alias: Alias | None = None
533
+
534
+ def as_(self, alias: Alias | str) -> FunctionCall:
535
+ """Attach a named alias to the function call."""
536
+ result = copy(self)
537
+ result._alias = alias if isinstance(alias, Alias) else Alias(alias)
538
+ return result
539
+
540
+ def get_alias(self) -> Alias | None:
541
+ return self._alias
542
+
543
+ def to_sql(
544
+ self,
545
+ alias_registry: AliasRegistry,
546
+ *,
547
+ include_alias: bool = False,
548
+ ) -> tuple[str, tuple[Any, ...]]:
549
+ """Convert function call to SQL and extract parameters.
550
+
551
+ Returns:
552
+ Tuple of (sql_string, parameters_tuple)
553
+
554
+ """
555
+ sql_args: list[str] = []
556
+ params: list[Any] = []
557
+
558
+ for arg in self.args:
559
+ if isinstance(arg, Column):
560
+ sql_args.append(arg.get_ref(alias_registry))
561
+
562
+ elif isinstance(arg, FunctionCall):
563
+ # Nested function call
564
+ nested_sql, nested_params = arg.to_sql(alias_registry)
565
+ sql_args.append(nested_sql)
566
+ params.extend(nested_params)
567
+ elif arg == "*":
568
+ # Special case for COUNT(*)
569
+ sql_args.append("*")
570
+ elif isinstance(arg, str):
571
+ # String literal - parameterized
572
+ sql_args.append("?")
573
+ params.append(arg)
574
+ elif isinstance(arg, (int, float)):
575
+ # Numeric literal - parameterized
576
+ sql_args.append("?")
577
+ params.append(arg)
578
+ else:
579
+ # Other types - parameterized
580
+ sql_args.append("?")
581
+ params.append(arg)
582
+
583
+ args_sql = ", ".join(sql_args)
584
+ sql = f"{self.name}({args_sql})"
585
+ if include_alias and self._alias is not None:
586
+ sql = f"{sql} AS {self._alias.get_ref(alias_registry)}"
587
+ return sql, tuple(params)
588
+
589
+ def __repr__(self) -> str:
590
+ args_repr = ", ".join(repr(arg) for arg in self.args)
591
+ if self._alias is None:
592
+ return f"FunctionCall({self.name}({args_repr}))"
593
+ return f"FunctionCall({self.name}({args_repr}) AS {self._alias!r})"
594
+
595
+ def __hash__(self) -> int:
596
+ raise TypeError(f"unhashable type: '{type(self).__name__}'")
597
+
598
+
599
+ class FunctionRegistry:
600
+ """Dynamic SQL function registry using __getattr__.
601
+
602
+ Allows arbitrary function calls without defining each one explicitly.
603
+ Any attribute access
604
+ returns a callable that creates FunctionCall instances.
605
+ """
606
+
607
+ def __getattr__(self, name: str) -> Callable[..., FunctionCall]:
608
+ """Return a callable for creating FunctionCall instances.
609
+
610
+ Args:
611
+ name: The function name (e.g., 'sum', 'count', 'my_custom_func').
612
+
613
+ Returns:
614
+ A callable that creates FunctionCall instances with uppercase name.
615
+
616
+ """
617
+
618
+ def function_call(*args: Any) -> FunctionCall:
619
+ return FunctionCall(name.upper(), *args)
620
+
621
+ return function_call
622
+
623
+
624
+ func = FunctionRegistry()
625
+
626
+
627
+ class Column(ComparableExpression):
628
+ def __init__(self, name: str) -> None:
629
+ self.name: str = name
630
+
631
+ def _attach_table(self, table: Table) -> None:
632
+ self.table = table
633
+
634
+ def get_ref(self, alias_registry: AliasRegistry) -> str:
635
+ alias = alias_registry.get_alias_for_table(self.table)
636
+ return f'"{alias.name}"."{self.name}"'
637
+
638
+ def like(self, pattern: str) -> Condition:
639
+ return Condition(column=self, operator=LikeOperator, value=pattern)
640
+
641
+ def ilike(self, pattern: str) -> Condition:
642
+ return Condition(column=self, operator=IlikeOperator, value=pattern)
643
+
644
+ def in_(self, values: tuple[Any, ...] | list[Any] | Any) -> Condition:
645
+ return Condition(column=self, operator=InOperator, value=values)
646
+
647
+ def not_in(self, values: tuple[Any, ...] | list[Any] | Any) -> Condition:
648
+ return Condition(column=self, operator=NotInOperator, value=values)
649
+
650
+
651
+ class Table:
652
+ def __init__(
653
+ self,
654
+ name: str | AbstractQuery,
655
+ ) -> None:
656
+ self._table_name: str = ""
657
+ self._subquery: AbstractQuery | None = None
658
+
659
+ if isinstance(name, AbstractQuery):
660
+ self._subquery = name
661
+ else:
662
+ self._table_name = name
663
+
664
+ def get_name(self) -> str:
665
+ if self._subquery is not None:
666
+ raise ValueError("Table is a subquery, no name available")
667
+ return self._table_name
668
+
669
+ def to_sql(
670
+ self,
671
+ alias_registry: AliasRegistry | None = None,
672
+ ) -> tuple[str, tuple[Any, ...]]:
673
+ if self._subquery is not None:
674
+ subquery_sql, subquery_params = self._subquery.build_query(
675
+ alias_registry,
676
+ )
677
+ return f"({subquery_sql})", subquery_params
678
+
679
+ return f'"{self._table_name}"', tuple()
680
+
681
+ def __getattr__(self, column_name: str) -> Column:
682
+ if column_name.startswith("_"):
683
+ raise AttributeError(
684
+ f"'{type(self).__name__}' "
685
+ f"object has no attribute '{column_name}'",
686
+ )
687
+ column = Column(column_name)
688
+ column._attach_table(self) # pyright: ignore[reportPrivateUsage]
689
+ return column