t-sql 3.2.0__tar.gz → 4.0.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.
Files changed (31) hide show
  1. {t_sql-3.2.0 → t_sql-4.0.0}/PKG-INFO +36 -1
  2. {t_sql-3.2.0 → t_sql-4.0.0}/README.md +35 -0
  3. {t_sql-3.2.0 → t_sql-4.0.0}/pyproject.toml +1 -1
  4. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_query_builder.py +67 -9
  5. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_sqlalchemy_integration.py +32 -0
  6. {t_sql-3.2.0 → t_sql-4.0.0}/tsql/query_builder.py +37 -12
  7. {t_sql-3.2.0 → t_sql-4.0.0}/.dockerignore +0 -0
  8. {t_sql-3.2.0 → t_sql-4.0.0}/.github/workflows/publish.yml +0 -0
  9. {t_sql-3.2.0 → t_sql-4.0.0}/.github/workflows/test.yml +0 -0
  10. {t_sql-3.2.0 → t_sql-4.0.0}/.gitignore +0 -0
  11. {t_sql-3.2.0 → t_sql-4.0.0}/Dockerfile +0 -0
  12. {t_sql-3.2.0 → t_sql-4.0.0}/LICENSE +0 -0
  13. {t_sql-3.2.0 → t_sql-4.0.0}/compose.yaml +0 -0
  14. {t_sql-3.2.0 → t_sql-4.0.0}/context7.json +0 -0
  15. {t_sql-3.2.0 → t_sql-4.0.0}/pytest.ini +0 -0
  16. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_alembic_integration.py +0 -0
  17. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_asyncpg_integration.py +0 -0
  18. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_different_object_types.py +0 -0
  19. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_escaped.py +0 -0
  20. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_escaped_binary_hex.py +0 -0
  21. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_helper_functions.py +0 -0
  22. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_injection_edge_cases.py +0 -0
  23. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_injection_protection_validation.py +0 -0
  24. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_injections_for_escaped.py +0 -0
  25. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_mysql_integration.py +0 -0
  26. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_parameter_names.py +0 -0
  27. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_sqlite_integration.py +0 -0
  28. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_styles.py +0 -0
  29. {t_sql-3.2.0 → t_sql-4.0.0}/tests/test_tsql.py +0 -0
  30. {t_sql-3.2.0 → t_sql-4.0.0}/tsql/__init__.py +0 -0
  31. {t_sql-3.2.0 → t_sql-4.0.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 3.2.0
3
+ Version: 4.0.0
4
4
  Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
5
  Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
6
  License-File: LICENSE
@@ -257,12 +257,47 @@ query = (Posts.select()
257
257
 
258
258
  ## Query Features
259
259
 
260
+ ### Selecting All Columns from a Table
261
+
262
+ Use `Table.ALL` to select all columns from a specific table:
263
+
260
264
  ```python
265
+ # Select all columns from posts
266
+ query = Posts.select(Posts.ALL)
267
+ # ('SELECT posts.* FROM posts', [])
268
+
269
+ # Select all columns from posts + specific columns from joined tables
270
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
271
+ .join(Users, Posts.user_id == Users.id))
272
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
273
+
274
+ # Select all columns from multiple tables
275
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
276
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
277
+ ```
278
+
279
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
280
+
281
+ ### NULL Checks and Other Operators
282
+
283
+ ```python
284
+ # NULL checks
285
+ query = Users.select().where(Users.email.is_null())
286
+ query = Users.select().where(Users.email.is_not_null())
287
+
261
288
  # IN clause
262
289
  query = Users.select().where(Users.id.in_([1, 2, 3]))
290
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
263
291
 
264
292
  # LIKE clause
265
293
  query = Users.select().where(Users.username.like('%john%'))
294
+ query = Users.select().where(Users.username.not_like('%john%'))
295
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
296
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
297
+
298
+ # BETWEEN clause
299
+ query = Users.select().where(Users.age.between(18, 65))
300
+ query = Users.select().where(Users.age.not_between(18, 65))
266
301
 
267
302
  # ORDER BY
268
303
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -247,12 +247,47 @@ query = (Posts.select()
247
247
 
248
248
  ## Query Features
249
249
 
250
+ ### Selecting All Columns from a Table
251
+
252
+ Use `Table.ALL` to select all columns from a specific table:
253
+
250
254
  ```python
255
+ # Select all columns from posts
256
+ query = Posts.select(Posts.ALL)
257
+ # ('SELECT posts.* FROM posts', [])
258
+
259
+ # Select all columns from posts + specific columns from joined tables
260
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
261
+ .join(Users, Posts.user_id == Users.id))
262
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
263
+
264
+ # Select all columns from multiple tables
265
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
266
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
267
+ ```
268
+
269
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
270
+
271
+ ### NULL Checks and Other Operators
272
+
273
+ ```python
274
+ # NULL checks
275
+ query = Users.select().where(Users.email.is_null())
276
+ query = Users.select().where(Users.email.is_not_null())
277
+
251
278
  # IN clause
252
279
  query = Users.select().where(Users.id.in_([1, 2, 3]))
280
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
253
281
 
254
282
  # LIKE clause
255
283
  query = Users.select().where(Users.username.like('%john%'))
284
+ query = Users.select().where(Users.username.not_like('%john%'))
285
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
286
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
287
+
288
+ # BETWEEN clause
289
+ query = Users.select().where(Users.age.between(18, 65))
290
+ query = Users.select().where(Users.age.not_between(18, 65))
256
291
 
257
292
  # ORDER BY
258
293
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "3.2.0"
7
+ version = "4.0.0"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -838,23 +838,81 @@ def test_tstring_with_params_in_select():
838
838
  assert params == [' - ', 5]
839
839
 
840
840
 
841
- def test_column_is_null_property():
842
- """Test is_null property"""
843
- condition = Users.email.is_null
841
+ def test_select_table_all():
842
+ """Test selecting all columns from a table using Table.ALL"""
843
+ query = Posts.select(Posts.ALL)
844
+ sql, params = query.render()
845
+
846
+ assert 'SELECT posts.*' in sql
847
+ assert 'FROM posts' in sql
848
+ assert params == []
849
+
850
+
851
+ def test_select_table_all_with_other_columns():
852
+ """Test mixing Table.ALL with specific columns from another table"""
853
+ query = Posts.select(Posts.ALL, Users.username, Users.email)
854
+ sql, params = query.render()
855
+
856
+ assert 'SELECT posts.*, users.username, users.email' in sql
857
+ assert 'FROM posts' in sql
858
+ assert params == []
859
+
860
+
861
+ def test_select_multiple_table_alls():
862
+ """Test selecting all columns from multiple tables"""
863
+ query = Posts.select(Posts.ALL, Users.ALL)
864
+ sql, params = query.render()
865
+
866
+ assert 'SELECT posts.*, users.*' in sql
867
+ assert 'FROM posts' in sql
868
+ assert params == []
869
+
870
+
871
+ def test_select_table_all_with_join():
872
+ """Test Table.ALL in a real-world join scenario"""
873
+ query = (Posts.select(Posts.ALL, Users.username)
874
+ .join(Users, Posts.user_id == Users.id)
875
+ .where(Posts.id > 100))
876
+ sql, params = query.render()
877
+
878
+ assert 'SELECT posts.*, users.username' in sql
879
+ assert 'FROM posts' in sql
880
+ assert 'INNER JOIN users ON posts.user_id = users.id' in sql
881
+ assert 'WHERE posts.id > ?' in sql
882
+ assert params == [100]
883
+
884
+
885
+ def test_select_table_all_with_schema():
886
+ """Test that Table.ALL works with schema-qualified tables"""
887
+ class Accounts(Table, table_name='accounts', schema='other'):
888
+ id: Column
889
+ name: Column
890
+
891
+ query = Accounts.select(Accounts.ALL)
892
+ sql, params = query.render()
893
+
894
+ assert 'SELECT other.accounts.*' in sql
895
+ assert 'FROM other.accounts' in sql
896
+ assert params == []
897
+
898
+
899
+ def test_column_is_null_method():
900
+ """Test is_null method"""
901
+ condition = Users.email.is_null()
844
902
  assert condition.operator == 'IS'
845
903
  assert condition.right is None
846
904
 
847
905
 
848
- def test_column_is_not_null_property():
849
- """Test is_not_null property"""
850
- condition = Users.email.is_not_null
906
+ def test_column_is_not_null_method():
907
+ """Test is_not_null method"""
908
+ condition = Users.email.is_not_null()
851
909
  assert condition.operator == 'IS NOT'
852
910
  assert condition.right is None
853
911
 
854
912
 
855
913
  def test_where_with_is_null():
856
914
  """Test WHERE with is_null"""
857
- query = Users.select().where(Users.email.is_null)
915
+ query = Users.select().where(Users.email.is_null())
858
916
  sql, params = query.render()
859
917
 
860
918
  assert 'WHERE users.email IS NULL' in sql
@@ -863,7 +921,7 @@ def test_where_with_is_null():
863
921
 
864
922
  def test_where_with_is_not_null():
865
923
  """Test WHERE with is_not_null"""
866
- query = Users.select().where(Users.email.is_not_null)
924
+ query = Users.select().where(Users.email.is_not_null())
867
925
  sql, params = query.render()
868
926
 
869
927
  assert 'WHERE users.email IS NOT NULL' in sql
@@ -1002,7 +1060,7 @@ def test_complex_query_with_new_operators():
1002
1060
  """Test complex query using multiple new operators"""
1003
1061
  query = (Users.select()
1004
1062
  .where(Users.id.between(1, 100))
1005
- .where(Users.email.is_not_null)
1063
+ .where(Users.email.is_not_null())
1006
1064
  .where(Users.id.not_in([5, 10, 15]))
1007
1065
  .where(Users.username.ilike('%test%')))
1008
1066
  sql, params = query.render()
@@ -229,6 +229,38 @@ def test_mixing_query_builder_with_tsql():
229
229
  assert params == [18, 'john', 'john', 25]
230
230
 
231
231
 
232
+ def test_sa_column_annotations_are_correct_type():
233
+ """Test that SA Column assignments get correct type annotations for IDE autocomplete"""
234
+ from tsql.query_builder import Column as TsqlColumn
235
+
236
+ metadata = MetaData()
237
+
238
+ class MyTable(Table, table_name='mytable', metadata=metadata):
239
+ my_column = Column(TIMESTAMP())
240
+ another = Column(Integer())
241
+ text_field = Column(String(100))
242
+
243
+ # Verify that __annotations__ has been updated to reflect tsql.Column
244
+ assert 'my_column' in MyTable.__annotations__
245
+ assert 'another' in MyTable.__annotations__
246
+ assert 'text_field' in MyTable.__annotations__
247
+
248
+ assert MyTable.__annotations__['my_column'] == TsqlColumn
249
+ assert MyTable.__annotations__['another'] == TsqlColumn
250
+ assert MyTable.__annotations__['text_field'] == TsqlColumn
251
+
252
+ # Verify that the columns actually work as tsql.Column objects
253
+ col = MyTable.my_column
254
+ assert isinstance(col, TsqlColumn)
255
+ assert hasattr(col, 'is_null')
256
+ assert hasattr(col, 'asc')
257
+ assert hasattr(col, 'desc')
258
+
259
+ # Verify is_null works
260
+ condition = col.is_null()
261
+ assert condition.operator == 'IS'
262
+ assert condition.right is None
263
+
232
264
  def gen_id(prefix):
233
265
  """Dummy function for test"""
234
266
  return f"{prefix}_123"
@@ -38,10 +38,9 @@ class OrderByClause:
38
38
  class Column:
39
39
  """Represents a bound column (table + column name) for building queries"""
40
40
 
41
- def __init__(self, table_name: str = None, column_name: str = None, python_type: type = None, alias: str = None, schema: str = None):
41
+ def __init__(self, table_name: str = None, column_name: str = None, alias: str = None, schema: str = None):
42
42
  self.table_name = table_name
43
43
  self.column_name = column_name
44
- self.python_type = python_type
45
44
  self.alias = alias
46
45
  self.schema = schema
47
46
 
@@ -71,7 +70,7 @@ class Column:
71
70
  Example:
72
71
  users.select(users.first_name.as_('first'), users.last_name.as_('last'))
73
72
  """
74
- return Column(self.table_name, self.column_name, self.python_type, alias, self.schema)
73
+ return Column(self.table_name, self.column_name, alias, self.schema)
75
74
 
76
75
  def __eq__(self, other) -> 'Condition':
77
76
  if other is None:
@@ -149,12 +148,10 @@ class Column:
149
148
  """
150
149
  return Condition(self, 'NOT BETWEEN', (start, end))
151
150
 
152
- @property
153
151
  def is_null(self) -> 'Condition':
154
152
  """Create an IS NULL condition"""
155
153
  return Condition(self, 'IS', None)
156
154
 
157
- @property
158
155
  def is_not_null(self) -> 'Condition':
159
156
  """Create an IS NOT NULL condition"""
160
157
  return Condition(self, 'IS NOT', None)
@@ -271,7 +268,11 @@ class Table:
271
268
  sa_columns.append(sa_col)
272
269
 
273
270
  # Create query builder ColumnDescriptor
274
- setattr(cls, field_name, ColumnDescriptor(field_name, field_type))
271
+ setattr(cls, field_name, ColumnDescriptor(field_name))
272
+ # Update annotation to reflect the descriptor's return type
273
+ if not hasattr(cls, '__annotations__'):
274
+ cls.__annotations__ = {}
275
+ cls.__annotations__[field_name] = Column
275
276
  continue
276
277
 
277
278
  # Check if it's a Column instance (for column_name remapping)
@@ -283,7 +284,11 @@ class Table:
283
284
  db_column_name = field_name
284
285
 
285
286
  # Create query builder ColumnDescriptor with the DB column name
286
- setattr(cls, field_name, ColumnDescriptor(db_column_name, field_type))
287
+ setattr(cls, field_name, ColumnDescriptor(db_column_name))
288
+ # Update annotation to reflect the descriptor's return type
289
+ if not hasattr(cls, '__annotations__'):
290
+ cls.__annotations__ = {}
291
+ cls.__annotations__[field_name] = Column
287
292
 
288
293
  # Create SQLAlchemy column if metadata provided
289
294
  if metadata is not None and HAS_SQLALCHEMY:
@@ -294,7 +299,11 @@ class Table:
294
299
  # Check if it's an Ellipsis (...) declaration
295
300
  if field_value is ...:
296
301
  # Create query builder ColumnDescriptor
297
- setattr(cls, field_name, ColumnDescriptor(field_name, None))
302
+ setattr(cls, field_name, ColumnDescriptor(field_name))
303
+ # Update annotation to reflect the descriptor's return type
304
+ if not hasattr(cls, '__annotations__'):
305
+ cls.__annotations__ = {}
306
+ cls.__annotations__[field_name] = Column
298
307
  continue
299
308
 
300
309
  # Otherwise, handle type annotations
@@ -303,7 +312,11 @@ class Table:
303
312
  continue
304
313
 
305
314
  # Create query builder ColumnDescriptor for type-annotated fields
306
- setattr(cls, field_name, ColumnDescriptor(field_name, field_type))
315
+ setattr(cls, field_name, ColumnDescriptor(field_name))
316
+ # Update annotation to reflect the descriptor's return type
317
+ if not hasattr(cls, '__annotations__'):
318
+ cls.__annotations__ = {}
319
+ cls.__annotations__[field_name] = Column
307
320
 
308
321
  # Create SQLAlchemy column if metadata provided
309
322
  if metadata is not None and HAS_SQLALCHEMY:
@@ -314,6 +327,9 @@ class Table:
314
327
  if metadata is not None and HAS_SQLALCHEMY:
315
328
  cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, schema=schema)
