t-sql 1.0.1__tar.gz → 1.1.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 (35) hide show
  1. t_sql-1.1.0/.github/workflows/publish.yml +28 -0
  2. t_sql-1.1.0/.github/workflows/test.yml +31 -0
  3. t_sql-1.1.0/PKG-INFO +425 -0
  4. t_sql-1.1.0/README.md +414 -0
  5. {t_sql-1.0.1 → t_sql-1.1.0}/pyproject.toml +5 -1
  6. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_injections_for_escaped.py +1 -1
  7. t_sql-1.1.0/tests/test_query_builder.py +390 -0
  8. t_sql-1.1.0/tests/test_sqlalchemy_integration.py +245 -0
  9. t_sql-1.1.0/tsql/query_builder.py +381 -0
  10. t_sql-1.0.1/.idea/.gitignore +0 -8
  11. t_sql-1.0.1/.idea/inspectionProfiles/Project_Default.xml +0 -24
  12. t_sql-1.0.1/.idea/inspectionProfiles/profiles_settings.xml +0 -6
  13. t_sql-1.0.1/.idea/misc.xml +0 -11
  14. t_sql-1.0.1/.idea/tsql.iml +0 -10
  15. t_sql-1.0.1/.idea/vcs.xml +0 -6
  16. t_sql-1.0.1/.idea/workspace.xml +0 -92
  17. t_sql-1.0.1/PKG-INFO +0 -182
  18. t_sql-1.0.1/README.md +0 -173
  19. {t_sql-1.0.1 → t_sql-1.1.0}/.dockerignore +0 -0
  20. {t_sql-1.0.1 → t_sql-1.1.0}/.gitignore +0 -0
  21. {t_sql-1.0.1 → t_sql-1.1.0}/Dockerfile +0 -0
  22. {t_sql-1.0.1 → t_sql-1.1.0}/LICENSE +0 -0
  23. {t_sql-1.0.1 → t_sql-1.1.0}/compose.yaml +0 -0
  24. {t_sql-1.0.1 → t_sql-1.1.0}/pytest.ini +0 -0
  25. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_asyncpg_integration.py +0 -0
  26. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_different_object_types.py +0 -0
  27. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_escaped.py +0 -0
  28. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_escaped_binary_hex.py +0 -0
  29. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_helper_functions.py +0 -0
  30. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_injection_edge_cases.py +0 -0
  31. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_injection_protection_validation.py +0 -0
  32. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_styles.py +0 -0
  33. {t_sql-1.0.1 → t_sql-1.1.0}/tests/test_tsql.py +0 -0
  34. {t_sql-1.0.1 → t_sql-1.1.0}/tsql/__init__.py +0 -0
  35. {t_sql-1.0.1 → t_sql-1.1.0}/tsql/styles.py +0 -0
