t-sql 1.2.1__tar.gz → 2.1.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.
Files changed (43) hide show
  1. t_sql-2.1.1/.beads/tsql.db +0 -0
  2. {t_sql-1.2.1 → t_sql-2.1.1}/.github/workflows/test.yml +20 -0
  3. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/workspace.xml +7 -1
  4. t_sql-2.1.1/PKG-INFO +531 -0
  5. t_sql-2.1.1/README.md +521 -0
  6. t_sql-2.1.1/compose.yaml +29 -0
  7. {t_sql-1.2.1 → t_sql-2.1.1}/context7.json +10 -0
  8. {t_sql-1.2.1 → t_sql-2.1.1}/pyproject.toml +8 -6
  9. t_sql-2.1.1/tests/test_alembic_integration.py +363 -0
  10. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_asyncpg_integration.py +1 -92
  11. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_helper_functions.py +2 -63
  12. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_injection_edge_cases.py +153 -1
  13. t_sql-2.1.1/tests/test_mysql_integration.py +286 -0
  14. t_sql-2.1.1/tests/test_query_builder.py +1169 -0
  15. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_sqlalchemy_integration.py +20 -31
  16. t_sql-2.1.1/tests/test_sqlite_integration.py +351 -0
  17. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_styles.py +24 -0
  18. {t_sql-1.2.1 → t_sql-2.1.1}/tsql/__init__.py +69 -64
  19. t_sql-2.1.1/tsql/query_builder.py +911 -0
  20. t_sql-1.2.1/PKG-INFO +0 -491
  21. t_sql-1.2.1/README.md +0 -480
  22. t_sql-1.2.1/compose.yaml +0 -14
  23. t_sql-1.2.1/tests/test_query_builder.py +0 -532
  24. t_sql-1.2.1/tsql/query_builder.py +0 -532
  25. {t_sql-1.2.1 → t_sql-2.1.1}/.dockerignore +0 -0
  26. {t_sql-1.2.1 → t_sql-2.1.1}/.github/workflows/publish.yml +0 -0
  27. {t_sql-1.2.1 → t_sql-2.1.1}/.gitignore +0 -0
  28. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/.gitignore +0 -0
  29. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  30. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  31. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/misc.xml +0 -0
  32. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/tsql.iml +0 -0
  33. {t_sql-1.2.1 → t_sql-2.1.1}/.idea/vcs.xml +0 -0
  34. {t_sql-1.2.1 → t_sql-2.1.1}/Dockerfile +0 -0
  35. {t_sql-1.2.1 → t_sql-2.1.1}/LICENSE +0 -0
  36. {t_sql-1.2.1 → t_sql-2.1.1}/pytest.ini +0 -0
  37. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_different_object_types.py +0 -0
  38. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_escaped.py +0 -0
  39. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_escaped_binary_hex.py +0 -0
  40. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_injection_protection_validation.py +0 -0
  41. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_injections_for_escaped.py +0 -0
  42. {t_sql-1.2.1 → t_sql-2.1.1}/tests/test_tsql.py +0 -0
  43. {t_sql-1.2.1 → t_sql-2.1.1}/tsql/styles.py +0 -0
Binary file
@@ -28,6 +28,21 @@ jobs:
28
28
  --health-timeout 5s
29
29
  --health-retries 5
30
30
 
31
+ mysql:
32
+ image: mysql:8.0
33
+ env:
34
+ MYSQL_ROOT_PASSWORD: password
35
+ MYSQL_DATABASE: testdb
36
+ MYSQL_USER: testuser
37
+ MYSQL_PASSWORD: password
38
+ ports:
39
+ - 3306:3306
40
+ options: >-
41
+ --health-cmd "mysqladmin ping -h localhost -u root -ppassword"
42
+ --health-interval 10s
43
+ --health-timeout 5s
44
+ --health-retries 5
45
+
31
46
  steps:
32
47
  - uses: actions/checkout@v4
33
48
 
