t-sql 4.5.2__tar.gz → 4.6.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 (37) hide show
  1. t_sql-4.5.2/README.md → t_sql-4.6.0/PKG-INFO +46 -0
  2. t_sql-4.5.2/PKG-INFO → t_sql-4.6.0/README.md +36 -10
  3. {t_sql-4.5.2 → t_sql-4.6.0}/pyproject.toml +1 -1
  4. t_sql-4.6.0/tests/test_string_based_builders.py +183 -0
  5. {t_sql-4.5.2 → t_sql-4.6.0}/tsql/query_builder.py +149 -25
  6. {t_sql-4.5.2 → t_sql-4.6.0}/.dockerignore +0 -0
  7. {t_sql-4.5.2 → t_sql-4.6.0}/.github/workflows/publish.yml +0 -0
  8. {t_sql-4.5.2 → t_sql-4.6.0}/.github/workflows/test.yml +0 -0
  9. {t_sql-4.5.2 → t_sql-4.6.0}/.gitignore +0 -0
  10. {t_sql-4.5.2 → t_sql-4.6.0}/Dockerfile +0 -0
  11. {t_sql-4.5.2 → t_sql-4.6.0}/LICENSE +0 -0
  12. {t_sql-4.5.2 → t_sql-4.6.0}/compose.yaml +0 -0
  13. {t_sql-4.5.2 → t_sql-4.6.0}/context7.json +0 -0
  14. {t_sql-4.5.2 → t_sql-4.6.0}/pytest.ini +0 -0
  15. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_alembic_integration.py +0 -0
  16. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_asyncpg_integration.py +0 -0
  17. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_different_object_types.py +0 -0
  18. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_error_messages.py +0 -0
  19. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_escaped.py +0 -0
  20. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_escaped_binary_hex.py +0 -0
  21. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_helper_functions.py +0 -0
  22. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injection_edge_cases.py +0 -0
  23. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injection_protection_validation.py +0 -0
  24. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injections_for_escaped.py +0 -0
  25. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_mysql_integration.py +0 -0
  26. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_parameter_names.py +0 -0
  27. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_query_builder.py +0 -0
  28. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_sqlalchemy_integration.py +0 -0
  29. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_sqlite_integration.py +0 -0
  30. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_styles.py +0 -0
  31. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_template_in_builders.py +0 -0
  32. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_tsql.py +0 -0
  33. {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_type_processor.py +0 -0
  34. {t_sql-4.5.2 → t_sql-4.6.0}/tsql/__init__.py +0 -0
  35. {t_sql-4.5.2 → t_sql-4.6.0}/tsql/row.py +0 -0
  36. {t_sql-4.5.2 → t_sql-4.6.0}/tsql/styles.py +0 -0
  37. {t_sql-4.5.2 → t_sql-4.6.0}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
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
+
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.5.2"
7
+ version = "4.6.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"
@@ -0,0 +1,183 @@
1
+ """Test string-based query builders (dynamic table/column names)"""
2
+ from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
3
+
4
+
5
+ def test_select_from_string_table():
6
+ """Test SELECT query with string table name"""
7
+ query = SelectQueryBuilder.from_table('users').select('id', 'name', 'email')
8
+ sql, params = query.render()
9
+
10
+ assert 'SELECT id, name, email' in sql
11
+ assert 'FROM users' in sql
12
+ assert params == []
13
+
14
+
15
+ def test_select_with_schema():
16
+ """Test SELECT with schema qualification"""
17
+ query = SelectQueryBuilder.from_table('users', schema='public').select('id', 'name')
18
+ sql, params = query.render()
19
+
20
+ assert 'FROM public.users' in sql
21
+
22
+
23
+ def test_select_with_where_tstring():
24
+ """Test SELECT with t-string WHERE clause"""
25
+ user_id = 123
26
+ status = 'active'
27
+ query = SelectQueryBuilder.from_table('users').select('name').where(t'id = {user_id} AND status = {status}')
28
+ sql, params = query.render()
29
+
30
+ assert 'WHERE' in sql
31
+ assert params == [123, 'active']
32
+
33
+
34
+ def test_select_order_by_string():
35
+ """Test ORDER BY with string column names"""
36
+ query = SelectQueryBuilder.from_table('users').select('id', 'name').order_by('created_at', direction='DESC')
37
+ sql, params = query.render()
38
+
39
+ assert 'ORDER BY created_at DESC' in sql
40
+
41
+
42
+ def test_select_order_by_multiple():
43
+ """Test ORDER BY with multiple columns (chained calls)"""
44
+ query = SelectQueryBuilder.from_table('users') \
45
+ .select('id', 'name') \
46
+ .order_by('username') \
47
+ .order_by('created_at', direction='DESC')
48
+ sql, params = query.render()
49
+
50
+ assert 'ORDER BY username ASC, created_at DESC' in sql
51
+
52
+
53
+ def test_select_group_by_string():
54
+ """Test GROUP BY with string column names"""
55
+ query = SelectQueryBuilder.from_table('orders').select('user_id', t'COUNT(*) as count').group_by('user_id')
56
+ sql, params = query.render()
57
+
58
+ assert 'GROUP BY user_id' in sql
59
+
60
+
61
+ def test_select_limit_offset():
62
+ """Test LIMIT and OFFSET with string-based query"""
63
+ query = SelectQueryBuilder.from_table('users').select('id').limit(10).offset(20)
64
+ sql, params = query.render()
65
+
66
+ assert 'LIMIT' in sql
67
+ assert 'OFFSET' in sql
68
+ assert params == [10, 20]
69
+
70
+
71
+ def test_insert_into_string_table():
72
+ """Test INSERT with string table name"""
73
+ query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'})
74
+ sql, params = query.render()
75
+
76
+ assert 'INSERT INTO users' in sql
77
+ assert 'name' in sql
78
+ assert 'email' in sql
79
+ assert 'Bob' in params
80
+ assert 'bob@test.com' in params
81
+
82
+
83
+ def test_insert_with_schema():
84
+ """Test INSERT with schema qualification"""
85
+ query = InsertBuilder.into_table('users', {'name': 'Alice'}, schema='public')
86
+ sql, params = query.render()
87
+
88
+ assert 'INSERT INTO public.users' in sql
89
+
90
+
91
+ def test_insert_with_returning():
92
+ """Test INSERT with RETURNING clause"""
93
+ query = InsertBuilder.into_table('users', {'name': 'Bob'}).returning('id')
94
+ sql, params = query.render()
95
+
96
+ assert 'RETURNING id' in sql
97
+
98
+
99
+ def test_insert_on_conflict_do_nothing():
100
+ """Test INSERT with ON CONFLICT DO NOTHING"""
101
+ query = InsertBuilder.into_table('users', {'email': 'test@example.com'}).on_conflict_do_nothing('email')
102
+ sql, params = query.render()
103
+
104
+ assert 'ON CONFLICT' in sql
105
+ assert 'DO NOTHING' in sql
106
+
107
+
108
+ def test_update_string_table():
109
+ """Test UPDATE with string table name"""
110
+ cutoff_date = '2024-01-01'
111
+ query = UpdateBuilder.table('users', {'status': 'inactive'}).where(t'last_login < {cutoff_date}')
112
+ sql, params = query.render()
113
+
114
+ assert 'UPDATE users' in sql
115
+ assert 'SET status' in sql
116
+ assert 'WHERE' in sql
117
+ assert 'inactive' in params
118
+ assert '2024-01-01' in params
119
+
120
+
121
+ def test_update_with_schema():
122
+ """Test UPDATE with schema qualification"""
123
+ status = 'active'
124
+ query = UpdateBuilder.table('users', {'status': 'inactive'}, schema='public').where(t'status = {status}')
125
+ sql, params = query.render()
126
+
127
+ assert 'UPDATE public.users' in sql
128
+
129
+
130
+ def test_delete_from_string_table():
131
+ """Test DELETE with string table name"""
132
+ cutoff = '2023-01-01'
133
+ query = DeleteBuilder.from_table('users').where(t'created_at < {cutoff}')
134
+ sql, params = query.render()
135
+
136
+ assert 'DELETE FROM users' in sql
137
+ assert 'WHERE' in sql
138
+ assert '2023-01-01' in params
139
+
140
+
141
+ def test_delete_with_schema():
142
+ """Test DELETE with schema qualification"""
143
+ user_id = 999
144
+ query = DeleteBuilder.from_table('users', schema='public').where(t'id = {user_id}')
145
+ sql, params = query.render()
146
+
147
+ assert 'DELETE FROM public.users' in sql
148
+
149
+
150
+ def test_string_based_prevents_injection():
151
+ """Test that invalid identifiers raise errors via :literal validation"""
152
+ import pytest
153
+
154
+ # Invalid table name
155
+ with pytest.raises(ValueError):
156
+ query = SelectQueryBuilder.from_table('users; DROP TABLE users--')
157
+ query.render()
158
+
159
+ # Invalid column name
160
+ with pytest.raises(ValueError):
161
+ query = SelectQueryBuilder.from_table('users').select('id; DROP TABLE users--')
162
+ query.render()
163
+
164
+ # Invalid schema name (too many dots)
165
+ with pytest.raises(ValueError):
166
+ query = SelectQueryBuilder.from_table('users', schema='a.b.c.d')
167
+ query.render()
168
+
169
+
170
+ def test_mixed_string_and_tstring_columns():
171
+ """Test mixing string columns with t-string expressions"""
172
+ query = SelectQueryBuilder.from_table('users').select('id', 'name', t'UPPER(email) AS email_upper')
173
+ sql, params = query.render()
174
+
175
+ assert 'SELECT id, name, UPPER(email) AS email_upper' in sql
176
+
177
+
178
+ def test_string_select_star():
179
+ """Test SELECT * with string-based table"""
180
+ query = SelectQueryBuilder.from_table('users')
181
+ sql, params = query.render()
182
+
183
+ assert 'SELECT * FROM users' in sql
@@ -14,6 +14,18 @@ class UnsafeQueryError(Exception):
14
14
  """
15
15
  pass
16
16
 
17
+
18
+ class _StringTable:
19
+ """Minimal table representation for string-based queries.
20
+
21
+ This acts like a Table class but without the Column descriptors.
22
+ Used internally when queries are built with string table names.
23
+ """
24
+ def __init__(self, table_name: str, schema: Optional[str] = None):
25
+ self.table_name = table_name
26
+ self.schema = schema
27
+ self._type_processors = {}
28
+
17
29
  # Optional SQLAlchemy support
18
30
  try:
19
31
  from sqlalchemy import MetaData, Table as SATable, Column as SAColumn
@@ -620,7 +632,7 @@ class QueryBuilder(ABC):
620
632
  class InsertBuilder(QueryBuilder):
621
633
  """Fluent interface for building INSERT queries"""
622
634
 
623
- def __init__(self, base_table: type['Table'], values: dict[str, Any]):
635
+ def __init__(self, base_table: Union[type['Table'], _StringTable], values: dict[str, Any]):
624
636
  self.base_table = base_table
625
637
 
626
638
  # Apply defaults from SQLAlchemy columns if available
@@ -650,6 +662,26 @@ class InsertBuilder(QueryBuilder):
650
662
  self._update_cols: Optional[dict[str, Any]] = None
651
663
  self._returning_cols: Optional[List[str]] = None
652
664
 
665
+ @classmethod
666
+ def into_table(cls, table_name: str, values: dict[str, Any], schema: Optional[str] = None) -> 'InsertBuilder':
667
+ """Create an InsertBuilder from a string table name.
668
+
669
+ Args:
670
+ table_name: Name of the table
671
+ values: Dictionary of column names and values
672
+ schema: Optional schema name
673
+
674
+ Returns:
675
+ InsertBuilder instance
676
+
677
+ Example:
678
+ InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \\
679
+ .on_conflict_do_nothing('email') \\
680
+ .returning('id')
681
+ """
682
+ string_table = _StringTable(table_name, schema)
683
+ return cls(string_table, values)
684
+
653
685
  def ignore(self) -> 'InsertBuilder':
654
686
  """Add INSERT IGNORE (MySQL)"""
655
687
  self._ignore = True
@@ -848,7 +880,7 @@ def _process_value_for_builder(value: Any, type_processor: Any = None) -> Any:
848
880
  class UpdateBuilder(QueryBuilder):
849
881
  """Fluent interface for building UPDATE queries"""
850
882
 
851
- def __init__(self, base_table: type['Table'], values: dict[str, Any]):
883
+ def __init__(self, base_table: Union[type['Table'], _StringTable], values: dict[str, Any]):
852
884
  self.base_table = base_table
853
885
 
854
886
  # Apply onupdate defaults from SQLAlchemy columns if available
@@ -876,6 +908,25 @@ class UpdateBuilder(QueryBuilder):
876
908
  self._returning_cols: Optional[List[str]] = None
877
909
  self._requires_where: bool = True
878
910
 
911
+ @classmethod
912
+ def table(cls, table_name: str, values: dict[str, Any], schema: Optional[str] = None) -> 'UpdateBuilder':
913
+ """Create an UpdateBuilder from a string table name.
914
+
915
+ Args:
916
+ table_name: Name of the table
917
+ values: Dictionary of column names and values to update
918
+ schema: Optional schema name
919
+
920
+ Returns:
921
+ UpdateBuilder instance
922
+
923
+ Example:
924
+ UpdateBuilder.table('users', {'status': 'inactive'}, schema='public') \\
925
+ .where(t'last_login < {cutoff_date}')
926
+ """
927
+ string_table = _StringTable(table_name, schema)
928
+ return cls(string_table, values)
929
+
879
930
  def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
880
931
  """Add a WHERE condition (multiple calls are ANDed together)"""
881
932
  self._conditions.append(condition)
@@ -977,12 +1028,30 @@ class UpdateBuilder(QueryBuilder):
977
1028
  class DeleteBuilder(QueryBuilder):
978
1029
  """Fluent interface for building DELETE queries"""
979
1030
 
980
- def __init__(self, base_table: type['Table']):
1031
+ def __init__(self, base_table: Union[type['Table'], _StringTable]):
981
1032
  self.base_table = base_table
982
1033
  self._conditions: List[Union[Condition, Template]] = []
983
1034
  self._returning_cols: Optional[List[str]] = None
984
1035
  self._requires_where: bool = True
985
1036
 
1037
+ @classmethod
1038
+ def from_table(cls, table_name: str, schema: Optional[str] = None) -> 'DeleteBuilder':
1039
+ """Create a DeleteBuilder from a string table name.
1040
+
1041
+ Args:
1042
+ table_name: Name of the table
1043
+ schema: Optional schema name
1044
+
1045
+ Returns:
1046
+ DeleteBuilder instance
1047
+
1048
+ Example:
1049
+ DeleteBuilder.from_table('users', schema='public') \\
1050
+ .where(t'created_at < {cutoff}')
1051
+ """
1052
+ string_table = _StringTable(table_name, schema)
1053
+ return cls(string_table)
1054
+
986
1055
  def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
987
1056
  """Add a WHERE condition (multiple calls are ANDed together)"""
988
1057
  self._conditions.append(condition)
@@ -1077,22 +1146,41 @@ class DeleteBuilder(QueryBuilder):
1077
1146
  class SelectQueryBuilder(QueryBuilder):
1078
1147
  """Fluent interface for building SQL SELECT queries"""
1079
1148
 
1080
- def __init__(self, base_table: type['Table']):
1149
+ def __init__(self, base_table: Union[type['Table'], _StringTable]):
1081
1150
  self.base_table = base_table
1082
- self._columns: Optional[List[Column]] = None
1083
- self._conditions: List[Condition] = []
1151
+ self._columns: Optional[List[Union[Column, str]]] = None
1152
+ self._conditions: List[Union[Condition, Template]] = []
1084
1153
  self._joins: List[Join] = []
1085
- self._group_by_columns: List[Column] = []
1154
+ self._group_by_columns: List[Union[Column, str]] = []
1086
1155
  self._having_conditions: List[Union[Condition, Template]] = []
1087
- self._order_by_columns: List[tuple[Column, str]] = []
1156
+ self._order_by_columns: List[tuple[Union[Column, str], str]] = []
1088
1157
  self._limit_value: Optional[int] = None
1089
1158
  self._offset_value: Optional[int] = None
1090
1159
 
1091
- def select(self, *columns: Union[Column, Template]) -> 'SelectQueryBuilder':
1160
+ @classmethod
1161
+ def from_table(cls, table_name: str, schema: Optional[str] = None) -> 'SelectQueryBuilder':
1162
+ """Create a SelectQueryBuilder from a string table name.
1163
+
1164
+ Args:
1165
+ table_name: Name of the table
1166
+ schema: Optional schema name
1167
+
1168
+ Returns:
1169
+ SelectQueryBuilder instance
1170
+
1171
+ Example:
1172
+ SelectQueryBuilder.from_table('users', schema='public') \\
1173
+ .select('id', 'name') \\
1174
+ .where(t'status = {status}')
1175
+ """
1176
+ string_table = _StringTable(table_name, schema)
1177
+ return cls(string_table)
1178
+
1179
+ def select(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
1092
1180
  """Specify columns to select
1093
1181
 
1094
1182
  Args:
1095
- columns: Column objects (optionally with .as_() aliases) or raw t-string Templates
1183
+ columns: Column objects (optionally with .as_() aliases), raw t-string Templates, or string column names
1096
1184
 
1097
1185
  Examples:
1098
1186
  # Using Column.as_() for aliases
@@ -1101,6 +1189,9 @@ class SelectQueryBuilder(QueryBuilder):
1101
1189
  # Mixing Column objects and raw t-strings
1102
1190
  users.select(users.id, users.email, t'users.first_name AS first')
1103
1191
 
1192
+ # String-based columns
1193
+ SelectQueryBuilder.from_table('users').select('id', 'name', 'email')
1194
+
1104
1195
  # No columns specified selects all (SELECT *)
1105
1196
  users.select()
1106
1197
  """
@@ -1128,11 +1219,12 @@ class SelectQueryBuilder(QueryBuilder):
1128
1219
  """Add a RIGHT JOIN clause"""
1129
1220
  return self.join(table, on, 'RIGHT')
1130
1221
 
1131
- def order_by(self, *columns: Union[Column, OrderByClause]) -> 'SelectQueryBuilder':
1222
+ def order_by(self, *columns: Union[Column, OrderByClause, str], direction: str = 'ASC') -> 'SelectQueryBuilder':
1132
1223
  """Add ORDER BY clause
1133
1224
 
1134
1225
  Args:
1135
- columns: Column objects or OrderByClause objects (from .asc()/.desc())
1226
+ columns: Column objects, OrderByClause objects (from .asc()/.desc()), or string column names
1227
+ direction: Sort direction ('ASC' or 'DESC') for columns that don't have explicit direction
1136
1228
 
1137
1229
  Examples:
1138
1230
  # Using .asc() and .desc() methods
@@ -1140,16 +1232,31 @@ class SelectQueryBuilder(QueryBuilder):
1140
1232
 
1141
1233
  # Bare column defaults to ASC
1142
1234
  Users.select().order_by(Users.username)
1235
+
1236
+ # String-based ordering with explicit direction
1237
+ SelectQueryBuilder.from_table('users').order_by('username', direction='DESC')
1238
+
1239
+ # Multiple columns in one call
1240
+ Users.select().order_by(Users.username, Users.id.desc())
1143
1241
  """
1144
- for col in columns:
1145
- if isinstance(col, OrderByClause):
1146
- self._order_by_columns.append((col.column, col.direction))
1242
+ for column in columns:
1243
+ if isinstance(column, OrderByClause):
1244
+ self._order_by_columns.append((column.column, column.direction))
1147
1245
  else:
1148
- self._order_by_columns.append((col, 'ASC'))
1246
+ # Column or string with direction
1247
+ self._order_by_columns.append((column, direction.upper()))
1149
1248
  return self
1150
1249
 
1151
- def group_by(self, *columns: Column) -> 'SelectQueryBuilder':
1152
- """Add GROUP BY clause"""
1250
+ def group_by(self, *columns: Union[Column, str]) -> 'SelectQueryBuilder':
1251
+ """Add GROUP BY clause
1252
+
1253
+ Args:
1254
+ columns: Column objects or string column names
1255
+
1256
+ Examples:
1257
+ # String-based GROUP BY
1258
+ SelectQueryBuilder.from_table('orders').select('user_id', 'COUNT(*)').group_by('user_id')
1259
+ """
1153
1260
  self._group_by_columns.extend(columns)
1154
1261
  return self
1155
1262
 
@@ -1176,11 +1283,14 @@ class SelectQueryBuilder(QueryBuilder):
1176
1283
  parts: List[Template] = []
1177
1284
 
1178
1285
  if self._columns:
1179
- # Build column list, handling both Column objects and Template (t-string) objects
1286
+ # Build column list, handling Column objects, Template (t-string) objects, and strings
1180
1287
  column_parts = []
1181
1288
  for col in self._columns:
1182
1289
  if isinstance(col, Template):
1183
1290
  column_parts.append(col)
1291
+ elif isinstance(col, str):
1292
+ # String column name, use :literal for validation
1293
+ column_parts.append(t'{col:literal}')
1184
1294
  else:
1185
1295
  # Column object, convert to string
1186
1296
  column_parts.append(t'{str(col):unsafe}')
@@ -1210,9 +1320,15 @@ class SelectQueryBuilder(QueryBuilder):
1210
1320
  parts.append(t'WHERE {combined_where}')
1211
1321
 
1212
1322
  if self._group_by_columns:
1213
- group_by_strs = [str(col) for col in self._group_by_columns]
1214
- group_by_str = ', '.join(group_by_strs)
1215
- parts.append(t'GROUP BY {group_by_str:unsafe}')
1323
+ group_by_parts = []
1324
+ for col in self._group_by_columns:
1325
+ if isinstance(col, str):
1326
+ group_by_parts.append(t'{col:literal}')
1327
+ else:
1328
+ col_str = str(col)
1329
+ group_by_parts.append(t'{col_str:unsafe}')
1330
+ group_by_template = t_join(t', ', group_by_parts)
1331
+ parts.append(t'GROUP BY {group_by_template}')
1216
1332
 
1217
1333
  if self._having_conditions:
1218
1334
  having_parts = []
@@ -1225,9 +1341,17 @@ class SelectQueryBuilder(QueryBuilder):
1225
1341
  parts.append(t'HAVING {combined_having}')
1226
1342
 
1227
1343
  if self._order_by_columns:
1228
- order_strs = [f"{col} {direction}" for col, direction in self._order_by_columns]
1229
- order_by_str = ', '.join(order_strs)
1230
- parts.append(t'ORDER BY {order_by_str:unsafe}')
1344
+ order_parts = []
1345
+ for col, direction in self._order_by_columns:
1346
+ if isinstance(col, str):
1347
+ # String column name - validate with :literal
1348
+ order_parts.append(t'{col:literal} {direction:unsafe}')
1349
+ else:
1350
+ # Column object - convert to string
1351
+ col_str = str(col)
1352
+ order_parts.append(t'{col_str:unsafe} {direction:unsafe}')
1353
+ order_by_template = t_join(t', ', order_parts)
1354
+ parts.append(t'ORDER BY {order_by_template}')
1231
1355
 
1232
1356
  if self._limit_value is not None:
1233
1357
  limit_val = self._limit_value
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