t-sql 2.2.1__tar.gz → 3.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.
- {t_sql-2.2.1 → t_sql-3.1.0}/PKG-INFO +115 -25
- {t_sql-2.2.1 → t_sql-3.1.0}/README.md +114 -24
- {t_sql-2.2.1 → t_sql-3.1.0}/pyproject.toml +1 -1
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_helper_functions.py +2 -7
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_mysql_integration.py +2 -2
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_query_builder.py +45 -13
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_sqlite_integration.py +4 -4
- {t_sql-2.2.1 → t_sql-3.1.0}/tsql/__init__.py +8 -4
- {t_sql-2.2.1 → t_sql-3.1.0}/tsql/query_builder.py +54 -6
- {t_sql-2.2.1 → t_sql-3.1.0}/.dockerignore +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/.github/workflows/publish.yml +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/.github/workflows/test.yml +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/.gitignore +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/Dockerfile +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/LICENSE +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/compose.yaml +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/context7.json +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/pytest.ini +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_different_object_types.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_escaped.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_parameter_names.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_styles.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_tsql.py +0 -0
- {t_sql-2.2.1 → t_sql-3.1.0}/tsql/styles.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.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
|
|
@@ -159,8 +159,7 @@ sql, params = query.render()
|
|
|
159
159
|
Quick INSERT queries:
|
|
160
160
|
|
|
161
161
|
```python
|
|
162
|
-
|
|
163
|
-
query = tsql.insert('users', values)
|
|
162
|
+
query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
|
|
164
163
|
sql, params = query.render()
|
|
165
164
|
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
166
165
|
```
|
|
@@ -171,14 +170,9 @@ Quick UPDATE queries:
|
|
|
171
170
|
|
|
172
171
|
```python
|
|
173
172
|
# Update by ID
|
|
174
|
-
query = tsql.update('users',
|
|
173
|
+
query = tsql.update('users', 'abc123', email='new@example.com')
|
|
175
174
|
sql, params = query.render()
|
|
176
175
|
# ('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
176
|
```
|
|
183
177
|
|
|
184
178
|
#### delete
|
|
@@ -271,8 +265,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
|
|
|
271
265
|
query = Users.select().where(Users.username.like('%john%'))
|
|
272
266
|
|
|
273
267
|
# ORDER BY
|
|
274
|
-
query = Posts.select().order_by(Posts.id)
|
|
275
|
-
query = Posts.select().order_by(
|
|
268
|
+
query = Posts.select().order_by(Posts.id) # defaults to ASC
|
|
269
|
+
query = Posts.select().order_by(Posts.id.desc())
|
|
270
|
+
query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
|
|
276
271
|
|
|
277
272
|
# LIMIT and OFFSET
|
|
278
273
|
query = Posts.select().limit(10).offset(20)
|
|
@@ -291,47 +286,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
|
|
|
291
286
|
|
|
292
287
|
```python
|
|
293
288
|
# Basic insert
|
|
294
|
-
|
|
295
|
-
query = Users.insert(values)
|
|
289
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com')
|
|
296
290
|
sql, params = query.render()
|
|
297
291
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
|
|
298
292
|
|
|
299
293
|
# INSERT with RETURNING (Postgres/SQLite)
|
|
300
|
-
query = Users.insert(
|
|
294
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
|
|
301
295
|
sql, params = query.render()
|
|
302
296
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
|
|
303
297
|
|
|
304
298
|
# INSERT IGNORE (MySQL)
|
|
305
|
-
query = Users.insert(
|
|
299
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
|
|
306
300
|
sql, params = query.render()
|
|
307
301
|
# ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
|
|
308
302
|
|
|
309
303
|
# ON CONFLICT DO NOTHING (Postgres/SQLite)
|
|
310
|
-
query = Users.insert(
|
|
304
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
|
|
311
305
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
|
|
312
306
|
|
|
313
307
|
# ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
|
|
314
|
-
query = Users.insert(
|
|
308
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
|
|
315
309
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
|
|
316
310
|
|
|
317
311
|
# ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
|
|
318
|
-
query = Users.insert(
|
|
312
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
|
|
319
313
|
# ('INSERT INTO users (...) VALUES (...)
|
|
320
314
|
# ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
|
|
321
315
|
|
|
322
316
|
# ON CONFLICT with custom update
|
|
323
|
-
query = Users.insert(
|
|
317
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
|
|
324
318
|
conflict_on='id',
|
|
325
319
|
update={'username': 'updated_name'}
|
|
326
320
|
)
|
|
327
321
|
|
|
328
322
|
# ON DUPLICATE KEY UPDATE (MySQL)
|
|
329
|
-
query = Users.insert(
|
|
323
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
|
|
330
324
|
# ('INSERT INTO users (...) VALUES (...)
|
|
331
325
|
# ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
|
|
332
326
|
|
|
333
327
|
# Chain multiple modifiers
|
|
334
|
-
query = (Users.insert(
|
|
328
|
+
query = (Users.insert(id='abc123', username='john', email='john@example.com')
|
|
335
329
|
.on_conflict_update(conflict_on='id')
|
|
336
330
|
.returning('id', 'username'))
|
|
337
331
|
```
|
|
@@ -340,22 +334,22 @@ query = (Users.insert(values)
|
|
|
340
334
|
|
|
341
335
|
```python
|
|
342
336
|
# Basic update (no WHERE = updates all rows!)
|
|
343
|
-
query = Users.update(
|
|
337
|
+
query = Users.update(email='newemail@example.com')
|
|
344
338
|
sql, params = query.render()
|
|
345
339
|
# ('UPDATE users SET email = ?', ['newemail@example.com'])
|
|
346
340
|
|
|
347
341
|
# UPDATE with WHERE
|
|
348
|
-
query = Users.update(
|
|
342
|
+
query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
|
|
349
343
|
sql, params = query.render()
|
|
350
344
|
# ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
|
|
351
345
|
|
|
352
346
|
# Multiple WHERE conditions
|
|
353
|
-
query = (Users.update(
|
|
347
|
+
query = (Users.update(email='newemail@example.com')
|
|
354
348
|
.where(Users.id == 'abc123')
|
|
355
349
|
.where(Users.age > 18))
|
|
356
350
|
|
|
357
351
|
# With RETURNING (Postgres/SQLite)
|
|
358
|
-
query = (Users.update(
|
|
352
|
+
query = (Users.update(email='new@example.com')
|
|
359
353
|
.where(Users.id == 'abc123')
|
|
360
354
|
.returning())
|
|
361
355
|
# ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
|
|
@@ -559,3 +553,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
|
|
|
559
553
|
```
|
|
560
554
|
|
|
561
555
|
The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
|
|
556
|
+
|
|
557
|
+
# Security Considerations
|
|
558
|
+
|
|
559
|
+
## Overview
|
|
560
|
+
|
|
561
|
+
SQL injection is one of the most critical web application security risks (OWASP Top 10). This library is designed from the ground up to prevent SQL injection attacks through multiple layers of protection. However, understanding how these protections work—and where they can be bypassed—is essential for secure usage.
|
|
562
|
+
|
|
563
|
+
## How t-sql Prevents SQL Injection
|
|
564
|
+
|
|
565
|
+
### 1. Automatic Parameterization (Primary Defense)
|
|
566
|
+
|
|
567
|
+
By default, all interpolated values in t-strings are converted to parameterized queries:
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
# User input (potentially malicious)
|
|
571
|
+
user_input = "admin' OR 1=1 --"
|
|
572
|
+
|
|
573
|
+
# t-sql automatically parameterizes this
|
|
574
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
|
|
575
|
+
# Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The malicious SQL becomes **literal string data** in the parameter, not executable SQL code. The database treats it as a string value to match, not as SQL syntax.
|
|
579
|
+
|
|
580
|
+
**Attack vectors prevented:**
|
|
581
|
+
- Classic injection: `' OR 1=1 --`
|
|
582
|
+
- Union-based: `' UNION SELECT * FROM secrets --`
|
|
583
|
+
- Stacked queries: `'; DROP TABLE users; --`
|
|
584
|
+
- Boolean-based blind: `' AND SLEEP(5) --`
|
|
585
|
+
- Authentication bypass: `admin'--`
|
|
586
|
+
|
|
587
|
+
### 2. Literal Validation (Identifier Safety)
|
|
588
|
+
|
|
589
|
+
For table and column names that cannot be parameterized, use `:literal`:
|
|
590
|
+
|
|
591
|
+
```python
|
|
592
|
+
table = "users"
|
|
593
|
+
col = "name"
|
|
594
|
+
sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
**Validation rules:**
|
|
598
|
+
- Must be valid Python identifiers (`str.isidentifier()`)
|
|
599
|
+
- Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
|
|
600
|
+
- Rejects anything with spaces, quotes, or special characters
|
|
601
|
+
|
|
602
|
+
```python
|
|
603
|
+
# These are REJECTED with ValueError:
|
|
604
|
+
bad_table = "users; DROP TABLE secrets" # Contains semicolon
|
|
605
|
+
bad_col = "name' OR 1=1" # Contains quote
|
|
606
|
+
bad_schema = "schema.table.column.extra" # Too many parts
|
|
607
|
+
|
|
608
|
+
tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Attack vectors prevented:**
|
|
612
|
+
- Table/column injection: `users; DROP TABLE secrets`
|
|
613
|
+
- Second-order injection via identifiers
|
|
614
|
+
- Schema manipulation
|
|
615
|
+
|
|
616
|
+
### 3. Escape-based Protection (ESCAPED Style)
|
|
617
|
+
|
|
618
|
+
For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
|
|
619
|
+
|
|
620
|
+
```python
|
|
621
|
+
malicious = "'; DROP TABLE users; --"
|
|
622
|
+
sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
|
|
623
|
+
# Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
|
|
624
|
+
# (single quotes are doubled, making it literal data)
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
|
|
628
|
+
|
|
629
|
+
## Danger Zones: Where You Can Still Get Hurt
|
|
630
|
+
|
|
631
|
+
### The :unsafe Format Spec
|
|
632
|
+
|
|
633
|
+
The `:unsafe` format spec **bypasses all safety mechanisms**:
|
|
634
|
+
|
|
635
|
+
```python
|
|
636
|
+
# DANGEROUS - no validation or parameterization!
|
|
637
|
+
dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
|
|
638
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
**When :unsafe is acceptable:**
|
|
642
|
+
- Hard-coded SQL fragments in your own code
|
|
643
|
+
- SQL generated by trusted, validated builder logic
|
|
644
|
+
- Dynamic ORDER BY clauses (after validation)
|
|
645
|
+
|
|
646
|
+
**When :unsafe is DANGEROUS:**
|
|
647
|
+
- **Never** with user input (even "validated" input)
|
|
648
|
+
- Dynamic WHERE clauses from external sources
|
|
649
|
+
- Any data from forms, APIs, or databases
|
|
650
|
+
|
|
651
|
+
**Recommendation:** Treat `:unsafe` like `eval()` in your code reviews. Every usage should be scrutinized and documented.
|
|
@@ -149,8 +149,7 @@ sql, params = query.render()
|
|
|
149
149
|
Quick INSERT queries:
|
|
150
150
|
|
|
151
151
|
```python
|
|
152
|
-
|
|
153
|
-
query = tsql.insert('users', values)
|
|
152
|
+
query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
|
|
154
153
|
sql, params = query.render()
|
|
155
154
|
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
156
155
|
```
|
|
@@ -161,14 +160,9 @@ Quick UPDATE queries:
|
|
|
161
160
|
|
|
162
161
|
```python
|
|
163
162
|
# Update by ID
|
|
164
|
-
query = tsql.update('users',
|
|
163
|
+
query = tsql.update('users', 'abc123', email='new@example.com')
|
|
165
164
|
sql, params = query.render()
|
|
166
165
|
# ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
|
|
167
|
-
|
|
168
|
-
# Update with custom WHERE
|
|
169
|
-
query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
|
|
170
|
-
sql, params = query.render()
|
|
171
|
-
# ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
|
|
172
166
|
```
|
|
173
167
|
|
|
174
168
|
#### delete
|
|
@@ -261,8 +255,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
|
|
|
261
255
|
query = Users.select().where(Users.username.like('%john%'))
|
|
262
256
|
|
|
263
257
|
# ORDER BY
|
|
264
|
-
query = Posts.select().order_by(Posts.id)
|
|
265
|
-
query = Posts.select().order_by(
|
|
258
|
+
query = Posts.select().order_by(Posts.id) # defaults to ASC
|
|
259
|
+
query = Posts.select().order_by(Posts.id.desc())
|
|
260
|
+
query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
|
|
266
261
|
|
|
267
262
|
# LIMIT and OFFSET
|
|
268
263
|
query = Posts.select().limit(10).offset(20)
|
|
@@ -281,47 +276,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
|
|
|
281
276
|
|
|
282
277
|
```python
|
|
283
278
|
# Basic insert
|
|
284
|
-
|
|
285
|
-
query = Users.insert(values)
|
|
279
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com')
|
|
286
280
|
sql, params = query.render()
|
|
287
281
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
|
|
288
282
|
|
|
289
283
|
# INSERT with RETURNING (Postgres/SQLite)
|
|
290
|
-
query = Users.insert(
|
|
284
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
|
|
291
285
|
sql, params = query.render()
|
|
292
286
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
|
|
293
287
|
|
|
294
288
|
# INSERT IGNORE (MySQL)
|
|
295
|
-
query = Users.insert(
|
|
289
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
|
|
296
290
|
sql, params = query.render()
|
|
297
291
|
# ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
|
|
298
292
|
|
|
299
293
|
# ON CONFLICT DO NOTHING (Postgres/SQLite)
|
|
300
|
-
query = Users.insert(
|
|
294
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
|
|
301
295
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
|
|
302
296
|
|
|
303
297
|
# ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
|
|
304
|
-
query = Users.insert(
|
|
298
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
|
|
305
299
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
|
|
306
300
|
|
|
307
301
|
# ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
|
|
308
|
-
query = Users.insert(
|
|
302
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
|
|
309
303
|
# ('INSERT INTO users (...) VALUES (...)
|
|
310
304
|
# ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
|
|
311
305
|
|
|
312
306
|
# ON CONFLICT with custom update
|
|
313
|
-
query = Users.insert(
|
|
307
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
|
|
314
308
|
conflict_on='id',
|
|
315
309
|
update={'username': 'updated_name'}
|
|
316
310
|
)
|
|
317
311
|
|
|
318
312
|
# ON DUPLICATE KEY UPDATE (MySQL)
|
|
319
|
-
query = Users.insert(
|
|
313
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
|
|
320
314
|
# ('INSERT INTO users (...) VALUES (...)
|
|
321
315
|
# ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
|
|
322
316
|
|
|
323
317
|
# Chain multiple modifiers
|
|
324
|
-
query = (Users.insert(
|
|
318
|
+
query = (Users.insert(id='abc123', username='john', email='john@example.com')
|
|
325
319
|
.on_conflict_update(conflict_on='id')
|
|
326
320
|
.returning('id', 'username'))
|
|
327
321
|
```
|
|
@@ -330,22 +324,22 @@ query = (Users.insert(values)
|
|
|
330
324
|
|
|
331
325
|
```python
|
|
332
326
|
# Basic update (no WHERE = updates all rows!)
|
|
333
|
-
query = Users.update(
|
|
327
|
+
query = Users.update(email='newemail@example.com')
|
|
334
328
|
sql, params = query.render()
|
|
335
329
|
# ('UPDATE users SET email = ?', ['newemail@example.com'])
|
|
336
330
|
|
|
337
331
|
# UPDATE with WHERE
|
|
338
|
-
query = Users.update(
|
|
332
|
+
query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
|
|
339
333
|
sql, params = query.render()
|
|
340
334
|
# ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
|
|
341
335
|
|
|
342
336
|
# Multiple WHERE conditions
|
|
343
|
-
query = (Users.update(
|
|
337
|
+
query = (Users.update(email='newemail@example.com')
|
|
344
338
|
.where(Users.id == 'abc123')
|
|
345
339
|
.where(Users.age > 18))
|
|
346
340
|
|
|
347
341
|
# With RETURNING (Postgres/SQLite)
|
|
348
|
-
query = (Users.update(
|
|
342
|
+
query = (Users.update(email='new@example.com')
|
|
349
343
|
.where(Users.id == 'abc123')
|
|
350
344
|
.returning())
|
|
351
345
|
# ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
|
|
@@ -549,3 +543,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
|
|
|
549
543
|
```
|
|
550
544
|
|
|
551
545
|
The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
|
|
546
|
+
|
|
547
|
+
# Security Considerations
|
|
548
|
+
|
|
549
|
+
## Overview
|
|
550
|
+
|
|
551
|
+
SQL injection is one of the most critical web application security risks (OWASP Top 10). This library is designed from the ground up to prevent SQL injection attacks through multiple layers of protection. However, understanding how these protections work—and where they can be bypassed—is essential for secure usage.
|
|
552
|
+
|
|
553
|
+
## How t-sql Prevents SQL Injection
|
|
554
|
+
|
|
555
|
+
### 1. Automatic Parameterization (Primary Defense)
|
|
556
|
+
|
|
557
|
+
By default, all interpolated values in t-strings are converted to parameterized queries:
|
|
558
|
+
|
|
559
|
+
```python
|
|
560
|
+
# User input (potentially malicious)
|
|
561
|
+
user_input = "admin' OR 1=1 --"
|
|
562
|
+
|
|
563
|
+
# t-sql automatically parameterizes this
|
|
564
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
|
|
565
|
+
# Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
The malicious SQL becomes **literal string data** in the parameter, not executable SQL code. The database treats it as a string value to match, not as SQL syntax.
|
|
569
|
+
|
|
570
|
+
**Attack vectors prevented:**
|
|
571
|
+
- Classic injection: `' OR 1=1 --`
|
|
572
|
+
- Union-based: `' UNION SELECT * FROM secrets --`
|
|
573
|
+
- Stacked queries: `'; DROP TABLE users; --`
|
|
574
|
+
- Boolean-based blind: `' AND SLEEP(5) --`
|
|
575
|
+
- Authentication bypass: `admin'--`
|
|
576
|
+
|
|
577
|
+
### 2. Literal Validation (Identifier Safety)
|
|
578
|
+
|
|
579
|
+
For table and column names that cannot be parameterized, use `:literal`:
|
|
580
|
+
|
|
581
|
+
```python
|
|
582
|
+
table = "users"
|
|
583
|
+
col = "name"
|
|
584
|
+
sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**Validation rules:**
|
|
588
|
+
- Must be valid Python identifiers (`str.isidentifier()`)
|
|
589
|
+
- Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
|
|
590
|
+
- Rejects anything with spaces, quotes, or special characters
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
# These are REJECTED with ValueError:
|
|
594
|
+
bad_table = "users; DROP TABLE secrets" # Contains semicolon
|
|
595
|
+
bad_col = "name' OR 1=1" # Contains quote
|
|
596
|
+
bad_schema = "schema.table.column.extra" # Too many parts
|
|
597
|
+
|
|
598
|
+
tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**Attack vectors prevented:**
|
|
602
|
+
- Table/column injection: `users; DROP TABLE secrets`
|
|
603
|
+
- Second-order injection via identifiers
|
|
604
|
+
- Schema manipulation
|
|
605
|
+
|
|
606
|
+
### 3. Escape-based Protection (ESCAPED Style)
|
|
607
|
+
|
|
608
|
+
For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
|
|
609
|
+
|
|
610
|
+
```python
|
|
611
|
+
malicious = "'; DROP TABLE users; --"
|
|
612
|
+
sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
|
|
613
|
+
# Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
|
|
614
|
+
# (single quotes are doubled, making it literal data)
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
**Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
|
|
618
|
+
|
|
619
|
+
## Danger Zones: Where You Can Still Get Hurt
|
|
620
|
+
|
|
621
|
+
### The :unsafe Format Spec
|
|
622
|
+
|
|
623
|
+
The `:unsafe` format spec **bypasses all safety mechanisms**:
|
|
624
|
+
|
|
625
|
+
```python
|
|
626
|
+
# DANGEROUS - no validation or parameterization!
|
|
627
|
+
dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
|
|
628
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
**When :unsafe is acceptable:**
|
|
632
|
+
- Hard-coded SQL fragments in your own code
|
|
633
|
+
- SQL generated by trusted, validated builder logic
|
|
634
|
+
- Dynamic ORDER BY clauses (after validation)
|
|
635
|
+
|
|
636
|
+
**When :unsafe is DANGEROUS:**
|
|
637
|
+
- **Never** with user input (even "validated" input)
|
|
638
|
+
- Dynamic WHERE clauses from external sources
|
|
639
|
+
- Any data from forms, APIs, or databases
|
|
640
|
+
|
|
641
|
+
**Recommendation:** Treat `:unsafe` like `eval()` in your code reviews. Every usage should be scrutinized and documented.
|
|
@@ -59,14 +59,9 @@ def test_insert():
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def test_update():
|
|
62
|
-
|
|
63
|
-
'name': 'Bob Updated',
|
|
64
|
-
'age': 35
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
query = tsql.update('users', values, 123)
|
|
62
|
+
query = tsql.update('users', 123, name='Bob Updated', age=35)
|
|
68
63
|
result = tsql.render(query)
|
|
69
|
-
|
|
64
|
+
|
|
70
65
|
assert "UPDATE users SET" in result[0]
|
|
71
66
|
assert "name = ?" in result[0]
|
|
72
67
|
assert "age = ?" in result[0]
|
|
@@ -170,7 +170,7 @@ async def test_update_without_returning(conn):
|
|
|
170
170
|
age: int
|
|
171
171
|
|
|
172
172
|
# Update without RETURNING
|
|
173
|
-
query = TestUsers.update(
|
|
173
|
+
query = TestUsers.update(age=31).where(TestUsers.name == 'Alice')
|
|
174
174
|
sql, params = query.render(style=tsql.styles.FORMAT)
|
|
175
175
|
|
|
176
176
|
# Should NOT have RETURNING clause
|
|
@@ -262,7 +262,7 @@ async def test_helper_functions(conn):
|
|
|
262
262
|
assert row[1] == 25
|
|
263
263
|
|
|
264
264
|
# Test update
|
|
265
|
-
query = tsql.update('test_users',
|
|
265
|
+
query = tsql.update('test_users', 1, age=26)
|
|
266
266
|
sql, params = query.render(style=tsql.styles.FORMAT)
|
|
267
267
|
|
|
268
268
|
await cursor.execute(sql, params)
|
|
@@ -213,7 +213,7 @@ def test_order_by():
|
|
|
213
213
|
|
|
214
214
|
def test_order_by_desc():
|
|
215
215
|
"""Test ORDER BY with DESC"""
|
|
216
|
-
query = Users.select().order_by(
|
|
216
|
+
query = Users.select().order_by(Users.id.desc())
|
|
217
217
|
sql, params = query.render()
|
|
218
218
|
|
|
219
219
|
assert 'ORDER BY users.id DESC' in sql
|
|
@@ -221,7 +221,39 @@ def test_order_by_desc():
|
|
|
221
221
|
|
|
222
222
|
def test_order_by_multiple():
|
|
223
223
|
"""Test ORDER BY with multiple columns"""
|
|
224
|
-
query = Users.select().order_by(Users.username,
|
|
224
|
+
query = Users.select().order_by(Users.username, Users.id.desc())
|
|
225
|
+
sql, params = query.render()
|
|
226
|
+
|
|
227
|
+
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_order_by_asc_method():
|
|
231
|
+
"""Test ORDER BY with .asc() method"""
|
|
232
|
+
query = Users.select().order_by(Users.username.asc())
|
|
233
|
+
sql, params = query.render()
|
|
234
|
+
|
|
235
|
+
assert 'ORDER BY users.username ASC' in sql
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_order_by_desc_method():
|
|
239
|
+
"""Test ORDER BY with .desc() method"""
|
|
240
|
+
query = Users.select().order_by(Users.id.desc())
|
|
241
|
+
sql, params = query.render()
|
|
242
|
+
|
|
243
|
+
assert 'ORDER BY users.id DESC' in sql
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_order_by_mixed_methods():
|
|
247
|
+
"""Test ORDER BY with mixed .asc() and .desc() methods"""
|
|
248
|
+
query = Users.select().order_by(Users.username.asc(), Users.id.desc())
|
|
249
|
+
sql, params = query.render()
|
|
250
|
+
|
|
251
|
+
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_order_by_mixed_syntax():
|
|
255
|
+
"""Test ORDER BY with mixed method calls and bare columns"""
|
|
256
|
+
query = Users.select().order_by(Users.username, Users.id.desc())
|
|
225
257
|
sql, params = query.render()
|
|
226
258
|
|
|
227
259
|
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
@@ -242,7 +274,7 @@ def test_complex_query():
|
|
|
242
274
|
.join(Users, Posts.user_id == Users.id)
|
|
243
275
|
.where(Posts.id > 100)
|
|
244
276
|
.where(Users.id >= 5)
|
|
245
|
-
.order_by(
|
|
277
|
+
.order_by(Posts.id.desc())
|
|
246
278
|
.limit(20))
|
|
247
279
|
sql, params = query.render()
|
|
248
280
|
|
|
@@ -351,7 +383,7 @@ def test_schema_in_update():
|
|
|
351
383
|
id: Column
|
|
352
384
|
name: Column
|
|
353
385
|
|
|
354
|
-
query = Accounts.update(
|
|
386
|
+
query = Accounts.update(name='Updated Account').where(Accounts.id == '1')
|
|
355
387
|
sql, params = query.render()
|
|
356
388
|
|
|
357
389
|
assert 'UPDATE other.accounts SET' in sql
|
|
@@ -480,7 +512,7 @@ def test_complex_aggregation_query():
|
|
|
480
512
|
.where(Posts.id > 100)
|
|
481
513
|
.group_by(Posts.user_id)
|
|
482
514
|
.having(Posts.id > 5)
|
|
483
|
-
.order_by(
|
|
515
|
+
.order_by(Posts.user_id.desc())
|
|
484
516
|
.limit(10)
|
|
485
517
|
.offset(5))
|
|
486
518
|
sql, params = query.render()
|
|
@@ -601,7 +633,7 @@ def test_table_insert_chained_with_returning():
|
|
|
601
633
|
|
|
602
634
|
def test_table_update_with_where():
|
|
603
635
|
"""Test table.update() with WHERE clause"""
|
|
604
|
-
builder = Users.update(
|
|
636
|
+
builder = Users.update(username='bob_updated').where(Users.id == 5)
|
|
605
637
|
assert isinstance(builder, UpdateBuilder)
|
|
606
638
|
|
|
607
639
|
sql, params = builder.render()
|
|
@@ -615,7 +647,7 @@ def test_table_update_with_where():
|
|
|
615
647
|
|
|
616
648
|
def test_table_update_multiple_conditions():
|
|
617
649
|
"""Test table.update() with multiple WHERE conditions"""
|
|
618
|
-
builder = (Users.update(
|
|
650
|
+
builder = (Users.update(username='bob_updated', email='new@example.com')
|
|
619
651
|
.where(Users.id > 10)
|
|
620
652
|
.where(Users.created_at == None))
|
|
621
653
|
|
|
@@ -630,7 +662,7 @@ def test_table_update_multiple_conditions():
|
|
|
630
662
|
|
|
631
663
|
def test_table_update_with_returning():
|
|
632
664
|
"""Test table.update() with RETURNING"""
|
|
633
|
-
builder = Users.update(
|
|
665
|
+
builder = Users.update(username='bob_updated').where(Users.id == 5).returning()
|
|
634
666
|
sql, params = builder.render()
|
|
635
667
|
|
|
636
668
|
assert 'UPDATE users SET' in sql
|
|
@@ -681,7 +713,7 @@ def test_table_delete_with_returning():
|
|
|
681
713
|
def test_update_with_t_string_where():
|
|
682
714
|
"""Test UpdateBuilder with raw t-string WHERE clause"""
|
|
683
715
|
min_age = 18
|
|
684
|
-
builder = Users.update(
|
|
716
|
+
builder = Users.update(username='adult').where(t"age >= {min_age}")
|
|
685
717
|
|
|
686
718
|
sql, params = builder.render()
|
|
687
719
|
|
|
@@ -1062,7 +1094,7 @@ def test_update_with_onupdate_default():
|
|
|
1062
1094
|
title = SAColumn(String)
|
|
1063
1095
|
updated_at = SAColumn(String, onupdate=lambda: 'updated_timestamp')
|
|
1064
1096
|
|
|
1065
|
-
query = Articles.update(
|
|
1097
|
+
query = Articles.update(title='Updated Title').where(Articles.id == '123')
|
|
1066
1098
|
sql, params = query.render()
|
|
1067
1099
|
|
|
1068
1100
|
assert 'UPDATE articles SET' in sql
|
|
@@ -1082,7 +1114,7 @@ def test_update_overrides_onupdate():
|
|
|
1082
1114
|
title = SAColumn(String)
|
|
1083
1115
|
updated_at = SAColumn(String, onupdate=lambda: 'auto_timestamp')
|
|
1084
1116
|
|
|
1085
|
-
query = Articles.update(
|
|
1117
|
+
query = Articles.update(title='Updated', updated_at='manual_timestamp').where(Articles.id == '123')
|
|
1086
1118
|
sql, params = query.render()
|
|
1087
1119
|
|
|
1088
1120
|
assert 'UPDATE articles SET' in sql
|
|
@@ -1140,14 +1172,14 @@ def test_returning_cols_validation_update():
|
|
|
1140
1172
|
import pytest
|
|
1141
1173
|
|
|
1142
1174
|
# Test with malicious returning column
|
|
1143
|
-
query = Users.update(
|
|
1175
|
+
query = Users.update(username='hacked')
|
|
1144
1176
|
builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
|
|
1145
1177
|
|
|
1146
1178
|
with pytest.raises(ValueError, match="Invalid RETURNING column name"):
|
|
1147
1179
|
builder.render()
|
|
1148
1180
|
|
|
1149
1181
|
# Test with valid returning columns (should work)
|
|
1150
|
-
query2 = Users.update(
|
|
1182
|
+
query2 = Users.update(username='updated')
|
|
1151
1183
|
builder2 = query2.where(Users.id == 5).returning("id", "username")
|
|
1152
1184
|
sql, params = builder2.render()
|
|
1153
1185
|
|
|
@@ -136,7 +136,7 @@ async def test_update_with_returning(conn):
|
|
|
136
136
|
age: int
|
|
137
137
|
|
|
138
138
|
# Update with RETURNING
|
|
139
|
-
query = TestUsers.update(
|
|
139
|
+
query = TestUsers.update(age=31).where(TestUsers.name == 'Alice').returning()
|
|
140
140
|
sql, params = query.render()
|
|
141
141
|
|
|
142
142
|
assert 'RETURNING *' in sql
|
|
@@ -196,7 +196,7 @@ async def test_update_without_returning(conn):
|
|
|
196
196
|
age: int
|
|
197
197
|
|
|
198
198
|
# Update without RETURNING
|
|
199
|
-
query = TestUsers.update(
|
|
199
|
+
query = TestUsers.update(age=31).where(TestUsers.name == 'Alice')
|
|
200
200
|
sql, params = query.render()
|
|
201
201
|
|
|
202
202
|
# Should NOT have RETURNING clause
|
|
@@ -278,7 +278,7 @@ async def test_helper_functions(conn):
|
|
|
278
278
|
assert row[1] == 25
|
|
279
279
|
|
|
280
280
|
# Test update
|
|
281
|
-
query = tsql.update('test_users',
|
|
281
|
+
query = tsql.update('test_users', 1, age=26)
|
|
282
282
|
sql, params = query.render()
|
|
283
283
|
|
|
284
284
|
await conn.execute(sql, params)
|
|
@@ -331,7 +331,7 @@ async def test_query_builder_select(conn):
|
|
|
331
331
|
assert rows[0][0] == 'Alice'
|
|
332
332
|
|
|
333
333
|
# Test ORDER BY
|
|
334
|
-
query = TestUsers.select().order_by(
|
|
334
|
+
query = TestUsers.select().order_by(TestUsers.age.desc())
|
|
335
335
|
sql, params = query.render()
|
|
336
336
|
|
|
337
337
|
cursor = await conn.execute(sql, params)
|
|
@@ -326,19 +326,23 @@ def insert(table: str, **values: Any) -> TSQL:
|
|
|
326
326
|
return TSQL(t"INSERT INTO {table:literal} {values:as_values}")
|
|
327
327
|
|
|
328
328
|
|
|
329
|
-
def update(table: str,
|
|
329
|
+
def update(table: str, id: str | int, **values: Any) -> TSQL:
|
|
330
330
|
"""Helper function to build UPDATE queries for a single row
|
|
331
331
|
|
|
332
332
|
Args:
|
|
333
333
|
table: Table name
|
|
334
|
-
values: Dictionary of column names and values to update
|
|
335
334
|
id: ID value to update
|
|
335
|
+
**values: Column names and values to update as keyword arguments
|
|
336
336
|
|
|
337
337
|
Returns:
|
|
338
338
|
TSQL object representing the UPDATE query
|
|
339
|
+
|
|
340
|
+
Example:
|
|
341
|
+
update('users', 123, name='Bob', age=35)
|
|
342
|
+
Or with dict unpacking: update('users', 123, **my_dict)
|
|
339
343
|
"""
|
|
340
|
-
if not
|
|
341
|
-
raise ValueError("
|
|
344
|
+
if not values:
|
|
345
|
+
raise ValueError("update requires at least one column value")
|
|
342
346
|
|
|
343
347
|
return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id}")
|
|
344
348
|
|
|
@@ -16,6 +16,17 @@ except ImportError:
|
|
|
16
16
|
SAColumnType = None
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
class OrderByClause:
|
|
20
|
+
"""Represents a column with an ORDER BY direction (ASC/DESC)"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, column: 'Column', direction: str):
|
|
23
|
+
self.column = column
|
|
24
|
+
self.direction = direction.upper()
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"OrderByClause({self.column!r}, {self.direction!r})"
|
|
28
|
+
|
|
29
|
+
|
|
19
30
|
class Column:
|
|
20
31
|
"""Represents a bound column (table + column name) for building queries"""
|
|
21
32
|
|
|
@@ -140,6 +151,28 @@ class Column:
|
|
|
140
151
|
"""Create an IS NOT NULL condition"""
|
|
141
152
|
return Condition(self, 'IS NOT', None)
|
|
142
153
|
|
|
154
|
+
def asc(self) -> OrderByClause:
|
|
155
|
+
"""Create an ascending ORDER BY clause
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
OrderByClause for use in order_by()
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
Users.select().order_by(Users.username.asc())
|
|
162
|
+
"""
|
|
163
|
+
return OrderByClause(self, 'ASC')
|
|
164
|
+
|
|
165
|
+
def desc(self) -> OrderByClause:
|
|
166
|
+
"""Create a descending ORDER BY clause
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
OrderByClause for use in order_by()
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
Users.select().order_by(Users.created_at.desc())
|
|
173
|
+
"""
|
|
174
|
+
return OrderByClause(self, 'DESC')
|
|
175
|
+
|
|
143
176
|
|
|
144
177
|
class Table:
|
|
145
178
|
"""Base class for all table definitions. Provides query builder methods.
|
|
@@ -298,14 +331,18 @@ class Table:
|
|
|
298
331
|
return InsertBuilder(cls, values)
|
|
299
332
|
|
|
300
333
|
@classmethod
|
|
301
|
-
def update(cls, values:
|
|
334
|
+
def update(cls, **values: Any) -> 'UpdateBuilder':
|
|
302
335
|
"""Start building an UPDATE query
|
|
303
336
|
|
|
304
337
|
Args:
|
|
305
|
-
values:
|
|
338
|
+
**values: Column names and values to update as keyword arguments
|
|
306
339
|
|
|
307
340
|
Returns:
|
|
308
341
|
UpdateBuilder for adding WHERE conditions
|
|
342
|
+
|
|
343
|
+
Example:
|
|
344
|
+
Users.update(username='bob', email='bob@example.com')
|
|
345
|
+
Or with dict unpacking: Users.update(**my_dict)
|
|
309
346
|
"""
|
|
310
347
|
return UpdateBuilder(cls, values)
|
|
311
348
|
|
|
@@ -834,11 +871,22 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
834
871
|
"""Add a RIGHT JOIN clause"""
|
|
835
872
|
return self.join(table, on, 'RIGHT')
|
|
836
873
|
|
|
837
|
-
def order_by(self, *columns: Union[Column,
|
|
838
|
-
"""Add ORDER BY clause
|
|
874
|
+
def order_by(self, *columns: Union[Column, OrderByClause]) -> 'SelectQueryBuilder':
|
|
875
|
+
"""Add ORDER BY clause
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
columns: Column objects or OrderByClause objects (from .asc()/.desc())
|
|
879
|
+
|
|
880
|
+
Examples:
|
|
881
|
+
# Using .asc() and .desc() methods
|
|
882
|
+
Users.select().order_by(Users.username.asc(), Users.id.desc())
|
|
883
|
+
|
|
884
|
+
# Bare column defaults to ASC
|
|
885
|
+
Users.select().order_by(Users.username)
|
|
886
|
+
"""
|
|
839
887
|
for col in columns:
|
|
840
|
-
if isinstance(col,
|
|
841
|
-
self._order_by_columns.append(col)
|
|
888
|
+
if isinstance(col, OrderByClause):
|
|
889
|
+
self._order_by_columns.append((col.column, col.direction))
|
|
842
890
|
else:
|
|
843
891
|
self._order_by_columns.append((col, 'ASC'))
|
|
844
892
|
return self
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|