sql-athame 0.4.0a12__py3-none-any.whl → 0.4.0a13__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_athame/base.py +479 -0
- sql_athame/dataclasses.py +817 -23
- {sql_athame-0.4.0a12.dist-info → sql_athame-0.4.0a13.dist-info}/METADATA +7 -4
- {sql_athame-0.4.0a12.dist-info → sql_athame-0.4.0a13.dist-info}/RECORD +6 -6
- {sql_athame-0.4.0a12.dist-info → sql_athame-0.4.0a13.dist-info}/WHEEL +0 -0
- {sql_athame-0.4.0a12.dist-info → sql_athame-0.4.0a13.dist-info}/licenses/LICENSE +0 -0
sql_athame/base.py
CHANGED
@@ -23,6 +23,14 @@ auto_numbered_re = re.compile(r"[A-Za-z0-9_]")
|
|
23
23
|
|
24
24
|
|
25
25
|
def auto_numbered(field_name):
|
26
|
+
"""Check if a field name should be auto-numbered.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
field_name: The field name to check
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
True if the field name should be auto-numbered (doesn't start with alphanumeric)
|
33
|
+
"""
|
26
34
|
return not auto_numbered_re.match(field_name)
|
27
35
|
|
28
36
|
|
@@ -31,6 +39,16 @@ def process_slot_value(
|
|
31
39
|
value: Any,
|
32
40
|
placeholders: dict[str, Placeholder],
|
33
41
|
) -> Union["Fragment", Placeholder]:
|
42
|
+
"""Process a slot value during fragment compilation.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
name: The slot name
|
46
|
+
value: The value to process
|
47
|
+
placeholders: Dictionary of existing placeholders
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
Either a Fragment if value is a Fragment, or a Placeholder
|
51
|
+
"""
|
34
52
|
if isinstance(value, Fragment):
|
35
53
|
return value
|
36
54
|
else:
|
@@ -41,10 +59,43 @@ def process_slot_value(
|
|
41
59
|
|
42
60
|
@dataclasses.dataclass
|
43
61
|
class Fragment:
|
62
|
+
"""Core SQL fragment class representing a piece of SQL with placeholders.
|
63
|
+
|
64
|
+
A Fragment contains SQL text and placeholders that can be combined with other
|
65
|
+
fragments to build complex queries. Fragments automatically handle parameter
|
66
|
+
binding and can be rendered to parameterized queries suitable for database drivers.
|
67
|
+
|
68
|
+
Attributes:
|
69
|
+
parts: List of SQL parts (strings, placeholders, slots, or other fragments)
|
70
|
+
|
71
|
+
Example:
|
72
|
+
>>> from sql_athame import sql
|
73
|
+
>>> frag = sql("SELECT * FROM users WHERE id = {}", 42)
|
74
|
+
>>> query, params = frag.query()
|
75
|
+
>>> query
|
76
|
+
'SELECT * FROM users WHERE id = $1'
|
77
|
+
>>> params
|
78
|
+
[42]
|
79
|
+
|
80
|
+
>>> # Fragments can be combined
|
81
|
+
>>> where_clause = sql("active = {}", True)
|
82
|
+
>>> full_query = sql("SELECT * FROM users WHERE id = {} AND {}", 42, where_clause)
|
83
|
+
>>> list(full_query)
|
84
|
+
['SELECT * FROM users WHERE id = $1 AND active = $2', 42, True]
|
85
|
+
"""
|
86
|
+
|
44
87
|
__slots__ = ["parts"]
|
45
88
|
parts: list[Part]
|
46
89
|
|
47
90
|
def flatten_into(self, parts: list[FlatPart]) -> None:
|
91
|
+
"""Recursively flatten this fragment into a list of flat parts.
|
92
|
+
|
93
|
+
This method traverses nested fragments and adds all parts to the provided list.
|
94
|
+
String parts are not combined here - that's done in flatten().
|
95
|
+
|
96
|
+
Args:
|
97
|
+
parts: List to append flattened parts to
|
98
|
+
"""
|
48
99
|
for part in self.parts:
|
49
100
|
if isinstance(part, Fragment):
|
50
101
|
part.flatten_into(parts)
|
@@ -52,6 +103,21 @@ class Fragment:
|
|
52
103
|
parts.append(part)
|
53
104
|
|
54
105
|
def compile(self) -> Callable[..., "Fragment"]:
|
106
|
+
"""Create an optimized function for filling slots in this fragment.
|
107
|
+
|
108
|
+
Returns a compiled function that when called with **kwargs will create a new
|
109
|
+
Fragment equivalent to calling self.fill(**kwargs). The compilation process
|
110
|
+
does as much work as possible up front, making repeated slot filling much faster.
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Function that takes **kwargs and returns a Fragment with slots filled
|
114
|
+
|
115
|
+
Example:
|
116
|
+
>>> template = sql("SELECT * FROM users WHERE name = {name} AND age > {age}")
|
117
|
+
>>> compiled_template = template.compile()
|
118
|
+
>>> query1 = compiled_template(name="Alice", age=25)
|
119
|
+
>>> query2 = compiled_template(name="Bob", age=30)
|
120
|
+
"""
|
55
121
|
flattened = self.flatten()
|
56
122
|
env = dict(
|
57
123
|
process_slot_value=process_slot_value,
|
@@ -77,6 +143,14 @@ class Fragment:
|
|
77
143
|
return env["compiled"] # type: ignore
|
78
144
|
|
79
145
|
def flatten(self) -> "Fragment":
|
146
|
+
"""Create a flattened version of this fragment.
|
147
|
+
|
148
|
+
Recursively flattens all nested fragments and combines adjacent string parts
|
149
|
+
into single strings for efficiency.
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
New Fragment with no nested fragments and adjacent strings combined
|
153
|
+
"""
|
80
154
|
parts: list[FlatPart] = []
|
81
155
|
self.flatten_into(parts)
|
82
156
|
out_parts: list[Part] = []
|
@@ -88,6 +162,24 @@ class Fragment:
|
|
88
162
|
return Fragment(out_parts)
|
89
163
|
|
90
164
|
def fill(self, **kwargs: Any) -> "Fragment":
|
165
|
+
"""Create a new fragment by filling any empty slots with provided values.
|
166
|
+
|
167
|
+
Searches for Slot objects in this fragment and replaces them with the
|
168
|
+
corresponding values from kwargs. If a value is a Fragment, it's substituted
|
169
|
+
in-place; otherwise it becomes a placeholder.
|
170
|
+
|
171
|
+
Args:
|
172
|
+
**kwargs: Named values to fill into slots
|
173
|
+
|
174
|
+
Returns:
|
175
|
+
New Fragment with slots filled
|
176
|
+
|
177
|
+
Example:
|
178
|
+
>>> template = sql("SELECT * FROM {table} WHERE id = {id}")
|
179
|
+
>>> query = template.fill(table=sql.identifier("users"), id=42)
|
180
|
+
>>> list(query)
|
181
|
+
['SELECT * FROM "users" WHERE id = $1', 42]
|
182
|
+
"""
|
91
183
|
parts: list[Part] = []
|
92
184
|
self.flatten_into(cast(list[FlatPart], parts))
|
93
185
|
placeholders: dict[str, Placeholder] = {}
|
@@ -109,6 +201,20 @@ class Fragment:
|
|
109
201
|
) -> tuple[str, list[Placeholder]]: ... # pragma: no cover
|
110
202
|
|
111
203
|
def prep_query(self, allow_slots: bool = False) -> tuple[str, list[Any]]:
|
204
|
+
"""Prepare the fragment for query execution.
|
205
|
+
|
206
|
+
Flattens the fragment and converts placeholders to numbered parameters ($1, $2, etc.)
|
207
|
+
suitable for database drivers like asyncpg.
|
208
|
+
|
209
|
+
Args:
|
210
|
+
allow_slots: If True, allows unfilled slots; if False, raises ValueError for unfilled slots
|
211
|
+
|
212
|
+
Returns:
|
213
|
+
Tuple of (query_string, parameter_objects)
|
214
|
+
|
215
|
+
Raises:
|
216
|
+
ValueError: If allow_slots is False and there are unfilled slots
|
217
|
+
"""
|
112
218
|
parts: list[FlatPart] = []
|
113
219
|
self.flatten_into(parts)
|
114
220
|
args: list[Union[Placeholder, Slot]] = []
|
@@ -134,14 +240,63 @@ class Fragment:
|
|
134
240
|
return "".join(out_parts).strip(), args
|
135
241
|
|
136
242
|
def query(self) -> tuple[str, list[Any]]:
|
243
|
+
"""Render the fragment into a query string and parameter list.
|
244
|
+
|
245
|
+
Returns:
|
246
|
+
Tuple of (query_string, parameter_values) ready for database execution
|
247
|
+
|
248
|
+
Raises:
|
249
|
+
ValueError: If there are any unfilled slots
|
250
|
+
|
251
|
+
Example:
|
252
|
+
>>> frag = sql("SELECT * FROM users WHERE id = {}", 42)
|
253
|
+
>>> frag.query()
|
254
|
+
('SELECT * FROM users WHERE id = $1', [42])
|
255
|
+
"""
|
137
256
|
query, args = self.prep_query()
|
138
257
|
placeholder_values = [arg.value for arg in args]
|
139
258
|
return query, placeholder_values
|
140
259
|
|
141
260
|
def sqlalchemy_text(self) -> Any:
|
261
|
+
"""Convert this fragment to a SQLAlchemy TextClause.
|
262
|
+
|
263
|
+
Renders the fragment into a SQLAlchemy TextClause with bound parameters.
|
264
|
+
Placeholder values will be bound with bindparams. Unfilled slots will be
|
265
|
+
included as unbound parameters.
|
266
|
+
|
267
|
+
Returns:
|
268
|
+
SQLAlchemy TextClause object
|
269
|
+
|
270
|
+
Raises:
|
271
|
+
ImportError: If SQLAlchemy is not installed
|
272
|
+
|
273
|
+
Example:
|
274
|
+
>>> frag = sql("SELECT * FROM users WHERE id = {}", 42)
|
275
|
+
>>> text_clause = frag.sqlalchemy_text()
|
276
|
+
>>> # Can be used with SQLAlchemy engine.execute(text_clause)
|
277
|
+
"""
|
142
278
|
return sqlalchemy_text_from_fragment(self)
|
143
279
|
|
144
280
|
def prepare(self) -> tuple[str, Callable[..., list[Any]]]:
|
281
|
+
"""Prepare fragment for use with prepared statements.
|
282
|
+
|
283
|
+
Returns a query string and a function that generates parameter lists.
|
284
|
+
The query string contains numbered placeholders, and the function takes
|
285
|
+
**kwargs for any unfilled slots and returns the complete parameter list.
|
286
|
+
|
287
|
+
Returns:
|
288
|
+
Tuple of (query_string, parameter_generator_function)
|
289
|
+
|
290
|
+
Example:
|
291
|
+
>>> template = sql("UPDATE users SET name={name}, age={age} WHERE id < {}", 100)
|
292
|
+
>>> query, param_func = template.prepare()
|
293
|
+
>>> query
|
294
|
+
'UPDATE users SET name=$1, age=$2 WHERE id < $3'
|
295
|
+
>>> param_func(name="Alice", age=25)
|
296
|
+
['Alice', 25, 100]
|
297
|
+
>>> param_func(name="Bob", age=30)
|
298
|
+
['Bob', 30, 100]
|
299
|
+
"""
|
145
300
|
query, args = self.prep_query(allow_slots=True)
|
146
301
|
env = {}
|
147
302
|
func = [
|
@@ -159,17 +314,95 @@ class Fragment:
|
|
159
314
|
return query, env["generate_args"] # type: ignore
|
160
315
|
|
161
316
|
def __iter__(self) -> Iterator[Any]:
|
317
|
+
"""Make Fragment iterable for use with asyncpg and similar drivers.
|
318
|
+
|
319
|
+
Returns an iterator that yields the query string followed by all parameter
|
320
|
+
values. This matches the (query, *args) calling convention of asyncpg.
|
321
|
+
|
322
|
+
Yields:
|
323
|
+
Query string, then each parameter value
|
324
|
+
|
325
|
+
Example:
|
326
|
+
>>> frag = sql("SELECT * FROM users WHERE id = {} AND name = {}", 42, "Alice")
|
327
|
+
>>> list(frag)
|
328
|
+
['SELECT * FROM users WHERE id = $1 AND name = $2', 42, 'Alice']
|
329
|
+
>>> # Can be used directly with asyncpg
|
330
|
+
>>> await conn.fetch(*frag)
|
331
|
+
"""
|
162
332
|
sql, args = self.query()
|
163
333
|
return iter((sql, *args))
|
164
334
|
|
165
335
|
def join(self, parts: Iterable["Fragment"]) -> "Fragment":
|
336
|
+
"""Join multiple fragments using this fragment as a separator.
|
337
|
+
|
338
|
+
Creates a new fragment by joining the provided fragments with this fragment
|
339
|
+
as the separator between them.
|
340
|
+
|
341
|
+
Args:
|
342
|
+
parts: Iterable of Fragment objects to join
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
New Fragment with parts joined by this fragment
|
346
|
+
|
347
|
+
Example:
|
348
|
+
>>> separator = sql(" AND ")
|
349
|
+
>>> conditions = [sql("a = {}", 1), sql("b = {}", 2), sql("c = {}", 3)]
|
350
|
+
>>> result = separator.join(conditions)
|
351
|
+
>>> list(result)
|
352
|
+
['a = $1 AND b = $2 AND c = $3', 1, 2, 3]
|
353
|
+
|
354
|
+
>>> # More commonly used for CASE statements
|
355
|
+
>>> clauses = [sql("WHEN {} THEN {}", x, y) for x, y in [("a", 1), ("b", 2)]]
|
356
|
+
>>> case = sql("CASE {clauses} END", clauses=sql(" ").join(clauses))
|
357
|
+
"""
|
166
358
|
return Fragment(list(join_parts(parts, infix=self)))
|
167
359
|
|
168
360
|
|
169
361
|
class SQLFormatter:
|
362
|
+
"""Main SQL formatting class providing the sql() function and utility methods.
|
363
|
+
|
364
|
+
This class is instantiated as the global 'sql' object that provides the primary
|
365
|
+
interface for building SQL fragments. It supports format string syntax with
|
366
|
+
placeholders and provides utility methods for common SQL operations.
|
367
|
+
"""
|
368
|
+
|
170
369
|
def __call__(
|
171
370
|
self, fmt: str, *args: Any, preserve_formatting: bool = False, **kwargs: Any
|
172
371
|
) -> Fragment:
|
372
|
+
"""Create a SQL Fragment from a format string with placeholders.
|
373
|
+
|
374
|
+
The format string contains literal SQL and may contain positional references
|
375
|
+
marked by {} and named references marked by {name}. Positional references
|
376
|
+
must have matching arguments in *args. Named references may have matching
|
377
|
+
arguments in **kwargs; if not provided, they remain as named slots to be
|
378
|
+
filled later.
|
379
|
+
|
380
|
+
If a referenced argument is a Fragment, it is substituted into the SQL
|
381
|
+
along with its embedded placeholders. Otherwise, it becomes a placeholder value.
|
382
|
+
|
383
|
+
Args:
|
384
|
+
fmt: SQL format string with {} placeholders
|
385
|
+
*args: Positional arguments for {} placeholders
|
386
|
+
preserve_formatting: If True, preserve whitespace; if False, normalize whitespace
|
387
|
+
**kwargs: Named arguments for {name} placeholders
|
388
|
+
|
389
|
+
Returns:
|
390
|
+
Fragment containing the SQL with placeholders
|
391
|
+
|
392
|
+
Raises:
|
393
|
+
ValueError: If there are unfilled positional arguments
|
394
|
+
|
395
|
+
Example:
|
396
|
+
>>> sql("SELECT * FROM users WHERE id = {}", 42)
|
397
|
+
Fragment(['SELECT * FROM users WHERE id = ', Placeholder('0', 42)])
|
398
|
+
|
399
|
+
>>> sql("SELECT * FROM users WHERE id = {id} AND name = {name}", id=42, name="Alice")
|
400
|
+
Fragment(['SELECT * FROM users WHERE id = ', Placeholder('id', 42), ' AND name = ', Placeholder('name', 'Alice')])
|
401
|
+
|
402
|
+
>>> # Fragments can be embedded
|
403
|
+
>>> where_clause = sql("active = {}", True)
|
404
|
+
>>> sql("SELECT * FROM users WHERE {}", where_clause)
|
405
|
+
"""
|
173
406
|
if not preserve_formatting:
|
174
407
|
fmt = newline_whitespace_re.sub(" ", fmt)
|
175
408
|
fmtr = string.Formatter()
|
@@ -198,23 +431,110 @@ class SQLFormatter:
|
|
198
431
|
|
199
432
|
@staticmethod
|
200
433
|
def value(value: Any) -> Fragment:
|
434
|
+
"""Create a Fragment with a single placeholder value.
|
435
|
+
|
436
|
+
Equivalent to sql("{}", value) but more explicit.
|
437
|
+
|
438
|
+
Args:
|
439
|
+
value: The value to create a placeholder for
|
440
|
+
|
441
|
+
Returns:
|
442
|
+
Fragment containing a single placeholder
|
443
|
+
|
444
|
+
Example:
|
445
|
+
>>> sql.value(42)
|
446
|
+
Fragment([Placeholder('value', 42)])
|
447
|
+
"""
|
201
448
|
placeholder = Placeholder("value", value)
|
202
449
|
return Fragment([placeholder])
|
203
450
|
|
204
451
|
@staticmethod
|
205
452
|
def escape(value: Any) -> Fragment:
|
453
|
+
"""Create a Fragment with a value escaped and embedded into the SQL.
|
454
|
+
|
455
|
+
Unlike placeholders, escaped values are embedded directly into the SQL text.
|
456
|
+
Types currently supported are strings, floats, ints, UUIDs, None, and
|
457
|
+
sequences of the above. Use with caution and only for trusted values.
|
458
|
+
|
459
|
+
Args:
|
460
|
+
value: The value to escape and embed
|
461
|
+
|
462
|
+
Returns:
|
463
|
+
Fragment with the escaped value as literal SQL
|
464
|
+
|
465
|
+
Example:
|
466
|
+
>>> list(sql("SELECT * FROM tbl WHERE qty = ANY({})", sql.escape([1, 3, 5])))
|
467
|
+
['SELECT * FROM tbl WHERE qty = ANY(ARRAY[1, 3, 5])']
|
468
|
+
|
469
|
+
>>> # Compare to placeholder version:
|
470
|
+
>>> list(sql("SELECT * FROM tbl WHERE qty = ANY({})", [1, 3, 5]))
|
471
|
+
['SELECT * FROM tbl WHERE qty = ANY($1)', [1, 3, 5]]
|
472
|
+
|
473
|
+
Note:
|
474
|
+
Burning invariant values into the query can potentially help the query optimizer.
|
475
|
+
"""
|
206
476
|
return lit(escape(value))
|
207
477
|
|
208
478
|
@staticmethod
|
209
479
|
def slot(name: str) -> Fragment:
|
480
|
+
"""Create a Fragment with a single empty slot.
|
481
|
+
|
482
|
+
Equivalent to sql("{name}") but more explicit.
|
483
|
+
|
484
|
+
Args:
|
485
|
+
name: The name of the slot
|
486
|
+
|
487
|
+
Returns:
|
488
|
+
Fragment containing a single slot
|
489
|
+
|
490
|
+
Example:
|
491
|
+
>>> template = sql("SELECT * FROM users WHERE {}", sql.slot("condition"))
|
492
|
+
>>> query = template.fill(condition=sql("active = {}", True))
|
493
|
+
"""
|
210
494
|
return Fragment([Slot(name)])
|
211
495
|
|
212
496
|
@staticmethod
|
213
497
|
def literal(text: str) -> Fragment:
|
498
|
+
"""Create a Fragment with literal SQL text.
|
499
|
+
|
500
|
+
No substitution of any kind is performed. Be very careful of SQL injection
|
501
|
+
when using this method.
|
502
|
+
|
503
|
+
Args:
|
504
|
+
text: Raw SQL text to include
|
505
|
+
|
506
|
+
Returns:
|
507
|
+
Fragment containing the literal SQL
|
508
|
+
|
509
|
+
Warning:
|
510
|
+
Only use with trusted SQL text to avoid SQL injection vulnerabilities.
|
511
|
+
|
512
|
+
Example:
|
513
|
+
>>> sql.literal("ORDER BY created_at DESC")
|
514
|
+
Fragment(['ORDER BY created_at DESC'])
|
515
|
+
"""
|
214
516
|
return Fragment([text])
|
215
517
|
|
216
518
|
@staticmethod
|
217
519
|
def identifier(name: str, prefix: Optional[str] = None) -> Fragment:
|
520
|
+
"""Create a Fragment with a quoted SQL identifier.
|
521
|
+
|
522
|
+
Creates a properly quoted identifier name, optionally with a dotted prefix
|
523
|
+
for schema or table qualification.
|
524
|
+
|
525
|
+
Args:
|
526
|
+
name: The identifier name to quote
|
527
|
+
prefix: Optional prefix (schema, table, etc.)
|
528
|
+
|
529
|
+
Returns:
|
530
|
+
Fragment containing the quoted identifier
|
531
|
+
|
532
|
+
Example:
|
533
|
+
>>> list(sql("SELECT {col} FROM {table}",
|
534
|
+
... col=sql.identifier("user_name"),
|
535
|
+
... table=sql.identifier("users", prefix="public")))
|
536
|
+
['SELECT "user_name" FROM "public"."users"']
|
537
|
+
"""
|
218
538
|
if prefix:
|
219
539
|
return lit(f"{quote_identifier(prefix)}.{quote_identifier(name)}")
|
220
540
|
else:
|
@@ -227,6 +547,24 @@ class SQLFormatter:
|
|
227
547
|
def all(self, *parts: Fragment) -> Fragment: ... # pragma: no cover
|
228
548
|
|
229
549
|
def all(self, *parts) -> Fragment: # type: ignore
|
550
|
+
"""Join fragments with AND, returning TRUE if no parts provided.
|
551
|
+
|
552
|
+
Creates a SQL Fragment joining the fragments in parts together with AND.
|
553
|
+
If parts is empty, returns TRUE. Each fragment is wrapped in parentheses.
|
554
|
+
|
555
|
+
Args:
|
556
|
+
*parts: Fragment objects to join, or single iterable of fragments
|
557
|
+
|
558
|
+
Returns:
|
559
|
+
Fragment containing the AND-joined conditions
|
560
|
+
|
561
|
+
Example:
|
562
|
+
>>> where = [sql("a = {}", 42), sql("x <> {}", "foo")]
|
563
|
+
>>> list(sql("SELECT * FROM tbl WHERE {}", sql.all(where)))
|
564
|
+
['SELECT * FROM tbl WHERE (a = $1) AND (x <> $2)', 42, 'foo']
|
565
|
+
>>> list(sql("SELECT * FROM tbl WHERE {}", sql.all([])))
|
566
|
+
['SELECT * FROM tbl WHERE TRUE']
|
567
|
+
"""
|
230
568
|
if parts and not isinstance(parts[0], Fragment):
|
231
569
|
parts = parts[0]
|
232
570
|
return any_all(list(parts), "AND", "TRUE")
|
@@ -238,6 +576,24 @@ class SQLFormatter:
|
|
238
576
|
def any(self, *parts: Fragment) -> Fragment: ... # pragma: no cover
|
239
577
|
|
240
578
|
def any(self, *parts) -> Fragment: # type: ignore
|
579
|
+
"""Join fragments with OR, returning FALSE if no parts provided.
|
580
|
+
|
581
|
+
Creates a SQL Fragment joining the fragments in parts together with OR.
|
582
|
+
If parts is empty, returns FALSE. Each fragment is wrapped in parentheses.
|
583
|
+
|
584
|
+
Args:
|
585
|
+
*parts: Fragment objects to join, or single iterable of fragments
|
586
|
+
|
587
|
+
Returns:
|
588
|
+
Fragment containing the OR-joined conditions
|
589
|
+
|
590
|
+
Example:
|
591
|
+
>>> where = [sql("a = {}", 42), sql("x <> {}", "foo")]
|
592
|
+
>>> list(sql("SELECT * FROM tbl WHERE {}", sql.any(where)))
|
593
|
+
['SELECT * FROM tbl WHERE (a = $1) OR (x <> $2)', 42, 'foo']
|
594
|
+
>>> list(sql("SELECT * FROM tbl WHERE {}", sql.any([])))
|
595
|
+
['SELECT * FROM tbl WHERE FALSE']
|
596
|
+
"""
|
241
597
|
if parts and not isinstance(parts[0], Fragment):
|
242
598
|
parts = parts[0]
|
243
599
|
return any_all(list(parts), "OR", "FALSE")
|
@@ -249,11 +605,49 @@ class SQLFormatter:
|
|
249
605
|
def list(self, *parts: Fragment) -> Fragment: ... # pragma: no cover
|
250
606
|
|
251
607
|
def list(self, *parts) -> Fragment: # type: ignore
|
608
|
+
"""Join fragments with commas.
|
609
|
+
|
610
|
+
Creates a SQL Fragment joining the fragments in parts together with commas.
|
611
|
+
Commonly used for column lists, value lists, etc.
|
612
|
+
|
613
|
+
Args:
|
614
|
+
*parts: Fragment objects to join, or single iterable of fragments
|
615
|
+
|
616
|
+
Returns:
|
617
|
+
Fragment containing the comma-separated fragments
|
618
|
+
|
619
|
+
Example:
|
620
|
+
>>> cols = [sql.identifier("id"), sql.identifier("name"), sql.identifier("email")]
|
621
|
+
>>> list(sql("SELECT {} FROM users", sql.list(cols)))
|
622
|
+
['SELECT "id", "name", "email" FROM users']
|
623
|
+
"""
|
252
624
|
if parts and not isinstance(parts[0], Fragment):
|
253
625
|
parts = parts[0]
|
254
626
|
return Fragment(list(join_parts(parts, infix=", ")))
|
255
627
|
|
256
628
|
def unnest(self, data: Iterable[Sequence[Any]], types: Iterable[str]) -> Fragment:
|
629
|
+
"""Create a Fragment containing an UNNEST expression with associated data.
|
630
|
+
|
631
|
+
The data is specified as tuples (in the "database columns" sense) in data,
|
632
|
+
and the PostgreSQL types must be specified in types. The data is transposed
|
633
|
+
into the correct form for UNNEST and embedded as placeholders.
|
634
|
+
|
635
|
+
Args:
|
636
|
+
data: Iterable of sequences, where each sequence represents a row
|
637
|
+
types: Iterable of PostgreSQL type names for each column
|
638
|
+
|
639
|
+
Returns:
|
640
|
+
Fragment containing UNNEST expression with typed array placeholders
|
641
|
+
|
642
|
+
Example:
|
643
|
+
>>> list(sql("SELECT * FROM {}", sql.unnest([("a", 1), ("b", 2), ("c", 3)], ["text", "integer"])))
|
644
|
+
['SELECT * FROM UNNEST($1::text[], $2::integer[])', ('a', 'b', 'c'), (1, 2, 3)]
|
645
|
+
|
646
|
+
>>> # Useful for bulk operations
|
647
|
+
>>> users_data = [("Alice", 25), ("Bob", 30), ("Charlie", 35)]
|
648
|
+
>>> insert_query = sql("INSERT INTO users (name, age) SELECT * FROM {}",
|
649
|
+
... sql.unnest(users_data, ["text", "integer"]))
|
650
|
+
"""
|
257
651
|
nested = [nest_for_type(x, t) for x, t in zip(zip(*data), types)]
|
258
652
|
if not nested:
|
259
653
|
nested = [nest_for_type([], t) for t in types]
|
@@ -261,16 +655,50 @@ class SQLFormatter:
|
|
261
655
|
|
262
656
|
|
263
657
|
sql = SQLFormatter()
|
658
|
+
"""Global SQLFormatter instance providing the main sql() function and utilities.
|
659
|
+
|
660
|
+
This is the primary interface for creating SQL fragments. Use it to:
|
661
|
+
- Create fragments: sql("SELECT * FROM users WHERE id = {}", user_id)
|
662
|
+
- Join fragments: sql.all([condition1, condition2])
|
663
|
+
- Create identifiers: sql.identifier("table_name")
|
664
|
+
- And much more
|
665
|
+
|
666
|
+
See SQLFormatter class documentation for all available methods.
|
667
|
+
"""
|
264
668
|
|
265
669
|
|
266
670
|
json_types = ("JSON", "JSONB")
|
267
671
|
|
268
672
|
|
269
673
|
def is_json_type(typename: str) -> bool:
|
674
|
+
"""Check if a type name represents a JSON type.
|
675
|
+
|
676
|
+
Args:
|
677
|
+
typename: PostgreSQL type name to check
|
678
|
+
|
679
|
+
Returns:
|
680
|
+
True if typename is JSON or JSONB (case insensitive)
|
681
|
+
"""
|
270
682
|
return typename.upper() in json_types
|
271
683
|
|
272
684
|
|
273
685
|
def nest_for_type(data: Sequence[Any], typename: str) -> Fragment:
|
686
|
+
"""Create a typed array fragment for UNNEST operations.
|
687
|
+
|
688
|
+
Converts a sequence of data into a properly typed PostgreSQL array fragment.
|
689
|
+
Handles JSON/JSONB types specially by converting objects to JSON strings.
|
690
|
+
|
691
|
+
Args:
|
692
|
+
data: Sequence of values to convert to array
|
693
|
+
typename: PostgreSQL type name for the array
|
694
|
+
|
695
|
+
Returns:
|
696
|
+
Fragment containing a typed array placeholder
|
697
|
+
|
698
|
+
Note:
|
699
|
+
For JSON/JSONB types, non-string values are converted to JSON strings
|
700
|
+
to work around asyncpg limitations.
|
701
|
+
"""
|
274
702
|
if is_json_type(typename):
|
275
703
|
# https://github.com/MagicStack/asyncpg/issues/345
|
276
704
|
|
@@ -287,10 +715,33 @@ def nest_for_type(data: Sequence[Any], typename: str) -> Fragment:
|
|
287
715
|
|
288
716
|
|
289
717
|
def lit(text: str) -> Fragment:
|
718
|
+
"""Create a Fragment containing literal SQL text.
|
719
|
+
|
720
|
+
Convenience function equivalent to Fragment([text]).
|
721
|
+
|
722
|
+
Args:
|
723
|
+
text: Literal SQL text
|
724
|
+
|
725
|
+
Returns:
|
726
|
+
Fragment containing the literal text
|
727
|
+
"""
|
290
728
|
return Fragment([text])
|
291
729
|
|
292
730
|
|
293
731
|
def any_all(frags: list[Fragment], op: str, base_case: str) -> Fragment:
|
732
|
+
"""Join fragments with a logical operator, with a base case for empty lists.
|
733
|
+
|
734
|
+
Used by sql.all() and sql.any() to implement AND/OR joining with appropriate
|
735
|
+
base cases (TRUE for AND, FALSE for OR).
|
736
|
+
|
737
|
+
Args:
|
738
|
+
frags: List of fragments to join
|
739
|
+
op: Operator to use for joining ("AND" or "OR")
|
740
|
+
base_case: Value to return if frags is empty ("TRUE" or "FALSE")
|
741
|
+
|
742
|
+
Returns:
|
743
|
+
Fragment with fragments joined by operator, or base case if empty
|
744
|
+
"""
|
294
745
|
if not frags:
|
295
746
|
return lit(base_case)
|
296
747
|
parts = join_parts(frags, prefix="(", infix=f") {op} (", suffix=")")
|
@@ -303,6 +754,20 @@ def join_parts(
|
|
303
754
|
prefix: Optional[Part] = None,
|
304
755
|
suffix: Optional[Part] = None,
|
305
756
|
) -> Iterator[Part]:
|
757
|
+
"""Join parts with a separator, optionally adding prefix and suffix.
|
758
|
+
|
759
|
+
Generator function that yields parts with infix separators between them,
|
760
|
+
and optional prefix/suffix parts.
|
761
|
+
|
762
|
+
Args:
|
763
|
+
parts: Parts to join
|
764
|
+
infix: Separator to place between parts
|
765
|
+
prefix: Optional part to yield first
|
766
|
+
suffix: Optional part to yield last
|
767
|
+
|
768
|
+
Yields:
|
769
|
+
Parts with separators, prefix, and suffix as appropriate
|
770
|
+
"""
|
306
771
|
if prefix:
|
307
772
|
yield prefix
|
308
773
|
for i, part in enumerate(parts):
|
@@ -314,5 +779,19 @@ def join_parts(
|
|
314
779
|
|
315
780
|
|
316
781
|
def quote_identifier(name: str) -> str:
|
782
|
+
"""Quote a SQL identifier with double quotes, escaping internal quotes.
|
783
|
+
|
784
|
+
Args:
|
785
|
+
name: Identifier name to quote
|
786
|
+
|
787
|
+
Returns:
|
788
|
+
Quoted identifier with internal quotes escaped
|
789
|
+
|
790
|
+
Example:
|
791
|
+
>>> quote_identifier("user_name")
|
792
|
+
'"user_name"'
|
793
|
+
>>> quote_identifier('table"with"quotes')
|
794
|
+
'"table""with""quotes"'
|
795
|
+
"""
|
317
796
|
quoted = name.replace('"', '""')
|
318
797
|
return f'"{quoted}"'
|