@@ -45,4 +60,9 @@ jobs:
45
60
  - name: Run tests
46
61
  env:
47
62
  DATABASE_URL: postgresql://postgres:password@localhost:5432/postgres
63
+ MYSQL_HOST: localhost
64
+ MYSQL_PORT: 3306
65
+ MYSQL_USER: testuser
66
+ MYSQL_PASSWORD: password
67
+ MYSQL_DB: testdb
48
68
  run: uv run pytest -v
@@ -5,7 +5,13 @@
5
5
  </component>
6
6
  <component name="ChangeListManager">
7
7
  <list default="true" id="059146b3-62bd-4ad4-890d-4356cf237b89" name="Changes" comment="">
8
- <change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
8
+ <change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
9
+ <change beforePath="$PROJECT_DIR$/tests/test_mysql_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_mysql_integration.py" afterDir="false" />
10
+ <change beforePath="$PROJECT_DIR$/tests/test_query_builder.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_query_builder.py" afterDir="false" />
11
+ <change beforePath="$PROJECT_DIR$/tests/test_sqlalchemy_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_sqlalchemy_integration.py" afterDir="false" />
12
+ <change beforePath="$PROJECT_DIR$/tests/test_sqlite_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_sqlite_integration.py" afterDir="false" />
13
+ <change beforePath="$PROJECT_DIR$/tsql/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/tsql/__init__.py" afterDir="false" />
14
+ <change beforePath="$PROJECT_DIR$/tsql/query_builder.py" beforeDir="false" afterPath="$PROJECT_DIR$/tsql/query_builder.py" afterDir="false" />
9
15
  </list>
10
16
  <option name="SHOW_DIALOG" value="false" />
11
17
  <option name="HIGHLIGHT_CONFLICTS" value="true" />
