t-sql 4.6.0__tar.gz → 4.7.1__tar.gz
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.
- t_sql-4.6.0/README.md → t_sql-4.7.1/PKG-INFO +26 -0
- t_sql-4.6.0/PKG-INFO → t_sql-4.7.1/README.md +16 -10
- {t_sql-4.6.0 → t_sql-4.7.1}/pyproject.toml +1 -1
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_alembic_integration.py +142 -1
- t_sql-4.7.1/tests/test_deep_nesting.py +535 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_sqlalchemy_integration.py +168 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_tsql.py +15 -4
- {t_sql-4.6.0 → t_sql-4.7.1}/tsql/__init__.py +3 -2
- {t_sql-4.6.0 → t_sql-4.7.1}/tsql/query_builder.py +6 -1
- {t_sql-4.6.0 → t_sql-4.7.1}/.dockerignore +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/.github/workflows/publish.yml +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/.github/workflows/test.yml +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/.gitignore +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/Dockerfile +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/LICENSE +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/compose.yaml +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/context7.json +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/pytest.ini +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_different_object_types.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_error_messages.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_escaped.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_helper_functions.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_parameter_names.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_query_builder.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_string_based_builders.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_styles.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_type_processor.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tsql/row.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tsql/styles.py +0 -0
- {t_sql-4.6.0 → t_sql-4.7.1}/tsql/type_processor.py +0 -0
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-sql
|
|
3
|
+
Version: 4.7.1
|
|
4
|
+
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
+
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: alembic>=1.17.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
1
11
|
# t-sql
|
|
2
12
|
|
|
3
13
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -105,6 +115,22 @@ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
|
|
|
105
115
|
# ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
|
|
106
116
|
```
|
|
107
117
|
|
|
118
|
+
#### Tuples for IN clauses
|
|
119
|
+
|
|
120
|
+
Use tuples to expand lists of values for SQL IN clauses:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# Convert list to tuple for IN clause
|
|
124
|
+
my_ids = ['123', '234', '531']
|
|
125
|
+
sql, params = tsql.render(t"SELECT * FROM mytable WHERE id IN {tuple(my_ids)}")
|
|
126
|
+
# ('SELECT * FROM mytable WHERE id IN (?, ?, ?)', ['123', '234', '531'])
|
|
127
|
+
|
|
128
|
+
# Or use a tuple directly
|
|
129
|
+
active_statuses = ('active', 'pending', 'approved')
|
|
130
|
+
sql, params = tsql.render(t"SELECT * FROM orders WHERE status IN {active_statuses}")
|
|
131
|
+
# ('SELECT * FROM orders WHERE status IN (?, ?, ?)', ['active', 'pending', 'approved'])
|
|
132
|
+
```
|
|
133
|
+
|
|
108
134
|
### Helper Functions
|
|
109
135
|
|
|
110
136
|
t-sql provides several convenience functions for common SQL operations:
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: t-sql
|
|
3
|
-
Version: 4.6.0
|
|
4
|
-
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
-
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.14
|
|
8
|
-
Requires-Dist: alembic>=1.17.0
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
|
|
11
1
|
# t-sql
|
|
12
2
|
|
|
13
3
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -115,6 +105,22 @@ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
|
|
|
115
105
|
# ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
|
|
116
106
|
```
|
|
117
107
|
|
|
108
|
+
#### Tuples for IN clauses
|
|
109
|
+
|
|
110
|
+
Use tuples to expand lists of values for SQL IN clauses:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Convert list to tuple for IN clause
|
|
114
|
+
my_ids = ['123', '234', '531']
|
|
115
|
+
sql, params = tsql.render(t"SELECT * FROM mytable WHERE id IN {tuple(my_ids)}")
|
|
116
|
+
# ('SELECT * FROM mytable WHERE id IN (?, ?, ?)', ['123', '234', '531'])
|
|
117
|
+
|
|
118
|
+
# Or use a tuple directly
|
|
119
|
+
active_statuses = ('active', 'pending', 'approved')
|
|
120
|
+
sql, params = tsql.render(t"SELECT * FROM orders WHERE status IN {active_statuses}")
|
|
121
|
+
# ('SELECT * FROM orders WHERE status IN (?, ?, ?)', ['active', 'pending', 'approved'])
|
|
122
|
+
```
|
|
123
|
+
|
|
118
124
|
### Helper Functions
|
|
119
125
|
|
|
120
126
|
t-sql provides several convenience functions for common SQL operations:
|
|
@@ -465,4 +465,145 @@ def test_alembic_detects_table_comment(temp_alembic_env):
|
|
|
465
465
|
assert len(add_table_ops) == 1
|
|
466
466
|
|
|
467
467
|
table = add_table_ops[0][1]
|
|
468
|
-
assert table.comment == 'Application configuration'
|
|
468
|
+
assert table.comment == 'Application configuration'
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_alembic_detects_indexes(temp_alembic_env):
|
|
472
|
+
"""Test that Alembic autogenerate detects indexes from indexes attribute"""
|
|
473
|
+
from sqlalchemy import Index
|
|
474
|
+
|
|
475
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
476
|
+
metadata = MetaData()
|
|
477
|
+
engine = create_engine("sqlite:///:memory:")
|
|
478
|
+
|
|
479
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
480
|
+
id = Column(String, primary_key=True)
|
|
481
|
+
email = Column(String, nullable=False)
|
|
482
|
+
username = Column(String)
|
|
483
|
+
|
|
484
|
+
indexes = [
|
|
485
|
+
Index('ix_users_email', 'email'),
|
|
486
|
+
Index('ix_users_username', 'username')
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
cfg = Config(str(alembic_ini))
|
|
490
|
+
cfg.attributes['target_metadata'] = metadata
|
|
491
|
+
cfg.attributes['connection'] = engine
|
|
492
|
+
|
|
493
|
+
with engine.begin() as connection:
|
|
494
|
+
mc = MigrationContext.configure(connection)
|
|
495
|
+
diff = compare_metadata(mc, metadata)
|
|
496
|
+
|
|
497
|
+
# Should detect the new table
|
|
498
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
499
|
+
assert len(add_table_ops) == 1
|
|
500
|
+
|
|
501
|
+
table = add_table_ops[0][1]
|
|
502
|
+
assert table.name == 'users'
|
|
503
|
+
|
|
504
|
+
# Alembic detects indexes as separate add_index operations
|
|
505
|
+
add_index_ops = [op for op in diff if op[0] == 'add_index']
|
|
506
|
+
assert len(add_index_ops) == 2
|
|
507
|
+
|
|
508
|
+
idx_names = {op[1].name for op in add_index_ops}
|
|
509
|
+
assert idx_names == {'ix_users_email', 'ix_users_username'}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_alembic_detects_gin_indexes(temp_alembic_env):
|
|
513
|
+
"""Test that Alembic autogenerate detects GIN indexes with PostgreSQL options"""
|
|
514
|
+
from sqlalchemy import Index
|
|
515
|
+
|
|
516
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
517
|
+
metadata = MetaData()
|
|
518
|
+
engine = create_engine("sqlite:///:memory:")
|
|
519
|
+
|
|
520
|
+
class Documents(Table, table_name='documents', metadata=metadata):
|
|
521
|
+
id = Column(String, primary_key=True)
|
|
522
|
+
title = Column(String)
|
|
523
|
+
content = Column(String)
|
|
524
|
+
|
|
525
|
+
indexes = [
|
|
526
|
+
Index('ix_documents_title_gin', 'title',
|
|
527
|
+
postgresql_using='gin',
|
|
528
|
+
postgresql_ops={'title': 'gin_trgm_ops'}),
|
|
529
|
+
Index('ix_documents_content_gin', 'content',
|
|
530
|
+
postgresql_using='gin',
|
|
531
|
+
postgresql_ops={'content': 'gin_trgm_ops'})
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
cfg = Config(str(alembic_ini))
|
|
535
|
+
cfg.attributes['target_metadata'] = metadata
|
|
536
|
+
cfg.attributes['connection'] = engine
|
|
537
|
+
|
|
538
|
+
with engine.begin() as connection:
|
|
539
|
+
mc = MigrationContext.configure(connection)
|
|
540
|
+
diff = compare_metadata(mc, metadata)
|
|
541
|
+
|
|
542
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
543
|
+
assert len(add_table_ops) == 1
|
|
544
|
+
|
|
545
|
+
# Alembic detects indexes as separate add_index operations
|
|
546
|
+
add_index_ops = [op for op in diff if op[0] == 'add_index']
|
|
547
|
+
assert len(add_index_ops) == 2
|
|
548
|
+
|
|
549
|
+
idx_names = {op[1].name for op in add_index_ops}
|
|
550
|
+
assert 'ix_documents_title_gin' in idx_names
|
|
551
|
+
assert 'ix_documents_content_gin' in idx_names
|
|
552
|
+
|
|
553
|
+
# Verify PostgreSQL-specific options are preserved
|
|
554
|
+
for op in add_index_ops:
|
|
555
|
+
idx = op[1]
|
|
556
|
+
if idx.name == 'ix_documents_title_gin':
|
|
557
|
+
assert idx.dialect_options['postgresql']['using'] == 'gin'
|
|
558
|
+
assert idx.dialect_options['postgresql']['ops'] == {'title': 'gin_trgm_ops'}
|
|
559
|
+
elif idx.name == 'ix_documents_content_gin':
|
|
560
|
+
assert idx.dialect_options['postgresql']['using'] == 'gin'
|
|
561
|
+
assert idx.dialect_options['postgresql']['ops'] == {'content': 'gin_trgm_ops'}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_alembic_detects_indexes_and_constraints(temp_alembic_env):
|
|
565
|
+
"""Test that Alembic autogenerate detects both indexes and constraints together"""
|
|
566
|
+
from sqlalchemy import Index, UniqueConstraint
|
|
567
|
+
|
|
568
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
569
|
+
metadata = MetaData()
|
|
570
|
+
engine = create_engine("sqlite:///:memory:")
|
|
571
|
+
|
|
572
|
+
class Products(Table, table_name='products', metadata=metadata):
|
|
573
|
+
id = Column(String, primary_key=True)
|
|
574
|
+
sku = Column(String, nullable=False)
|
|
575
|
+
name = Column(String)
|
|
576
|
+
category = Column(String)
|
|
577
|
+
|
|
578
|
+
constraints = [
|
|
579
|
+
UniqueConstraint('sku', name='uq_products_sku')
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
indexes = [
|
|
583
|
+
Index('ix_products_category', 'category'),
|
|
584
|
+
Index('ix_products_name', 'name')
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
cfg = Config(str(alembic_ini))
|
|
588
|
+
cfg.attributes['target_metadata'] = metadata
|
|
589
|
+
cfg.attributes['connection'] = engine
|
|
590
|
+
|
|
591
|
+
with engine.begin() as connection:
|
|
592
|
+
mc = MigrationContext.configure(connection)
|
|
593
|
+
diff = compare_metadata(mc, metadata)
|
|
594
|
+
|
|
595
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
596
|
+
assert len(add_table_ops) == 1
|
|
597
|
+
|
|
598
|
+
table = add_table_ops[0][1]
|
|
599
|
+
|
|
600
|
+
# Verify constraint is in the table definition
|
|
601
|
+
unique_constraints = [c for c in table.constraints if isinstance(c, UniqueConstraint)]
|
|
602
|
+
assert len(unique_constraints) == 1
|
|
603
|
+
assert unique_constraints[0].name == 'uq_products_sku'
|
|
604
|
+
|
|
605
|
+
# Verify indexes are detected as separate operations
|
|
606
|
+
add_index_ops = [op for op in diff if op[0] == 'add_index']
|
|
607
|
+
assert len(add_index_ops) == 2
|
|
608
|
+
idx_names = {op[1].name for op in add_index_ops}
|
|
609
|
+
assert idx_names == {'ix_products_category', 'ix_products_name'}
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Tests for deep template nesting, CTEs, subqueries, and complex query composition"""
|
|
2
|
+
import pytest
|
|
3
|
+
import tsql
|
|
4
|
+
from tsql import TSQL
|
|
5
|
+
from tsql.query_builder import Table, Column
|
|
6
|
+
from tsql import styles
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Users(Table):
|
|
10
|
+
id: Column
|
|
11
|
+
username: Column
|
|
12
|
+
email: Column
|
|
13
|
+
created_at: Column
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Posts(Table):
|
|
17
|
+
id: Column
|
|
18
|
+
user_id: Column
|
|
19
|
+
title: Column
|
|
20
|
+
content: Column
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Comments(Table):
|
|
24
|
+
id: Column
|
|
25
|
+
post_id: Column
|
|
26
|
+
user_id: Column
|
|
27
|
+
content: Column
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_simple_subquery_in_where():
|
|
31
|
+
"""Test basic subquery nesting with t-strings"""
|
|
32
|
+
min_id = 100
|
|
33
|
+
subquery = t"SELECT MAX(id) FROM users WHERE id > {min_id}"
|
|
34
|
+
query = t"SELECT * FROM posts WHERE user_id = ({subquery})"
|
|
35
|
+
|
|
36
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
37
|
+
|
|
38
|
+
assert sql == "SELECT * FROM posts WHERE user_id = (SELECT MAX(id) FROM users WHERE id > ?)"
|
|
39
|
+
assert params == [100]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_subquery_in_select():
|
|
43
|
+
"""Test subquery in SELECT clause"""
|
|
44
|
+
user_id = 42
|
|
45
|
+
subquery = t"SELECT COUNT(*) FROM posts WHERE user_id = {user_id}"
|
|
46
|
+
query = t"SELECT username, ({subquery}) as post_count FROM users WHERE id = {user_id}"
|
|
47
|
+
|
|
48
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
49
|
+
|
|
50
|
+
assert "SELECT username, (SELECT COUNT(*) FROM posts WHERE user_id = ?) as post_count" in sql
|
|
51
|
+
assert params == [42, 42]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_nested_subqueries_three_levels():
|
|
55
|
+
"""Test three levels of nested subqueries"""
|
|
56
|
+
user_id = 123
|
|
57
|
+
|
|
58
|
+
# Level 3: innermost query
|
|
59
|
+
innermost = t"SELECT post_id FROM comments WHERE user_id = {user_id}"
|
|
60
|
+
# Level 2: middle query
|
|
61
|
+
middle = t"SELECT user_id FROM posts WHERE id IN ({innermost})"
|
|
62
|
+
# Level 1: outer query
|
|
63
|
+
outer = t"SELECT username FROM users WHERE id IN ({middle})"
|
|
64
|
+
|
|
65
|
+
sql, params = tsql.render(outer, style=styles.QMARK)
|
|
66
|
+
|
|
67
|
+
assert "SELECT username FROM users WHERE id IN (SELECT user_id FROM posts WHERE id IN (SELECT post_id FROM comments WHERE user_id = ?))" in sql
|
|
68
|
+
assert params == [123]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_simple_cte():
|
|
72
|
+
"""Test basic CTE (Common Table Expression)"""
|
|
73
|
+
min_posts = 5
|
|
74
|
+
cte = t"WITH active_users AS (SELECT user_id, COUNT(*) as post_count FROM posts GROUP BY user_id HAVING COUNT(*) > {min_posts})"
|
|
75
|
+
query = t"{cte} SELECT u.username FROM users u JOIN active_users au ON u.id = au.user_id"
|
|
76
|
+
|
|
77
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
78
|
+
|
|
79
|
+
assert "WITH active_users AS" in sql
|
|
80
|
+
assert "HAVING COUNT(*) > ?" in sql
|
|
81
|
+
assert params == [5]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_multiple_ctes():
|
|
85
|
+
"""Test multiple CTEs chained together"""
|
|
86
|
+
min_posts = 10
|
|
87
|
+
min_comments = 5
|
|
88
|
+
|
|
89
|
+
cte1 = t"active_posters AS (SELECT user_id FROM posts GROUP BY user_id HAVING COUNT(*) > {min_posts})"
|
|
90
|
+
cte2 = t"active_commenters AS (SELECT user_id FROM comments GROUP BY user_id HAVING COUNT(*) > {min_comments})"
|
|
91
|
+
query = t"WITH {cte1}, {cte2} SELECT DISTINCT u.username FROM users u JOIN active_posters ap ON u.id = ap.user_id JOIN active_commenters ac ON u.id = ac.user_id"
|
|
92
|
+
|
|
93
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
94
|
+
|
|
95
|
+
assert "WITH active_posters AS" in sql
|
|
96
|
+
assert "active_commenters AS" in sql
|
|
97
|
+
assert params == [10, 5]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_cte_referencing_previous_cte():
|
|
101
|
+
"""Test CTE that references another CTE"""
|
|
102
|
+
threshold = 100
|
|
103
|
+
|
|
104
|
+
cte1 = t"popular_posts AS (SELECT id, user_id FROM posts WHERE id > {threshold})"
|
|
105
|
+
cte2 = t"top_users AS (SELECT user_id, COUNT(*) as count FROM popular_posts GROUP BY user_id)"
|
|
106
|
+
query = t"WITH {cte1}, {cte2} SELECT u.username, tu.count FROM users u JOIN top_users tu ON u.id = tu.user_id"
|
|
107
|
+
|
|
108
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
109
|
+
|
|
110
|
+
assert "WITH popular_posts AS" in sql
|
|
111
|
+
assert "top_users AS" in sql
|
|
112
|
+
assert "FROM popular_posts" in sql
|
|
113
|
+
assert params == [threshold]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_query_builder_in_subquery():
|
|
117
|
+
"""Test mixing query builder with t-string subquery"""
|
|
118
|
+
user_id = 42
|
|
119
|
+
|
|
120
|
+
# Use query builder for inner query
|
|
121
|
+
subquery = Users.select(Users.id).where(Users.username == "admin")
|
|
122
|
+
|
|
123
|
+
# Embed in t-string
|
|
124
|
+
outer = t"SELECT * FROM posts WHERE user_id IN ({subquery})"
|
|
125
|
+
|
|
126
|
+
sql, params = tsql.render(outer, style=styles.QMARK)
|
|
127
|
+
|
|
128
|
+
assert "SELECT users.id FROM users WHERE users.username = ?" in sql
|
|
129
|
+
assert "SELECT * FROM posts WHERE user_id IN" in sql
|
|
130
|
+
assert params == ["admin"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_tstring_in_query_builder_where():
|
|
134
|
+
"""Test t-string condition in query builder WHERE clause"""
|
|
135
|
+
max_id = 1000
|
|
136
|
+
username = "alice"
|
|
137
|
+
|
|
138
|
+
# T-string condition
|
|
139
|
+
condition = t"id < {max_id} OR username = {username}"
|
|
140
|
+
|
|
141
|
+
query = Users.select().where(condition)
|
|
142
|
+
sql, params = query.render(style=styles.QMARK)
|
|
143
|
+
|
|
144
|
+
assert "WHERE (id < ? OR username = ?)" in sql
|
|
145
|
+
assert params == [1000, "alice"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_query_builder_with_subquery_builder():
|
|
149
|
+
"""Test query builder with another query builder as subquery"""
|
|
150
|
+
# Inner query builder
|
|
151
|
+
active_users = Users.select(Users.id).where(Users.created_at > "2024-01-01")
|
|
152
|
+
|
|
153
|
+
# Outer query builder using inner as condition
|
|
154
|
+
posts_query = Posts.select().where(t"user_id IN ({active_users})")
|
|
155
|
+
|
|
156
|
+
sql, params = posts_query.render(style=styles.QMARK)
|
|
157
|
+
|
|
158
|
+
# SELECT with no columns specified defaults to SELECT *
|
|
159
|
+
assert "SELECT * FROM posts" in sql
|
|
160
|
+
assert "WHERE (user_id IN (SELECT users.id FROM users WHERE users.created_at > ?))" in sql
|
|
161
|
+
assert params == ["2024-01-01"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_deeply_nested_mixed_composition():
|
|
165
|
+
"""Test complex nesting mixing query builders and t-strings"""
|
|
166
|
+
# Level 4: query builder
|
|
167
|
+
comment_authors = Users.select(Users.id).where(Users.username == "alice")
|
|
168
|
+
|
|
169
|
+
# Level 3: t-string using query builder
|
|
170
|
+
commented_posts = t"SELECT post_id FROM comments WHERE user_id IN ({comment_authors})"
|
|
171
|
+
|
|
172
|
+
# Level 2: query builder using t-string
|
|
173
|
+
posts_with_comments = Posts.select(Posts.user_id).where(t"id IN ({commented_posts})")
|
|
174
|
+
|
|
175
|
+
# Level 1: final t-string query
|
|
176
|
+
final_query = t"SELECT username FROM users WHERE id IN ({posts_with_comments})"
|
|
177
|
+
|
|
178
|
+
sql, params = tsql.render(final_query, style=styles.QMARK)
|
|
179
|
+
|
|
180
|
+
# Verify the nesting structure exists
|
|
181
|
+
assert "SELECT username FROM users WHERE id IN" in sql
|
|
182
|
+
assert "SELECT posts.user_id FROM posts WHERE (id IN" in sql
|
|
183
|
+
assert "SELECT post_id FROM comments WHERE user_id IN" in sql
|
|
184
|
+
assert "SELECT users.id FROM users WHERE users.username = ?" in sql
|
|
185
|
+
assert params == ["alice"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_cte_with_query_builder():
|
|
189
|
+
"""Test CTE containing a query builder"""
|
|
190
|
+
# Build CTE content with query builder
|
|
191
|
+
active_users_query = Users.select(Users.id, Users.username).where(Users.created_at > "2024-01-01")
|
|
192
|
+
|
|
193
|
+
# Use in CTE
|
|
194
|
+
query = t"WITH active_users AS ({active_users_query}) SELECT au.username, COUNT(p.id) FROM active_users au LEFT JOIN posts p ON au.id = p.user_id GROUP BY au.username"
|
|
195
|
+
|
|
196
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
197
|
+
|
|
198
|
+
assert "WITH active_users AS (SELECT users.id, users.username FROM users WHERE users.created_at > ?)" in sql
|
|
199
|
+
assert params == ["2024-01-01"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_cte_chain_with_mixed_types():
|
|
203
|
+
"""Test chain of CTEs mixing query builders and t-strings"""
|
|
204
|
+
# First CTE uses query builder
|
|
205
|
+
cte1_query = Users.select(Users.id).where(Users.email != None)
|
|
206
|
+
|
|
207
|
+
# Second CTE uses t-string and references first
|
|
208
|
+
user_id = 100
|
|
209
|
+
cte2 = t"post_counts AS (SELECT user_id, COUNT(*) as count FROM posts WHERE user_id > {user_id} GROUP BY user_id)"
|
|
210
|
+
|
|
211
|
+
# Final query combines everything
|
|
212
|
+
query = t"WITH verified_users AS ({cte1_query}), {cte2} SELECT vu.id, pc.count FROM verified_users vu JOIN post_counts pc ON vu.id = pc.user_id"
|
|
213
|
+
|
|
214
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
215
|
+
|
|
216
|
+
# != None is correctly converted to IS NOT NULL (no parameter)
|
|
217
|
+
assert "WITH verified_users AS (SELECT users.id FROM users WHERE users.email IS NOT NULL)" in sql
|
|
218
|
+
assert "post_counts AS (SELECT user_id, COUNT(*) as count FROM posts" in sql
|
|
219
|
+
assert params == [100]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_correlated_subquery():
|
|
223
|
+
"""Test correlated subquery where inner query references outer query"""
|
|
224
|
+
min_count = 5
|
|
225
|
+
|
|
226
|
+
# Correlated subquery - note the reference to outer 'u' table
|
|
227
|
+
query = t"""
|
|
228
|
+
SELECT u.username
|
|
229
|
+
FROM users u
|
|
230
|
+
WHERE (
|
|
231
|
+
SELECT COUNT(*)
|
|
232
|
+
FROM posts p
|
|
233
|
+
WHERE p.user_id = u.id
|
|
234
|
+
) > {min_count}
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
238
|
+
|
|
239
|
+
assert "SELECT COUNT(*)" in sql
|
|
240
|
+
assert "WHERE p.user_id = u.id" in sql
|
|
241
|
+
assert ") > ?" in sql
|
|
242
|
+
assert params == [5]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_union_with_nested_queries():
|
|
246
|
+
"""Test UNION with nested subqueries"""
|
|
247
|
+
active_threshold = 100
|
|
248
|
+
admin_role = "admin"
|
|
249
|
+
|
|
250
|
+
query1 = t"SELECT id, username FROM users WHERE id > {active_threshold}"
|
|
251
|
+
query2 = Users.select(Users.id, Users.username).where(Users.email == admin_role)
|
|
252
|
+
|
|
253
|
+
union_query = t"{query1} UNION {query2}"
|
|
254
|
+
|
|
255
|
+
sql, params = tsql.render(union_query, style=styles.QMARK)
|
|
256
|
+
|
|
257
|
+
assert "SELECT id, username FROM users WHERE id > ?" in sql
|
|
258
|
+
assert "UNION SELECT users.id, users.username FROM users WHERE users.email = ?" in sql
|
|
259
|
+
assert params == [100, "admin"]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_exists_with_nested_query():
|
|
263
|
+
"""Test EXISTS clause with nested query"""
|
|
264
|
+
user_id = 42
|
|
265
|
+
|
|
266
|
+
subquery = Posts.select(Posts.id).where(Posts.user_id == user_id)
|
|
267
|
+
query = t"SELECT username FROM users WHERE EXISTS ({subquery})"
|
|
268
|
+
|
|
269
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
270
|
+
|
|
271
|
+
assert "WHERE EXISTS (SELECT posts.id FROM posts WHERE posts.user_id = ?)" in sql
|
|
272
|
+
assert params == [42]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_window_function_with_subquery():
|
|
276
|
+
"""Test window function with subquery in partition"""
|
|
277
|
+
min_id = 1000
|
|
278
|
+
|
|
279
|
+
query = t"""
|
|
280
|
+
SELECT
|
|
281
|
+
username,
|
|
282
|
+
ROW_NUMBER() OVER (
|
|
283
|
+
PARTITION BY (
|
|
284
|
+
SELECT COUNT(*) FROM posts WHERE user_id = users.id AND id > {min_id}
|
|
285
|
+
)
|
|
286
|
+
) as rank
|
|
287
|
+
FROM users
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
291
|
+
|
|
292
|
+
assert "ROW_NUMBER() OVER" in sql
|
|
293
|
+
assert "PARTITION BY" in sql
|
|
294
|
+
assert params == [1000]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_case_expression_with_subqueries():
|
|
298
|
+
"""Test CASE expression containing subqueries"""
|
|
299
|
+
threshold = 10
|
|
300
|
+
|
|
301
|
+
query = t"""
|
|
302
|
+
SELECT
|
|
303
|
+
username,
|
|
304
|
+
CASE
|
|
305
|
+
WHEN (SELECT COUNT(*) FROM posts WHERE user_id = users.id) > {threshold}
|
|
306
|
+
THEN 'active'
|
|
307
|
+
ELSE 'inactive'
|
|
308
|
+
END as status
|
|
309
|
+
FROM users
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
313
|
+
|
|
314
|
+
assert "CASE" in sql
|
|
315
|
+
assert "WHEN (SELECT COUNT(*)" in sql
|
|
316
|
+
assert "> ?" in sql
|
|
317
|
+
assert "THEN 'active'" in sql
|
|
318
|
+
assert params == [10]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_recursive_cte():
|
|
322
|
+
"""Test recursive CTE pattern"""
|
|
323
|
+
start_id = 1
|
|
324
|
+
|
|
325
|
+
query = t"""
|
|
326
|
+
WITH RECURSIVE user_hierarchy AS (
|
|
327
|
+
SELECT id, username, 0 as level
|
|
328
|
+
FROM users
|
|
329
|
+
WHERE id = {start_id}
|
|
330
|
+
|
|
331
|
+
UNION ALL
|
|
332
|
+
|
|
333
|
+
SELECT u.id, u.username, uh.level + 1
|
|
334
|
+
FROM users u
|
|
335
|
+
JOIN user_hierarchy uh ON u.id = uh.id + 1
|
|
336
|
+
WHERE uh.level < 10
|
|
337
|
+
)
|
|
338
|
+
SELECT * FROM user_hierarchy
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
342
|
+
|
|
343
|
+
assert "WITH RECURSIVE user_hierarchy AS" in sql
|
|
344
|
+
assert "UNION ALL" in sql
|
|
345
|
+
assert params == [1]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_five_level_nesting():
|
|
349
|
+
"""Stress test with five levels of nesting"""
|
|
350
|
+
val1, val2, val3, val4, val5 = 1, 2, 3, 4, 5
|
|
351
|
+
|
|
352
|
+
level5 = t"SELECT id FROM comments WHERE id > {val5}"
|
|
353
|
+
level4 = t"SELECT post_id FROM comments WHERE id IN ({level5}) AND user_id > {val4}"
|
|
354
|
+
level3 = t"SELECT user_id FROM posts WHERE id IN ({level4}) AND title LIKE {val3}"
|
|
355
|
+
level2 = t"SELECT id FROM users WHERE id IN ({level3}) AND email IS NOT NULL AND id > {val2}"
|
|
356
|
+
level1 = t"SELECT username FROM users WHERE id IN ({level2}) AND created_at IS NOT NULL AND id > {val1}"
|
|
357
|
+
|
|
358
|
+
sql, params = tsql.render(level1, style=styles.QMARK)
|
|
359
|
+
|
|
360
|
+
# Verify all values are captured (order is reversed due to depth-first processing)
|
|
361
|
+
assert params == [5, 4, 3, 2, 1]
|
|
362
|
+
|
|
363
|
+
# Verify nesting structure
|
|
364
|
+
assert sql.count("SELECT") == 5
|
|
365
|
+
assert sql.count("WHERE") == 5
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_lateral_join_with_subquery():
|
|
369
|
+
"""Test LATERAL join (PostgreSQL) with subquery"""
|
|
370
|
+
limit = 3
|
|
371
|
+
|
|
372
|
+
query = t"""
|
|
373
|
+
SELECT u.username, recent.title
|
|
374
|
+
FROM users u
|
|
375
|
+
LEFT JOIN LATERAL (
|
|
376
|
+
SELECT title
|
|
377
|
+
FROM posts
|
|
378
|
+
WHERE user_id = u.id
|
|
379
|
+
ORDER BY id DESC
|
|
380
|
+
LIMIT {limit}
|
|
381
|
+
) recent ON true
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
385
|
+
|
|
386
|
+
assert "LEFT JOIN LATERAL" in sql
|
|
387
|
+
assert "LIMIT ?" in sql
|
|
388
|
+
assert params == [3]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_values_clause_with_nested_select():
|
|
392
|
+
"""Test VALUES clause combined with nested SELECT"""
|
|
393
|
+
user_id = 42
|
|
394
|
+
|
|
395
|
+
subquery = t"SELECT MAX(id) FROM posts WHERE user_id = {user_id}"
|
|
396
|
+
query = t"""
|
|
397
|
+
INSERT INTO posts (user_id, title)
|
|
398
|
+
SELECT * FROM (
|
|
399
|
+
VALUES (({subquery}), 'New Post')
|
|
400
|
+
) AS v(user_id, title)
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
404
|
+
|
|
405
|
+
assert "VALUES ((SELECT MAX(id) FROM posts WHERE user_id = ?), 'New Post')" in sql
|
|
406
|
+
assert params == [42]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_multiple_independent_subqueries():
|
|
410
|
+
"""Test query with multiple independent subqueries at same level"""
|
|
411
|
+
threshold1 = 100
|
|
412
|
+
threshold2 = 50
|
|
413
|
+
date = "2024-01-01"
|
|
414
|
+
|
|
415
|
+
subquery1 = t"SELECT COUNT(*) FROM posts WHERE user_id = users.id AND id > {threshold1}"
|
|
416
|
+
subquery2 = t"SELECT COUNT(*) FROM comments WHERE user_id = users.id AND id > {threshold2}"
|
|
417
|
+
|
|
418
|
+
query = t"""
|
|
419
|
+
SELECT
|
|
420
|
+
username,
|
|
421
|
+
({subquery1}) as post_count,
|
|
422
|
+
({subquery2}) as comment_count
|
|
423
|
+
FROM users
|
|
424
|
+
WHERE created_at > {date}
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
428
|
+
|
|
429
|
+
assert sql.count("SELECT COUNT(*)") == 2
|
|
430
|
+
assert params == [100, 50, "2024-01-01"]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_array_agg_with_subquery():
|
|
434
|
+
"""Test aggregate function with subquery (PostgreSQL syntax)"""
|
|
435
|
+
user_id = 42
|
|
436
|
+
|
|
437
|
+
query = t"""
|
|
438
|
+
SELECT
|
|
439
|
+
username,
|
|
440
|
+
ARRAY_AGG((SELECT title FROM posts WHERE id = p.post_id)) as post_titles
|
|
441
|
+
FROM users u
|
|
442
|
+
JOIN posts p ON u.id = p.user_id
|
|
443
|
+
WHERE u.id = {user_id}
|
|
444
|
+
GROUP BY username
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
448
|
+
|
|
449
|
+
assert "ARRAY_AGG((SELECT title FROM posts WHERE id = p.post_id))" in sql
|
|
450
|
+
assert params == [42]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_with_literal_identifiers_in_nested_queries():
|
|
454
|
+
"""Test that :literal format spec works correctly in nested contexts"""
|
|
455
|
+
table_name = "users"
|
|
456
|
+
column_name = "username"
|
|
457
|
+
value = "alice"
|
|
458
|
+
|
|
459
|
+
subquery = t"SELECT id FROM {table_name:literal} WHERE {column_name:literal} = {value}"
|
|
460
|
+
query = t"SELECT * FROM posts WHERE user_id IN ({subquery})"
|
|
461
|
+
|
|
462
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
463
|
+
|
|
464
|
+
assert "SELECT id FROM users WHERE username = ?" in sql
|
|
465
|
+
assert params == [value]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_complex_real_world_analytics_query():
|
|
469
|
+
"""Test realistic complex analytics query with multiple CTEs and nesting"""
|
|
470
|
+
start_date = "2024-01-01"
|
|
471
|
+
end_date = "2024-12-31"
|
|
472
|
+
min_engagement = 10
|
|
473
|
+
|
|
474
|
+
# CTE 1: Active users
|
|
475
|
+
active_users = Users.select(Users.id, Users.username).where(Users.created_at >= start_date)
|
|
476
|
+
|
|
477
|
+
# CTE 2: Engagement metrics (t-string)
|
|
478
|
+
engagement_cte = t"""
|
|
479
|
+
engagement AS (
|
|
480
|
+
SELECT
|
|
481
|
+
user_id,
|
|
482
|
+
COUNT(DISTINCT post_id) as posts_commented,
|
|
483
|
+
COUNT(*) as total_comments
|
|
484
|
+
FROM comments
|
|
485
|
+
WHERE user_id IN (SELECT id FROM active_users)
|
|
486
|
+
GROUP BY user_id
|
|
487
|
+
HAVING COUNT(*) > {min_engagement}
|
|
488
|
+
)
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
# CTE 3: Post stats (mixed)
|
|
492
|
+
post_count_subquery = t"SELECT user_id, COUNT(*) as post_count FROM posts WHERE user_id IN (SELECT id FROM active_users) GROUP BY user_id"
|
|
493
|
+
|
|
494
|
+
# Final query
|
|
495
|
+
final = t"""
|
|
496
|
+
WITH active_users AS ({active_users}),
|
|
497
|
+
{engagement_cte},
|
|
498
|
+
post_stats AS ({post_count_subquery})
|
|
499
|
+
|
|
500
|
+
SELECT
|
|
501
|
+
au.username,
|
|
502
|
+
COALESCE(ps.post_count, 0) as posts,
|
|
503
|
+
COALESCE(e.total_comments, 0) as comments,
|
|
504
|
+
COALESCE(e.posts_commented, 0) as posts_with_comments
|
|
505
|
+
FROM active_users au
|
|
506
|
+
LEFT JOIN post_stats ps ON au.id = ps.user_id
|
|
507
|
+
LEFT JOIN engagement e ON au.id = e.user_id
|
|
508
|
+
ORDER BY posts DESC, comments DESC
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
sql, params = tsql.render(final, style=styles.QMARK)
|
|
512
|
+
|
|
513
|
+
# Verify structure
|
|
514
|
+
assert "WITH active_users AS" in sql
|
|
515
|
+
assert "engagement AS" in sql
|
|
516
|
+
assert "post_stats AS" in sql
|
|
517
|
+
assert params == [start_date, min_engagement]
|
|
518
|
+
|
|
519
|
+
# Verify all CTEs are present and properly nested
|
|
520
|
+
assert sql.count("SELECT") >= 4 # Multiple selects across CTEs
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_json_operations_with_subquery():
|
|
524
|
+
"""Test JSON operations (PostgreSQL) with nested queries"""
|
|
525
|
+
user_id = 42
|
|
526
|
+
key = "metadata"
|
|
527
|
+
|
|
528
|
+
subquery = t"SELECT email FROM users WHERE id = {user_id}"
|
|
529
|
+
query = t"SELECT data->>'{key:literal}' as metadata FROM posts WHERE user_id = ({subquery})"
|
|
530
|
+
|
|
531
|
+
sql, params = tsql.render(query, style=styles.QMARK)
|
|
532
|
+
|
|
533
|
+
assert "data->>'metadata'" in sql
|
|
534
|
+
assert "WHERE user_id = (SELECT email FROM users WHERE id = ?)" in sql
|
|
535
|
+
assert params == [42]
|
|
@@ -420,3 +420,171 @@ def test_table_with_constraints_and_comment():
|
|
|
420
420
|
sql, params = query.render()
|
|
421
421
|
assert 'WHERE api_keys.user_id = ?' in sql
|
|
422
422
|
assert params == ['user123']
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_table_with_single_index():
|
|
426
|
+
"""Test that a single index is properly added to SA table"""
|
|
427
|
+
from sqlalchemy import Index
|
|
428
|
+
|
|
429
|
+
metadata = MetaData()
|
|
430
|
+
|
|
431
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
432
|
+
id = Column(String, primary_key=True)
|
|
433
|
+
email = Column(String, nullable=False)
|
|
434
|
+
created_at = Column(Integer)
|
|
435
|
+
|
|
436
|
+
indexes = [
|
|
437
|
+
Index('ix_users_email', 'email')
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
sa_table = metadata.tables['users']
|
|
441
|
+
|
|
442
|
+
# Verify index is present
|
|
443
|
+
assert len(sa_table.indexes) == 1
|
|
444
|
+
idx = list(sa_table.indexes)[0]
|
|
445
|
+
assert idx.name == 'ix_users_email'
|
|
446
|
+
assert set(c.name for c in idx.columns) == {'email'}
|
|
447
|
+
|
|
448
|
+
# Query builder still works
|
|
449
|
+
query = Users.select(Users.email)
|
|
450
|
+
sql, params = query.render()
|
|
451
|
+
assert 'SELECT users.email FROM users' in sql
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_table_with_gin_index():
|
|
455
|
+
"""Test that GIN index with PostgreSQL-specific options works"""
|
|
456
|
+
from sqlalchemy import Index
|
|
457
|
+
|
|
458
|
+
metadata = MetaData()
|
|
459
|
+
|
|
460
|
+
class Documents(Table, table_name='documents', metadata=metadata):
|
|
461
|
+
id = Column(String, primary_key=True)
|
|
462
|
+
title = Column(String)
|
|
463
|
+
content = Column(String)
|
|
464
|
+
|
|
465
|
+
indexes = [
|
|
466
|
+
Index('ix_documents_title_gin', 'title',
|
|
467
|
+
postgresql_using='gin',
|
|
468
|
+
postgresql_ops={'title': 'gin_trgm_ops'}),
|
|
469
|
+
Index('ix_documents_content_gin', 'content',
|
|
470
|
+
postgresql_using='gin',
|
|
471
|
+
postgresql_ops={'content': 'gin_trgm_ops'})
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
sa_table = metadata.tables['documents']
|
|
475
|
+
|
|
476
|
+
# Verify both indexes are present
|
|
477
|
+
assert len(sa_table.indexes) == 2
|
|
478
|
+
|
|
479
|
+
idx_names = {idx.name for idx in sa_table.indexes}
|
|
480
|
+
assert 'ix_documents_title_gin' in idx_names
|
|
481
|
+
assert 'ix_documents_content_gin' in idx_names
|
|
482
|
+
|
|
483
|
+
# Verify PostgreSQL-specific options
|
|
484
|
+
for idx in sa_table.indexes:
|
|
485
|
+
if idx.name == 'ix_documents_title_gin':
|
|
486
|
+
assert idx.dialect_options['postgresql']['using'] == 'gin'
|
|
487
|
+
assert idx.dialect_options['postgresql']['ops'] == {'title': 'gin_trgm_ops'}
|
|
488
|
+
elif idx.name == 'ix_documents_content_gin':
|
|
489
|
+
assert idx.dialect_options['postgresql']['using'] == 'gin'
|
|
490
|
+
assert idx.dialect_options['postgresql']['ops'] == {'content': 'gin_trgm_ops'}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_table_with_multiple_indexes():
|
|
494
|
+
"""Test that multiple indexes can be added together"""
|
|
495
|
+
from sqlalchemy import Index
|
|
496
|
+
|
|
497
|
+
metadata = MetaData()
|
|
498
|
+
|
|
499
|
+
class Posts(Table, table_name='posts', metadata=metadata):
|
|
500
|
+
id = Column(String, primary_key=True)
|
|
501
|
+
author_id = Column(String, nullable=False)
|
|
502
|
+
status = Column(String)
|
|
503
|
+
published_at = Column(Integer)
|
|
504
|
+
|
|
505
|
+
indexes = [
|
|
506
|
+
Index('ix_posts_author', 'author_id'),
|
|
507
|
+
Index('ix_posts_status', 'status'),
|
|
508
|
+
Index('ix_posts_author_status', 'author_id', 'status')
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
sa_table = metadata.tables['posts']
|
|
512
|
+
|
|
513
|
+
# Verify all indexes are present
|
|
514
|
+
assert len(sa_table.indexes) == 3
|
|
515
|
+
|
|
516
|
+
idx_names = {idx.name for idx in sa_table.indexes}
|
|
517
|
+
assert idx_names == {'ix_posts_author', 'ix_posts_status', 'ix_posts_author_status'}
|
|
518
|
+
|
|
519
|
+
# Verify multi-column index
|
|
520
|
+
multi_idx = [idx for idx in sa_table.indexes if idx.name == 'ix_posts_author_status'][0]
|
|
521
|
+
assert set(c.name for c in multi_idx.columns) == {'author_id', 'status'}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_table_with_indexes_as_tuple():
|
|
525
|
+
"""Test that indexes attribute works with tuple format"""
|
|
526
|
+
from sqlalchemy import Index
|
|
527
|
+
|
|
528
|
+
metadata = MetaData()
|
|
529
|
+
|
|
530
|
+
class Comments(Table, table_name='comments', metadata=metadata):
|
|
531
|
+
id = Column(Integer, primary_key=True)
|
|
532
|
+
post_id = Column(String)
|
|
533
|
+
user_id = Column(String)
|
|
534
|
+
|
|
535
|
+
indexes = (
|
|
536
|
+
Index('ix_comments_post', 'post_id'),
|
|
537
|
+
Index('ix_comments_user', 'user_id'),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
sa_table = metadata.tables['comments']
|
|
541
|
+
|
|
542
|
+
# Verify indexes are present
|
|
543
|
+
assert len(sa_table.indexes) == 2
|
|
544
|
+
idx_names = {idx.name for idx in sa_table.indexes}
|
|
545
|
+
assert idx_names == {'ix_comments_post', 'ix_comments_user'}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_table_with_indexes_and_constraints():
|
|
549
|
+
"""Test that indexes and constraints work together"""
|
|
550
|
+
from sqlalchemy import Index, UniqueConstraint, CheckConstraint
|
|
551
|
+
|
|
552
|
+
metadata = MetaData()
|
|
553
|
+
|
|
554
|
+
class Products(Table, table_name='products', metadata=metadata):
|
|
555
|
+
id = Column(String, primary_key=True)
|
|
556
|
+
sku = Column(String, nullable=False)
|
|
557
|
+
name = Column(String)
|
|
558
|
+
price = Column(Integer)
|
|
559
|
+
category = Column(String)
|
|
560
|
+
|
|
561
|
+
constraints = [
|
|
562
|
+
UniqueConstraint('sku', name='uq_products_sku'),
|
|
563
|
+
CheckConstraint('price > 0', name='ck_products_positive_price')
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
indexes = [
|
|
567
|
+
Index('ix_products_category', 'category'),
|
|
568
|
+
Index('ix_products_name_gin', 'name',
|
|
569
|
+
postgresql_using='gin',
|
|
570
|
+
postgresql_ops={'name': 'gin_trgm_ops'})
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
sa_table = metadata.tables['products']
|
|
574
|
+
|
|
575
|
+
# Verify constraints
|
|
576
|
+
unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
|
|
577
|
+
check_constraints = [c for c in sa_table.constraints if isinstance(c, CheckConstraint)]
|
|
578
|
+
assert len(unique_constraints) == 1
|
|
579
|
+
assert len(check_constraints) == 1
|
|
580
|
+
|
|
581
|
+
# Verify indexes
|
|
582
|
+
assert len(sa_table.indexes) == 2
|
|
583
|
+
idx_names = {idx.name for idx in sa_table.indexes}
|
|
584
|
+
assert idx_names == {'ix_products_category', 'ix_products_name_gin'}
|
|
585
|
+
|
|
586
|
+
# Query builder still works
|
|
587
|
+
query = Products.select().where(Products.category == 'electronics')
|
|
588
|
+
sql, params = query.render()
|
|
589
|
+
assert 'WHERE products.category = ?' in sql
|
|
590
|
+
assert params == ['electronics']
|
|
@@ -35,15 +35,26 @@ def test_merges_literals_using_exsiting_tstring():
|
|
|
35
35
|
assert result._sql == 'hello there'
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def
|
|
39
|
-
|
|
38
|
+
def test_strips_horizontal_whitespace():
|
|
39
|
+
# Horizontal whitespace (spaces/tabs) is collapsed, but newlines are preserved
|
|
40
|
+
result = tsql.render(t"SELECT * FROM table")
|
|
40
41
|
assert result[0] == 'SELECT * FROM table'
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
def test_preserves_newlines_for_sql_comments():
|
|
45
|
+
# Newlines must be preserved so -- style SQL comments work correctly
|
|
46
|
+
query = t"""SELECT * FROM users
|
|
47
|
+
-- Filter by active status
|
|
48
|
+
WHERE active = true"""
|
|
49
|
+
result = tsql.render(query)
|
|
50
|
+
assert '-- Filter by active status\n' in result[0]
|
|
51
|
+
assert 'WHERE active = true' in result[0]
|
|
52
|
+
|
|
53
|
+
|
|
43
54
|
def test_doesnt_strip_whitespace_in_values():
|
|
44
55
|
user_input = 'Some string\nWith whitespace. With Formating that is \n just right'
|
|
45
|
-
result = tsql.render(t'INSERT
|
|
46
|
-
assert result[0] ==
|
|
56
|
+
result = tsql.render(t'INSERT INTO table (vals) VALUES({user_input})')
|
|
57
|
+
assert result[0] == 'INSERT INTO table (vals) VALUES(?)'
|
|
47
58
|
assert result[1] == [user_input]
|
|
48
59
|
|
|
49
60
|
|
|
@@ -9,8 +9,9 @@ from tsql.styles import ParamStyle, QMARK
|
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
12
|
-
# Pre-compile regex for whitespace collapsing
|
|
13
|
-
|
|
12
|
+
# Pre-compile regex for horizontal whitespace collapsing (spaces/tabs only)
|
|
13
|
+
# Preserves newlines so that -- style SQL comments work correctly
|
|
14
|
+
_WHITESPACE_RE = re.compile(r'[ \t]+')
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from tsql.query_builder import QueryBuilder
|
|
@@ -410,12 +410,17 @@ class Table:
|
|
|
410
410
|
if isinstance(table_constraints, tuple):
|
|
411
411
|
table_constraints = list(table_constraints)
|
|
412
412
|
|
|
413
|
+
# Extract indexes from class attribute (supports both tuple and list)
|
|
414
|
+
table_indexes = getattr(cls, 'indexes', [])
|
|
415
|
+
if isinstance(table_indexes, tuple):
|
|
416
|
+
table_indexes = list(table_indexes)
|
|
417
|
+
|
|
413
418
|
# Build keyword args for SATable
|
|
414
419
|
table_kwargs = {'schema': schema}
|
|
415
420
|
if comment is not None:
|
|
416
421
|
table_kwargs['comment'] = comment
|
|
417
422
|
|
|
418
|
-
cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, *table_constraints, **table_kwargs)
|
|
423
|
+
cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, *table_constraints, *table_indexes, **table_kwargs)
|
|
419
424
|
|
|
420
425
|
# Add the ALL column for wildcard column selection
|
|
421
426
|
cls.ALL = Column(cls.table_name, '*', schema=schema)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|