@@ -0,0 +1,28 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.14"
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v4
23
+
24
+ - name: Build package
25
+ run: uv build
26
+
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,31 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.14"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v4
26
+
27
+ - name: Install dependencies
28
+ run: uv sync
29
+
30
+ - name: Run tests
31
+ run: uv run pytest -v
t_sql-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,425 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 1.1.0
4
+ Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
+ Project-URL: Homepage, https://github.com/nhumrich/tsql
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Provides-Extra: sqlalchemy
9
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'sqlalchemy'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # tsql
13
+
14
+ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
15
+
16
+ TSQL 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
+ TSQL 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
+ ```
26
+ # with pip
27
+ pip install t-sql
28
+
29
+ # with uv
30
+ uv add t-sql
31
+ ```
32
+
33
+ ## using
34
+
35
+ ```
36
+ import tsql
37
+
38
+ tsql.render(t"select * from users where name={name)")
39
+ ```
40
+
41
+ ## Parameter Styles
42
+
43
+ - **QMARK** (default): Uses `?` placeholders
44
+ - **NUMERIC**: Uses `:1`, `:2`, etc. placeholders
45
+ - **NAMED**: Uses `:name` placeholders
46
+ - **FORMAT**: Uses `%s` placeholders
47
+ - **PYFORMAT**: Uses `%(name)s` placeholders
48
+ - **NUMERIC_DOLLAR**: Uses `$1`, `$2`, etc. (PostgreSQL native)
49
+ - **ESCAPED**: Escapes values directly into SQL (no parameters)
50
+
51
+ ## Examples:
52
+
53
+ ```python
54
+
55
+ # Basic usage with different parameter styles
56
+ import tsql
57
+ import tsql.styles
58
+
59
+ name = 'billy'
60
+ query = t'select * from users where name={name}'
61
+
62
+ # Default QMARK style
63
+ print(tsql.render(query))
64
+ # ('select * from users where name = ?', ['billy'])
65
+
66
+ # PostgreSQL native style
67
+ print(tsql.render(query, style=tsql.styles.NUMERIC_DOLLAR))
68
+ # ('select * from users where name = $1', ['billy'])
69
+
70
+ # ESCAPED style (no parameters)
71
+ print(tsql.render(query, style=tsql.styles.ESCAPED))
72
+ # ("select * from users where name = 'billy'", [])
73
+
74
+ # SQL injection prevention
75
+ name = "billy ' and 1=1 --"
76
+ print(tsql.render(query, style=tsql.styles.ESCAPED))
77
+ # ("select * from users where name = 'billy '' and 1=1 --'", [])
78
+
79
+ ```
80
+
81
+ ## Format-spec helpers
82
+
83
+ There are some built-in format spec helpers that can change the way some
84
+ parts of the library work.
85
+
86
+ ### Literal
87
+ One common example is you may want to set the name
88
+ of a column dynamically. By using the `literal` format spec, the value will
89
+ be sanitized against a valid literal and put straight into the sql query since
90
+ you cannot parameterize that part of a query, example:
91
+
92
+ ```python
93
+ query = t'select * from {table:literal} where {col:literal}={val}'
94
+ ```
95
+
96
+ or, a full example:
97
+ ```python
98
+
99
+ # with a like clause
100
+ min_age = 30
101
+ search_column = "name"
102
+ pattern = "O'Brien"
103
+ is_active = True
104
+ tsql.render(t"SELECT * FROM test_users WHERE age >= {min_age} AND {search_column:literal} LIKE '%' || {pattern} || '%' AND active = {is_active}")
105
+ ```
106
+
107
+ ### unsafe
108
+ You may want to do advanced things that may otherwise be considered unsfe.
109
+ This is okay if you can be sure that a user is not providing input. Like maybe
110
+ you care storing a query for some reason.
111
+ As per the name, this can open you up to sql injection and should be used with
112
+ extreme caution.
113
+ You can use the "unsafe" format spec for these
114
+ cases:
115
+ ```python
116
+ dynamic_where = input('type where clause')
117
+ tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
118
+ ```
119
+
120
+ ### as_values
121
+
122
+ The spec `:as_values` formats a dictionary into the format:
123
+ `(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
124
+
125
+ ### as_set
126
+
127
+ The spec `:as_set` formats a dictionary into the format:
128
+ `key1='?', key2='?'` for uses in update statements.
129
+
130
+ ### traditional format_spec
131
+
132
+ All other format specs should be handled as they would in a normal f-string.
133
+
134
+ ## Included helper methods
135
+
136
+ ```python
137
+ # select
138
+ tsql.select('table', 'abc123')
139
+ # SELECT * FROM table WHERE id='abc123'
140
+
141
+ # select with multiple ids and specific columns
142
+ tsql.select('users', ['abc123', 'def456'], columns=['name', 'age'])
143
+ # SELECT name, age FROM users WHERE id in ('abc123', 'def456')
144
+
145
+
146
+ # t_join (joins multiple t-strings together like .join on a str)
147
+ tsql.t_join(t" ", [t"hello", t"there"])
148
+ # t"hello there"
149
+
150
+
151
+ # insert
152
+ table = 'users'
153
+ values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
154
+ tsql.insert(table, values)
155
+ # INSERT INTO users (id, name, email) VALUES ('abc123', 'bob', 'bob@example.com')
156
+
157
+ # update values on a single row
158
+ table = 'users'
159
+ values = {'name': 'joe', 'email': 'joe@example.com'}
160
+ tsql.update(table, values, id='abc123')
161
+ # UPDATE users SET name='joe', email='joe@example.com' WHERE id='abc123'
162
+ ```
163
+
164
+ # Query Builder
165
+
166
+ For a more structured approach to building queries, TSQL includes an optional query builder that provides a fluent interface with type-safe column references.
167
+
168
+ ## Basic Usage
169
+
170
+ ```python
171
+ from tsql.query_builder import table
172
+
173
+ @table('users')
174
+ class Users:
175
+ id: int
176
+ username: str
177
+ email: str
178
+ created_at: str
179
+
180
+ # Decorator returns instance - use directly!
181
+ query = Users.select(Users.id, Users.username)
182
+ sql, params = query.render()
183
+ # SELECT users.id, users.username FROM users
184
+
185
+ # With WHERE clause
186
+ query = Users.select().where(Users.id > 100)
187
+ sql, params = query.render()
188
+ # SELECT * FROM users WHERE users.id > ?
189
+ # params: [100]
190
+
191
+ # Multiple WHERE conditions (ANDed together)
192
+ query = (Users.select(Users.username, Users.email)
193
+ .where(Users.id > 10)
194
+ .where(Users.email != None))
195
+ sql, params = query.render()
196
+ # SELECT users.username, users.email FROM users WHERE users.id > ? AND users.email IS NOT NULL
197
+ ```
198
+
199
+ ## Joins
200
+
201
+ ```python
202
+ @table('posts')
203
+ class Posts:
204
+ id: int
205
+ user_id: int
206
+ title: str
207
+ body: str
208
+
209
+ @table('comments')
210
+ class Comments:
211
+ id: int
212
+ post_id: int
213
+ user_id: int
214
+ content: str
215
+
216
+ # INNER JOIN
217
+ query = (Posts.select(Posts.title, Users.username)
218
+ .join(Users, Posts.user_id == Users.id)
219
+ .where(Posts.id > 100))
220
+ sql, params = query.render()
221
+ # SELECT posts.title, users.username FROM posts INNER JOIN users ON posts.user_id = users.id WHERE posts.id > ?
222
+
223
+ # LEFT JOIN with multiple tables
224
+ query = (Comments.select(Comments.content, Posts.title, Users.username)
225
+ .join(Posts, Comments.post_id == Posts.id)
226
+ .left_join(Users, Comments.user_id == Users.id))
227
+ ```
228
+
229
+ ## Additional Features
230
+
231
+ ```python
232
+ # IN clause
233
+ query = Users.select().where(Users.id.in_([1, 2, 3]))
234
+
235
+ # LIKE clause
236
+ query = Users.select().where(Users.username.like('%john%'))
237
+
238
+ # ORDER BY
239
+ query = Posts.select().order_by(Posts.created_at)
240
+ query = Posts.select().order_by((Posts.id, 'DESC'))
241
+
242
+ # LIMIT
243
+ query = Posts.select().limit(10)
244
+
245
+ # Complex query
246
+ query = (Posts.select(Posts.title, Users.username)
247
+ .join(Users, Posts.user_id == Users.id)
248
+ .where(Posts.id > 100)
249
+ .where(Users.id >= 5)
250
+ .order_by((Posts.id, 'DESC'))
251
+ .limit(20))
252
+ ```
253
+
254
+ ## Advanced Mixed Query (Query Builder + T-Strings)
255
+
256
+ You can combine the query builder's structured approach with raw t-string conditions for complex logic:
257
+
258
+ ```python
259
+ from tsql.query_builder import table
260
+ from sqlalchemy import MetaData, Column, String, Integer
261
+
262
+ metadata = MetaData()
263
+
264
+ @table('users', metadata=metadata)
265
+ class Users:
266
+ id = Column(String, primary_key=True)
267
+ name = Column(String)
268
+ age = Column(Integer)
269
+ email = Column(String)
270
+
271
+ # Start with query builder for the base query
272
+ query = Users.select(Users.id, Users.name, Users.email)
273
+
274
+ # Add simple conditions with query builder
275
+ query = query.where(Users.age > 18)
276
+
277
+ # Add complex logic with t-strings for advanced conditions
278
+ search_term = "john"
279
+ min_age = 25
280
+ name_col = str(Users.name)
281
+ email_col = str(Users.email)
282
+ age_col = str(Users.age)
283
+
284
+ # Build advanced t-string condition with OR logic
285
+ advanced_condition = t"{name_col:literal} LIKE '%' || {search_term} || '%' OR {email_col:literal} LIKE '%' || {search_term} || '%'"
286
+
287
+ # Mix it into the query builder (t-string conditions are automatically wrapped in parentheses)
288
+ query = query.where(advanced_condition)
289
+
290
+ sql, params = query.render()
291
+ # SELECT users.id, users.name, users.email FROM users
292
+ # WHERE users.age > ? AND (users.name LIKE '%' || ? || '%' OR users.email LIKE '%' || ? || '%')
293
+ # params: [18, 'john', 'john']
294
+ ```
295
+
296
+ **Important:** When using t-string conditions with `.where()`, they are automatically wrapped in parentheses to ensure proper operator precedence when combined with other conditions using AND. This prevents issues when your t-string contains OR operators.
297
+
298
+ This approach lets you use the query builder for structure and safety, while dropping down to t-strings when you need custom SQL logic that the query builder doesn't support.
299
+
300
+ ### Schema Support
301
+
302
+ ```python
303
+ @table('users', schema='public')
304
+ class Users:
305
+ id: int
306
+ name: str
307
+ ```
308
+
309
+ ## SQLAlchemy & Alembic Integration
310
+
311
+ The query builder can integrate with SQLAlchemy's metadata system, allowing alembic autogenerate to work while maintaining the clean query builder syntax.
312
+
313
+ First, install with SQLAlchemy support:
314
+ ```bash
315
+ pip install t-sql[sqlalchemy]
316
+ # or
317
+ uv add t-sql --optional sqlalchemy
318
+ ```
319
+
320
+ ### Two Ways to Define Columns
321
+
322
+ **1. Simple type annotations** (no alembic needed):
323
+ ```python
324
+ from tsql.query_builder import table
325
+
326
+ @table('users') # No metadata = query builder only
327
+ class Users:
328
+ id: int
329
+ name: str
330
+ age: int
331
+
332
+ # Decorator returns an instance - no need to instantiate!
333
+ query = Users.select(Users.name).where(Users.age > 18)
334
+ ```
335
+
336
+ **2. SQLAlchemy Column objects** (for alembic integration - **recommended**):
337
+ ```python
338
+ from sqlalchemy import MetaData, Column, String, Integer, ForeignKey, TIMESTAMP
339
+ from sqlalchemy.sql.functions import now
340
+ from tsql.query_builder import table
341
+
342
+ metadata = MetaData()
343
+
344
+ @table('users', metadata=metadata)
345
+ class Users:
346
+ id = Column(String, primary_key=True, default=lambda: gen_id())
347
+ email = Column(String(255), unique=True, nullable=False, index=True)
348
+ name = Column(String(100))
349
+ created_ts = Column(TIMESTAMP(timezone=True), server_default=now(), nullable=False)
350
+
351
+ @table('posts', metadata=metadata)
352
+ class Posts:
353
+ id = Column(String, primary_key=True)
354
+ user_id = Column(String, ForeignKey('users.id', ondelete='CASCADE'), index=True)
355
+ title = Column(String(500))
356
+ ```
357
+
358
+ **You can mix both approaches**:
359
+ ```python
360
+ @table('events', metadata=metadata)
361
+ class Events:
362
+ id = Column(String, primary_key=True, default=lambda: gen_id("e")) # Full SA
363
+ topic: str # Simple - becomes nullable String column
364
+ created_ts = Column(TIMESTAMP(timezone=True), server_default=now())
365
+ ```
366
+
367
+ ### Using the Query Builder
368
+
369
+ ```python
370
+ # For alembic (in your models.py or env.py)
371
+ target_metadata = metadata
372
+
373
+ # For queries - use decorated classes directly (they're already instances!)
374
+ query = (Posts.select(Posts.title, Users.name)
375
+ .join(Users, Posts.user_id == Users.id)
376
+ .where(Users.age > 18))
377
+
378
+ sql, params = query.render()
379
+ ```
380
+
381
+ ### Why Use SQLAlchemy Column?
382
+
383
+ Using `Column(...)` directly gives you:
384
+ - ✅ Full SQLAlchemy feature support (server defaults, computed columns, custom types, etc.)
385
+ - ✅ `ondelete` cascade rules on foreign keys
386
+ - ✅ Custom types like `JSONB`, `TIMESTAMP(timezone=True)`, `TypeDecorator`
387
+ - ✅ Callable defaults: `default=lambda: gen_id()`
388
+ - ✅ Server defaults: `server_default=now()`
389
+ - ✅ Column comments
390
+ - ✅ Everything SQLAlchemy supports
391
+
392
+ ### How It Works
393
+
394
+ When you provide a `metadata` parameter to `@table()`, the decorator:
395
+ 1. Detects SQLAlchemy `Column` objects and uses them directly
396
+ 2. Creates query builder Column descriptors for fluent syntax
397
+ 3. Registers tables to metadata for alembic
398
+
399
+ This means:
400
+ - Alembic autogenerate works perfectly
401
+ - Query builder gives you type-safe queries
402
+ - Single source of truth for your schema
403
+ - SQLAlchemy is optional - query builder works standalone
404
+
405
+ # Note on usage
406
+
407
+ This library should ideally be used inside middleware or library code
408
+ right before making an actual query. It can be used to enforce
409
+ using t-strings and prevent using raw strings.
410
+
411
+ For example:
412
+
413
+ ```
414
+ from string.templatelib import Template
415
+
416
+ import tsql
417
+
418
+ def execute_sql_query(query):
419
+ if not isinstance(query, Template):
420
+ raise TypeError('Cannot make a query without using t-strings')
421
+
422
+
423
+ return sql_engine.execute(*tsql.render(query))
424
+
425
+ ```