t_sql-2.1.1/PKG-INFO ADDED
@@ -0,0 +1,531 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 2.1.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
+
11
+ # t-sql
12
+
13
+ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
14
+ (Note: This library has absolutely nothing to do with Microsoft SQLServer)
15
+
16
+ t-sql provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
17
+
18
+ ## ⚠️ Python Version Requirement
19
+ This library requires Python 3.14+
20
+
21
+ t-sql is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
22
+
23
+ ## Installing
24
+
25
+ ```bash
26
+ # with pip
27
+ pip install t-sql
28
+
29
+ # with uv
30
+ uv add t-sql
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ import tsql
37
+
38
+ # Basic usage
39
+ name = 'billy'
40
+ query = t'select * from users where name={name}'
41
+
42
+ # Render with default QMARK style
43
+ sql, params = tsql.render(query)
44
+ # ('select * from users where name = ?', ['billy'])
45
+
46
+ # Or use a different parameter style
47
+ sql, params = tsql.render(query, style=tsql.styles.NUMERIC_DOLLAR)
48
+ # ('select * from users where name = $1', ['billy'])
49
+ ```
50
+
51
+ ## Parameter Styles
52
+
53
+ - **QMARK** (default): Uses `?` placeholders
54
+ - **NUMERIC**: Uses `:1`, `:2`, etc. placeholders
55
+ - **NAMED**: Uses `:name` placeholders
56
+ - **FORMAT**: Uses `%s` placeholders
57
+ - **PYFORMAT**: Uses `%(name)s` placeholders
58
+ - **NUMERIC_DOLLAR**: Uses `$1`, `$2`, etc. (PostgreSQL native)
59
+ - **ESCAPED**: Escapes values directly into SQL (no parameters)
60
+
61
+ ## Core Features
62
+
63
+ ### SQL Injection Prevention
64
+
65
+ ```python
66
+ # SQL injection prevention works automatically
67
+ name = "billy ' and 1=1 --"
68
+ sql, params = tsql.render(t'select * from users where name={name}')
69
+ # Even with ESCAPED style, quotes are properly escaped
70
+ sql, _ = tsql.render(t'select * from users where name={name}', style=tsql.styles.ESCAPED)
71
+ # ("select * from users where name = 'billy '' and 1=1 --'", [])
72
+ ```
73
+
74
+ ### Format-spec helpers
75
+
76
+ #### Literal
77
+
78
+ For table/column names that can't be parameterized:
79
+
80
+ ```python
81
+ table = "users"
82
+ col = "name"
83
+ val = "billy"
84
+ query = t'select * from {table:literal} where {col:literal}={val}'
85
+ sql, params = tsql.render(query)
86
+ # ('select * from users where name = ?', ['billy'])
87
+ ```
88
+
89
+ #### unsafe
90
+
91
+ For cases where you need to bypass safety (use with extreme caution):
92
+
93
+ ```python
94
+ dynamic_where = "age > 18 AND active = true"
95
+ sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
96
+ ```
97
+
98
+ #### as_values
99
+
100
+ Formats a dictionary for INSERT statements:
101
+
102
+ ```python
103
+ values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
104
+ sql, params = tsql.render(t"INSERT INTO users {values:as_values}")
105
+ # ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
106
+ ```
107
+
108
+ #### as_set
109
+
110
+ Formats a dictionary for UPDATE statements:
111
+
112
+ ```python
113
+ values = {'name': 'joe', 'email': 'joe@example.com'}
114
+ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
115
+ # ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
116
+ ```
117
+
118
+ ### Helper Functions
119
+
120
+ t-sql provides several convenience functions for common SQL operations:
121
+
122
+ #### t_join
123
+
124
+ Joins multiple t-strings together:
125
+
126
+ ```python
127
+ import tsql
128
+
129
+ min_age = 18
130
+ parts = [t"SELECT *", t"FROM users", t"WHERE age > {min_age}"]
131
+ query = tsql.t_join(t" ", parts)
132
+ sql, params = tsql.render(query)
133
+ # ('SELECT * FROM users WHERE age > ?', [18])
134
+ ```
135
+
136
+ #### select
137
+
138
+ Quick SELECT queries:
139
+
140
+ ```python
141
+ # Select all columns
142
+ query = tsql.select('users')
143
+ sql, params = query.render()
144
+ # ('SELECT * FROM users', [])
145
+
146
+ # Select specific columns
147
+ query = tsql.select('users', columns=['name', 'email'])
148
+ sql, params = query.render()
149
+ # ('SELECT name, email FROM users', [])
150
+
151
+ # With WHERE clause
152
+ query = tsql.select('users', columns=['name', 'email'], where={'age': 18})
153
+ sql, params = query.render()
154
+ # ('SELECT name, email FROM users WHERE age = ?', [18])
155
+ ```
156
+
157
+ #### insert
158
+
159
+ Quick INSERT queries:
160
+
161
+ ```python
162
+ values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
163
+ query = tsql.insert('users', values)
164
+ sql, params = query.render()
165
+ # ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
166
+ ```
167
+
168
+ #### update
169
+
170
+ Quick UPDATE queries:
171
+
172
+ ```python
173
+ # Update by ID
174
+ query = tsql.update('users', {'email': 'new@example.com'}, id_value='abc123')
175
+ sql, params = query.render()
176
+ # ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
177
+
178
+ # Update with custom WHERE
179
+ query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
180
+ sql, params = query.render()
181
+ # ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
182
+ ```
183
+
184
+ #### delete
185
+
186
+ Quick DELETE queries:
187
+
188
+ ```python
189
+ # Delete by ID
190
+ query = tsql.delete('users', id_value='abc123')
191
+ sql, params = query.render()
192
+ # ('DELETE FROM users WHERE id = ?', ['abc123'])
193
+
194
+ # Delete with custom WHERE
195
+ query = tsql.delete('users', where={'age': 18})
196
+ sql, params = query.render()
197
+ # ('DELETE FROM users WHERE age = ?', [18])
198
+ ```
199
+
200
+ **Note:** These helper functions return query builder objects, so you can chain additional methods:
201
+
202
+ ```python
203
+ query = tsql.select('users').where(t'age > {min_age}').limit(10)
204
+ sql, params = query.render()
205
+ ```
206
+
207
+ # Query Builder
208
+
209
+ For a more structured approach, t-sql includes an optional query builder with a fluent interface and type-safe column references.
210
+
211
+ ## Basic Usage
212
+
213
+ ```python
214
+ from tsql.query_builder import Table, Column
215
+
216
+ class Users(Table):
217
+ id: Column
218
+ username: Column
219
+ email: Column
220
+ age: Column
221
+
222
+ # Simple SELECT
223
+ query = Users.select(Users.id, Users.username)
224
+ sql, params = query.render()
225
+ # ('SELECT users.id, users.username FROM users', [])
226
+
227
+ # With WHERE clause
228
+ query = Users.select().where(Users.age > 18)
229
+ sql, params = query.render()
230
+ # ('SELECT * FROM users WHERE users.age > ?', [18])
231
+
232
+ # Multiple conditions (ANDed together)
233
+ query = (Users.select(Users.username, Users.email)
234
+ .where(Users.age > 18)
235
+ .where(Users.email != None))
236
+ ```
237
+
238
+ **Table Names:** The table name defaults to the lowercase class name. To specify a custom name:
239
+
240
+ ```python
241
+ class UserAccount(Table, table_name='user_accounts'):
242
+ id: Column
243
+ username: Column
244
+ ```
245
+
246
+ ## Joins
247
+
248
+ ```python
249
+ class Posts(Table):
250
+ id: Column
251
+ user_id: Column
252
+ title: Column
253
+
254
+ # INNER JOIN
255
+ query = (Posts.select(Posts.title, Users.username)
256
+ .join(Users, on=Posts.user_id == Users.id)
257
+ .where(Posts.id > 100))
258
+
259
+ # LEFT JOIN
260
+ query = (Posts.select()
261
+ .left_join(Users, on=Posts.user_id == Users.id))
262
+ ```
263
+
264
+ ## Query Features
265
+
266
+ ```python
267
+ # IN clause
268
+ query = Users.select().where(Users.id.in_([1, 2, 3]))
269
+
270
+ # LIKE clause
271
+ query = Users.select().where(Users.username.like('%john%'))
272
+
273
+ # ORDER BY
274
+ query = Posts.select().order_by(Posts.id)
275
+ query = Posts.select().order_by((Posts.id, 'DESC'))
276
+
277
+ # LIMIT and OFFSET
278
+ query = Posts.select().limit(10).offset(20)
279
+
280
+ # GROUP BY and HAVING
281
+ query = (Posts.select()
282
+ .group_by(Posts.user_id)
283
+ .having(t'COUNT(*) > {min_count}'))
284
+ ```
285
+
286
+ ## Write Operations
287
+
288
+ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic conflict handling.
289
+
290
+ ### INSERT
291
+
292
+ ```python
293
+ # Basic insert
294
+ values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
295
+ query = Users.insert(values)
296
+ sql, params = query.render()
297
+ # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
298
+
299
+ # INSERT with RETURNING (Postgres/SQLite)
300
+ query = Users.insert(values).returning()
301
+ sql, params = query.render()
302
+ # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
303
+
304
+ # INSERT IGNORE (MySQL)
305
+ query = Users.insert(values).ignore()
306
+ sql, params = query.render()
307
+ # ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
308
+
309
+ # ON CONFLICT DO NOTHING (Postgres/SQLite)
310
+ query = Users.insert(values).on_conflict_do_nothing()
311
+ # ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
312
+
313
+ # ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
314
+ query = Users.insert(values).on_conflict_do_nothing(conflict_on='email')
315
+ # ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
316
+
317
+ # ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
318
+ query = Users.insert(values).on_conflict_update(conflict_on='id')
319
+ # ('INSERT INTO users (...) VALUES (...)
320
+ # ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
321
+
322
+ # ON CONFLICT with custom update
323
+ query = Users.insert(values).on_conflict_update(
324
+ conflict_on='id',
325
+ update={'username': 'updated_name'}
326
+ )
327
+
328
+ # ON DUPLICATE KEY UPDATE (MySQL)
329
+ query = Users.insert(values).on_duplicate_key_update()
330
+ # ('INSERT INTO users (...) VALUES (...)
331
+ # ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
332
+
333
+ # Chain multiple modifiers
334
+ query = (Users.insert(values)
335
+ .on_conflict_update(conflict_on='id')
336
+ .returning('id', 'username'))
337
+ ```
338
+
339
+ ### UPDATE
340
+
341
+ ```python
342
+ # Basic update (no WHERE = updates all rows!)
343
+ query = Users.update({'email': 'newemail@example.com'})
344
+ sql, params = query.render()
345
+ # ('UPDATE users SET email = ?', ['newemail@example.com'])
346
+
347
+ # UPDATE with WHERE
348
+ query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
349
+ sql, params = query.render()
350
+ # ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
351
+
352
+ # Multiple WHERE conditions
353
+ query = (Users.update({'email': 'newemail@example.com'})
354
+ .where(Users.id == 'abc123')
355
+ .where(Users.age > 18))
356
+
357
+ # With RETURNING (Postgres/SQLite)
358
+ query = (Users.update({'email': 'new@example.com'})
359
+ .where(Users.id == 'abc123')
360
+ .returning())
361
+ # ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
362
+ ```
363
+
364
+ ### DELETE
365
+
366
+ ```python
367
+ # Basic delete (no WHERE = deletes all rows!)
368
+ query = Users.delete()
369
+ sql, params = query.render()
370
+ # ('DELETE FROM users', [])
371
+
372
+ # DELETE with WHERE
373
+ query = Users.delete().where(Users.id == 'abc123')
374
+ sql, params = query.render()
375
+ # ('DELETE FROM users WHERE users.id = ?', ['abc123'])
376
+
377
+ # Multiple conditions
378
+ query = Users.delete().where(Users.age < 18).where(Users.active == False)
379
+
380
+ # With RETURNING (Postgres/SQLite)
381
+ query = Users.delete().where(Users.id == 'abc123').returning()
382
+ # ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
383
+ ```
384
+
385
+ ## Database Compatibility
386
+
387
+ The query builder is database-agnostic - all methods are available regardless of which database you're using. It's your responsibility to use the appropriate methods for your database:
388
+
389
+ **PostgreSQL:**
390
+ - ✅ `.returning()` - RETURNING clause
391
+ - ✅ `.on_conflict_do_nothing()` - ON CONFLICT DO NOTHING
392
+ - ✅ `.on_conflict_update()` - ON CONFLICT DO UPDATE with EXCLUDED.*
393
+ - ❌ `.ignore()` - Not supported
394
+ - ❌ `.on_duplicate_key_update()` - Not supported
395
+
396
+ **MySQL:**
397
+ - ❌ `.returning()` - Not supported (MySQL limitation)
398
+ - ✅ `.ignore()` - INSERT IGNORE
399
+ - ✅ `.on_duplicate_key_update()` - ON DUPLICATE KEY UPDATE with VALUES()
400
+ - ❌ `.on_conflict_do_nothing()` - Not supported
401
+ - ❌ `.on_conflict_update()` - Not supported
402
+
403
+ **SQLite:**
404
+ - ✅ `.returning()` - RETURNING clause (SQLite 3.35+)
405
+ - ✅ `.on_conflict_do_nothing()` - ON CONFLICT DO NOTHING
406
+ - ✅ `.on_conflict_update()` - ON CONFLICT DO UPDATE
407
+ - ❌ `.ignore()` - Not supported
408
+ - ❌ `.on_duplicate_key_update()` - Not supported
409
+
410
+ If you use an unsupported method, your database will raise a syntax error when you execute the query.
411
+
412
+ ## Mixing Query Builder with T-Strings
413
+
414
+ You can combine the query builder with raw t-strings for complex logic:
415
+
416
+ ```python
417
+ from tsql.query_builder import Table, Column
418
+
419
+ class Users(Table):
420
+ id: Column
421
+ name: Column
422
+ age: Column
423
+ email: Column
424
+
425
+ # Start with query builder
426
+ query = Users.select(Users.id, Users.name, Users.email)
427
+
428
+ # Add structured condition
429
+ query = query.where(Users.age > 18)
430
+
431
+ # Add complex t-string condition for OR logic
432
+ search_term = "john"
433
+ name_col = str(Users.name)
434
+ email_col = str(Users.email)
435
+ complex_condition = t"{name_col:literal} LIKE '%' || {search_term} || '%' OR {email_col:literal} LIKE '%' || {search_term} || '%'"
436
+ query = query.where(complex_condition)
437
+
438
+ sql, params = query.render()
439
+ # SELECT users.id, users.name, users.email FROM users
440
+ # WHERE users.age > ? AND (users.name LIKE '%' || ? || '%' OR users.email LIKE '%' || ? || '%')
441
+ # params: [18, 'john', 'john']
442
+ ```
443
+
444
+ Note: T-string conditions passed to `.where()` are automatically wrapped in parentheses to ensure proper operator precedence.
445
+
446
+ ## SQLAlchemy & Alembic Integration
447
+
448
+ The query builder can integrate with SQLAlchemy's metadata system for alembic autogenerate:
449
+
450
+ ```bash
451
+ pip install t-sql[sqlalchemy]
452
+ # or
453
+ uv add t-sql --optional sqlalchemy
454
+ ```
455
+
456
+ ### Two Ways to Define Columns
457
+
458
+ **1. Simple Column annotations** (for query builder only):
459
+
460
+ ```python
461
+ from tsql import Table, Column
462
+
463
+ class Users(Table):
464
+ id: Column
465
+ name: Column
466
+ age: Column
467
+ ```
468
+
469
+ **2. SQLAlchemy Column objects** (for alembic integration):
470
+
471
+ ```python
472
+ from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
473
+ from tsql.query_builder import Table
474
+
475
+ metadata = MetaData()
476
+
477
+ class Users(Table, metadata=metadata):
478
+ id = Column(String, primary_key=True)
479
+ email = Column(String(255), unique=True, nullable=False)
480
+ name = Column(String(100))
481
+ age = Column(Integer)
482
+
483
+ # Use for alembic
484
+ target_metadata = metadata
485
+
486
+ # Use for queries (works identically!)
487
+ query = Users.select().where(Users.age > 18)
488
+ ```
489
+
490
+ You can mix both approaches:
491
+
492
+ ```python
493
+ from sqlalchemy import Column, String, DateTime
494
+ from sqlalchemy.sql.functions import now
495
+
496
+ class Events(Table, metadata=metadata):
497
+ id = Column(String, primary_key=True)
498
+ topic: Column # Simple annotation - becomes nullable String column
499
+ created_at = Column(DateTime, server_default=now())
500
+ ```
501
+
502
+ ## Schema Support
503
+
504
+ ```python
505
+ class Users(Table, schema='public'):
506
+ id: Column
507
+ name: Column
508
+ ```
509
+
510
+ Or with custom table name and schema:
511
+
512
+ ```python
513
+ class Users(Table, table_name='user_accounts', schema='public'):
514
+ id: Column
515
+ name: Column
516
+ ```
517
+
518
+ # Note on Usage
519
+
520
+ This library should ideally be used in middleware or library code right before making a query. It can enforce the use of t-strings and prevent raw strings:
521
+
522
+ ```python
523
+ from string.templatelib import Template
524
+ import tsql
525
+
526
+ def execute_sql_query(query):
527
+ if not isinstance(query, Template):
528
+ raise TypeError('Cannot make a query without using t-strings')
529
+
530
+ return sql_engine.execute(*tsql.render(query))
531
+ ```