316
329
 
330
+ # Add the ALL descriptor for wildcard column selection
331
+ cls.ALL = AllColumnsDescriptor()
332
+
317
333
  @classmethod
318
334
  def select(cls, *columns: Union['Column', Template]) -> 'SelectQueryBuilder':
319
335
  """Start building a SELECT query"""
@@ -367,9 +383,8 @@ class Table:
367
383
  class ColumnDescriptor:
368
384
  """Descriptor that creates Column objects when accessed on Table classes or instances"""
369
385
 
370
- def __init__(self, column_name: str, python_type: type = None):
386
+ def __init__(self, column_name: str):
371
387
  self.column_name = column_name
372
- self.python_type = python_type
373
388
 
374
389
  def __set_name__(self, owner, name):
375
390
  self.column_name = name
@@ -378,7 +393,17 @@ class ColumnDescriptor:
378
393
  if objtype is None:
379
394
  objtype = type(obj)
380
395
  schema = getattr(objtype, 'schema', None)
381
- return Column(objtype.table_name, self.column_name, self.python_type, schema=schema)
396
+ return Column(objtype.table_name, self.column_name, schema=schema)
397
+
398
+
399
+ class AllColumnsDescriptor:
400
+ """Descriptor that creates a Column with wildcard (*) for selecting all columns from a table"""
401
+
402
+ def __get__(self, obj, objtype=None) -> Column:
403
+ if objtype is None:
404
+ objtype = type(obj)
405
+ schema = getattr(objtype, 'schema', None)
406
+ return Column(objtype.table_name, '*', schema=schema)
382
407
 
383
408
 
384
409
  class Condition:
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