t-sql 4.5.2__tar.gz → 4.7.0__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.5.2/README.md → t_sql-4.7.0/PKG-INFO +46 -0
- t_sql-4.5.2/PKG-INFO → t_sql-4.7.0/README.md +36 -10
- {t_sql-4.5.2 → t_sql-4.7.0}/pyproject.toml +1 -1
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_alembic_integration.py +142 -1
- t_sql-4.7.0/tests/test_deep_nesting.py +534 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_sqlalchemy_integration.py +168 -0
- t_sql-4.7.0/tests/test_string_based_builders.py +183 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tsql/query_builder.py +155 -26
- {t_sql-4.5.2 → t_sql-4.7.0}/.dockerignore +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/.gitignore +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/Dockerfile +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/LICENSE +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/compose.yaml +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/context7.json +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/pytest.ini +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_escaped.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_query_builder.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_styles.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_tsql.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tsql/__init__.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tsql/row.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tsql/styles.py +0 -0
- {t_sql-4.5.2 → t_sql-4.7.0}/tsql/type_processor.py +0 -0
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-sql
|
|
3
|
+
Version: 4.7.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
|
+
|
|
1
11
|
# t-sql
|
|
2
12
|
|
|
3
13
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -436,6 +446,42 @@ The query builder is database-agnostic - all methods are available regardless of
|
|
|
436
446
|
|
|
437
447
|
If you use an unsupported method, your database will raise a syntax error when you execute the query.
|
|
438
448
|
|
|
449
|
+
## String-Based Query Builder
|
|
450
|
+
|
|
451
|
+
t-sql also supports building queries with string table/column names instead of Table class definitions:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
455
|
+
|
|
456
|
+
# SELECT
|
|
457
|
+
user_id = 123
|
|
458
|
+
status = 'active'
|
|
459
|
+
query = SelectQueryBuilder.from_table('users', schema='public') \
|
|
460
|
+
.select('id', 'name', 'email') \
|
|
461
|
+
.where(t'id = {user_id} AND status = {status}') \
|
|
462
|
+
.order_by('created_at', direction='DESC') \
|
|
463
|
+
.limit(10)
|
|
464
|
+
|
|
465
|
+
sql, params = query.render()
|
|
466
|
+
|
|
467
|
+
# INSERT
|
|
468
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \
|
|
469
|
+
.on_conflict_do_nothing('email') \
|
|
470
|
+
.returning('id')
|
|
471
|
+
|
|
472
|
+
# UPDATE
|
|
473
|
+
cutoff_date = '2024-01-01'
|
|
474
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}) \
|
|
475
|
+
.where(t'last_login < {cutoff_date}')
|
|
476
|
+
|
|
477
|
+
# DELETE
|
|
478
|
+
cutoff = '2023-01-01'
|
|
479
|
+
query = DeleteBuilder.from_table('users') \
|
|
480
|
+
.where(t'created_at < {cutoff}')
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
String identifiers are validated using the same `:literal` format spec as the core library, providing the same SQL injection protection.
|
|
484
|
+
|
|
439
485
|
## Mixing Query Builder with T-Strings
|
|
440
486
|
|
|
441
487
|
You can combine the query builder with raw t-strings for complex logic:
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: t-sql
|
|
3
|
-
Version: 4.5.2
|
|
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).
|
|
@@ -446,6 +436,42 @@ The query builder is database-agnostic - all methods are available regardless of
|
|
|
446
436
|
|
|
447
437
|
If you use an unsupported method, your database will raise a syntax error when you execute the query.
|
|
448
438
|
|
|
439
|
+
## String-Based Query Builder
|
|
440
|
+
|
|
441
|
+
t-sql also supports building queries with string table/column names instead of Table class definitions:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
445
|
+
|
|
446
|
+
# SELECT
|
|
447
|
+
user_id = 123
|
|
448
|
+
status = 'active'
|
|
449
|
+
query = SelectQueryBuilder.from_table('users', schema='public') \
|
|
450
|
+
.select('id', 'name', 'email') \
|
|
451
|
+
.where(t'id = {user_id} AND status = {status}') \
|
|
452
|
+
.order_by('created_at', direction='DESC') \
|
|
453
|
+
.limit(10)
|
|
454
|
+
|
|
455
|
+
sql, params = query.render()
|
|
456
|
+
|
|
457
|
+
# INSERT
|
|
458
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \
|
|
459
|
+
.on_conflict_do_nothing('email') \
|
|
460
|
+
.returning('id')
|
|
461
|
+
|
|
462
|
+
# UPDATE
|
|
463
|
+
cutoff_date = '2024-01-01'
|
|
464
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}) \
|
|
465
|
+
.where(t'last_login < {cutoff_date}')
|
|
466
|
+
|
|
467
|
+
# DELETE
|
|
468
|
+
cutoff = '2023-01-01'
|
|
469
|
+
query = DeleteBuilder.from_table('users') \
|
|
470
|
+
.where(t'created_at < {cutoff}')
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
String identifiers are validated using the same `:literal` format spec as the core library, providing the same SQL injection protection.
|
|
474
|
+
|
|
449
475
|
## Mixing Query Builder with T-Strings
|
|
450
476
|
|
|
451
477
|
You can combine the query builder with raw t-strings for complex logic:
|
|
@@ -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'}
|