t-sql 1.1.0__tar.gz → 1.2.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.
- {t_sql-1.1.0 → t_sql-1.2.1}/.github/workflows/test.yml +17 -0
- t_sql-1.2.1/.idea/.gitignore +8 -0
- t_sql-1.2.1/.idea/inspectionProfiles/Project_Default.xml +24 -0
- t_sql-1.2.1/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- t_sql-1.2.1/.idea/misc.xml +11 -0
- t_sql-1.2.1/.idea/tsql.iml +10 -0
- t_sql-1.2.1/.idea/vcs.xml +6 -0
- t_sql-1.2.1/.idea/workspace.xml +118 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/PKG-INFO +68 -2
- {t_sql-1.1.0 → t_sql-1.2.1}/README.md +67 -1
- t_sql-1.2.1/context7.json +26 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/pyproject.toml +1 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_asyncpg_integration.py +94 -2
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_helper_functions.py +76 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injection_protection_validation.py +2 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injections_for_escaped.py +2 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_query_builder.py +143 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/tsql/__init__.py +69 -2
- {t_sql-1.1.0 → t_sql-1.2.1}/tsql/query_builder.py +152 -1
- {t_sql-1.1.0 → t_sql-1.2.1}/.dockerignore +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/.github/workflows/publish.yml +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/.gitignore +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/Dockerfile +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/LICENSE +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/compose.yaml +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/pytest.ini +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_different_object_types.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_escaped.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_styles.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_tsql.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.1}/tsql/styles.py +0 -0
|
@@ -13,6 +13,21 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
python-version: ["3.14"]
|
|
15
15
|
|
|
16
|
+
services:
|
|
17
|
+
postgres:
|
|
18
|
+
image: postgres:18
|
|
19
|
+
env:
|
|
20
|
+
POSTGRES_PASSWORD: password
|
|
21
|
+
POSTGRES_USER: postgres
|
|
22
|
+
POSTGRES_DB: postgres
|
|
23
|
+
ports:
|
|
24
|
+
- 5432:5432
|
|
25
|
+
options: >-
|
|
26
|
+
--health-cmd pg_isready
|
|
27
|
+
--health-interval 10s
|
|
28
|
+
--health-timeout 5s
|
|
29
|
+
--health-retries 5
|
|
30
|
+
|
|
16
31
|
steps:
|
|
17
32
|
- uses: actions/checkout@v4
|
|
18
33
|
|
|
@@ -28,4 +43,6 @@ jobs:
|
|
|
28
43
|
run: uv sync
|
|
29
44
|
|
|
30
45
|
- name: Run tests
|
|
46
|
+
env:
|
|
47
|
+
DATABASE_URL: postgresql://postgres:password@localhost:5432/postgres
|
|
31
48
|
run: uv run pytest -v
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<component name="InspectionProjectProfileManager">
|
|
2
|
+
<profile version="1.0">
|
|
3
|
+
<option name="myName" value="Project Default" />
|
|
4
|
+
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
5
|
+
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
|
6
|
+
<option name="ourVersions">
|
|
7
|
+
<value>
|
|
8
|
+
<list size="3">
|
|
9
|
+
<item index="0" class="java.lang.String" itemvalue="3.11" />
|
|
10
|
+
<item index="1" class="java.lang.String" itemvalue="3.12" />
|
|
11
|
+
<item index="2" class="java.lang.String" itemvalue="3.13" />
|
|
12
|
+
</list>
|
|
13
|
+
</value>
|
|
14
|
+
</option>
|
|
15
|
+
</inspection_tool>
|
|
16
|
+
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
|
17
|
+
<option name="ignoredIdentifiers">
|
|
18
|
+
<list>
|
|
19
|
+
<option value="base64.binascii" />
|
|
20
|
+
</list>
|
|
21
|
+
</option>
|
|
22
|
+
</inspection_tool>
|
|
23
|
+
</profile>
|
|
24
|
+
</component>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="Black">
|
|
4
|
+
<option name="sdkName" value="uv (tsql)" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="KubernetesApiPersistence">{}</component>
|
|
7
|
+
<component name="KubernetesApiProvider"><![CDATA[{
|
|
8
|
+
"isMigrated": true
|
|
9
|
+
}]]></component>
|
|
10
|
+
<component name="ProjectRootManager" version="2" project-jdk-name="uv (tsql)" project-jdk-type="Python SDK" />
|
|
11
|
+
</project>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module version="4">
|
|
3
|
+
<component name="PyDocumentationSettings">
|
|
4
|
+
<option name="format" value="PLAIN" />
|
|
5
|
+
<option name="myDocStringFormat" value="Plain" />
|
|
6
|
+
</component>
|
|
7
|
+
<component name="TestRunnerService">
|
|
8
|
+
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
|
9
|
+
</component>
|
|
10
|
+
</module>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="AutoImportSettings">
|
|
4
|
+
<option name="autoReloadType" value="SELECTIVE" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ChangeListManager">
|
|
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" />
|
|
9
|
+
</list>
|
|
10
|
+
<option name="SHOW_DIALOG" value="false" />
|
|
11
|
+
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
12
|
+
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
13
|
+
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
14
|
+
</component>
|
|
15
|
+
<component name="Git.Settings">
|
|
16
|
+
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
17
|
+
</component>
|
|
18
|
+
<component name="GitHubPullRequestSearchHistory">{
|
|
19
|
+
"lastFilter": {
|
|
20
|
+
"state": "OPEN",
|
|
21
|
+
"assignee": "nhumrich"
|
|
22
|
+
}
|
|
23
|
+
}</component>
|
|
24
|
+
<component name="GithubPullRequestsUISettings">{
|
|
25
|
+
"selectedUrlAndAccountId": {
|
|
26
|
+
"url": "git@github.com:nhumrich/tsql.git",
|
|
27
|
+
"accountId": "f308fc0d-c429-47fb-8e52-74bdc95408d8"
|
|
28
|
+
}
|
|
29
|
+
}</component>
|
|
30
|
+
<component name="ProjectColorInfo">{
|
|
31
|
+
"associatedIndex": 2
|
|
32
|
+
}</component>
|
|
33
|
+
<component name="ProjectId" id="33DagtaPqCq8RWQhigWGQcgOtLh" />
|
|
34
|
+
<component name="ProjectViewState">
|
|
35
|
+
<option name="compactDirectories" value="true" />
|
|
36
|
+
<option name="hideEmptyMiddlePackages" value="true" />
|
|
37
|
+
<option name="showLibraryContents" value="true" />
|
|
38
|
+
</component>
|
|
39
|
+
<component name="PropertiesComponent">{
|
|
40
|
+
"keyToString": {
|
|
41
|
+
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
42
|
+
"Python tests.pytest in test_sqlalchemy_integration.py.executor": "Debug",
|
|
43
|
+
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
44
|
+
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
|
45
|
+
"RunOnceActivity.git.unshallow": "true",
|
|
46
|
+
"git-widget-placeholder": "main",
|
|
47
|
+
"junie.onboarding.icon.badge.shown": "true",
|
|
48
|
+
"last_opened_file_path": "/home/nhumrich/personal/tsql",
|
|
49
|
+
"node.js.detected.package.eslint": "true",
|
|
50
|
+
"node.js.detected.package.tslint": "true",
|
|
51
|
+
"node.js.selected.package.eslint": "(autodetect)",
|
|
52
|
+
"node.js.selected.package.tslint": "(autodetect)",
|
|
53
|
+
"nodejs_package_manager_path": "npm",
|
|
54
|
+
"to.speed.mode.migration.done": "true",
|
|
55
|
+
"vue.rearranger.settings.migration": "true"
|
|
56
|
+
}
|
|
57
|
+
}</component>
|
|
58
|
+
<component name="RunManager">
|
|
59
|
+
<configuration name="pytest in test_sqlalchemy_integration.py" type="tests" factoryName="py.test" temporary="true" nameIsGenerated="true">
|
|
60
|
+
<module name="tsql" />
|
|
61
|
+
<option name="ENV_FILES" value="" />
|
|
62
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
63
|
+
<option name="PARENT_ENVS" value="true" />
|
|
64
|
+
<option name="SDK_HOME" value="" />
|
|
65
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
66
|
+
<option name="IS_MODULE_SDK" value="true" />
|
|
67
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
68
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
69
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
70
|
+
<option name="_new_keywords" value="""" />
|
|
71
|
+
<option name="_new_parameters" value="""" />
|
|
72
|
+
<option name="_new_additionalArguments" value="""" />
|
|
73
|
+
<option name="_new_target" value=""$PROJECT_DIR$/tests/test_sqlalchemy_integration.py"" />
|
|
74
|
+
<option name="_new_targetType" value=""PATH"" />
|
|
75
|
+
<method v="2" />
|
|
76
|
+
</configuration>
|
|
77
|
+
<recent_temporary>
|
|
78
|
+
<list>
|
|
79
|
+
<item itemvalue="Python tests.pytest in test_sqlalchemy_integration.py" />
|
|
80
|
+
</list>
|
|
81
|
+
</recent_temporary>
|
|
82
|
+
</component>
|
|
83
|
+
<component name="SharedIndexes">
|
|
84
|
+
<attachedChunks>
|
|
85
|
+
<set>
|
|
86
|
+
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-PY-252.26830.99" />
|
|
87
|
+
<option value="bundled-python-sdk-164cda30dcd9-0af03a5fa574-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.26830.99" />
|
|
88
|
+
</set>
|
|
89
|
+
</attachedChunks>
|
|
90
|
+
</component>
|
|
91
|
+
<component name="TaskManager">
|
|
92
|
+
<task active="true" id="Default" summary="Default task">
|
|
93
|
+
<changelist id="059146b3-62bd-4ad4-890d-4356cf237b89" name="Changes" comment="" />
|
|
94
|
+
<created>1758854212149</created>
|
|
95
|
+
<option name="number" value="Default" />
|
|
96
|
+
<option name="presentableId" value="Default" />
|
|
97
|
+
<updated>1758854212149</updated>
|
|
98
|
+
</task>
|
|
99
|
+
<servers />
|
|
100
|
+
</component>
|
|
101
|
+
<component name="TypeScriptGeneratedFilesManager">
|
|
102
|
+
<option name="version" value="3" />
|
|
103
|
+
</component>
|
|
104
|
+
<component name="Vcs.Log.Tabs.Properties">
|
|
105
|
+
<option name="TAB_STATES">
|
|
106
|
+
<map>
|
|
107
|
+
<entry key="MAIN">
|
|
108
|
+
<value>
|
|
109
|
+
<State />
|
|
110
|
+
</value>
|
|
111
|
+
</entry>
|
|
112
|
+
</map>
|
|
113
|
+
</option>
|
|
114
|
+
</component>
|
|
115
|
+
<component name="com.intellij.coverage.CoverageDataManagerImpl">
|
|
116
|
+
<SUITE FILE_PATH="coverage/tsql$pytest_in_test_sqlalchemy_integration_py.coverage" NAME="pytest in test_sqlalchemy_integration.py Coverage Results" MODIFIED="1760138305802" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
|
|
117
|
+
</component>
|
|
118
|
+
</project>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
5
|
Project-URL: Homepage, https://github.com/nhumrich/tsql
|
|
6
6
|
License-File: LICENSE
|
|
@@ -154,11 +154,29 @@ values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
|
|
|
154
154
|
tsql.insert(table, values)
|
|
155
155
|
# INSERT INTO users (id, name, email) VALUES ('abc123', 'bob', 'bob@example.com')
|
|
156
156
|
|
|
157
|
+
# insert with ignore_conflict
|
|
158
|
+
tsql.insert(table, values, ignore_conflict=True)
|
|
159
|
+
# INSERT INTO users (id, name, email) VALUES ('abc123', 'bob', 'bob@example.com') ON CONFLICT DO NOTHING RETURNING *
|
|
160
|
+
|
|
161
|
+
# upsert (insert or update on conflict)
|
|
162
|
+
values = {'id': 'abc123', 'name': 'joe', 'email': 'joe@example.com'}
|
|
163
|
+
tsql.upsert(table, values, conflict_on='id')
|
|
164
|
+
# INSERT INTO users (id, name, email) VALUES ('abc123', 'joe', 'joe@example.com')
|
|
165
|
+
# ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email RETURNING *
|
|
166
|
+
|
|
167
|
+
# upsert with multiple conflict columns
|
|
168
|
+
tsql.upsert(table, values, conflict_on=['email', 'name'])
|
|
169
|
+
# ON CONFLICT (email, name) DO UPDATE SET ...
|
|
170
|
+
|
|
157
171
|
# update values on a single row
|
|
158
172
|
table = 'users'
|
|
159
173
|
values = {'name': 'joe', 'email': 'joe@example.com'}
|
|
160
174
|
tsql.update(table, values, id='abc123')
|
|
161
|
-
# UPDATE users SET name='joe', email='joe@example.com' WHERE id='abc123'
|
|
175
|
+
# UPDATE users SET name='joe', email='joe@example.com' WHERE id='abc123' RETURNING *
|
|
176
|
+
|
|
177
|
+
# delete a single row
|
|
178
|
+
tsql.delete(table, id='abc123')
|
|
179
|
+
# DELETE FROM users WHERE id = 'abc123'
|
|
162
180
|
```
|
|
163
181
|
|
|
164
182
|
# Query Builder
|
|
@@ -251,6 +269,54 @@ query = (Posts.select(Posts.title, Users.username)
|
|
|
251
269
|
.limit(20))
|
|
252
270
|
```
|
|
253
271
|
|
|
272
|
+
## Write Operations
|
|
273
|
+
|
|
274
|
+
The query builder supports INSERT, UPDATE, UPSERT, and DELETE operations:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
# INSERT
|
|
278
|
+
values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
|
|
279
|
+
query = Users.insert(values)
|
|
280
|
+
sql, params = query.render()
|
|
281
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *
|
|
282
|
+
|
|
283
|
+
# INSERT with conflict handling (ignore)
|
|
284
|
+
query = Users.insert(values, ignore_conflict=True)
|
|
285
|
+
sql, params = query.render()
|
|
286
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING *
|
|
287
|
+
|
|
288
|
+
# UPSERT (INSERT ... ON CONFLICT DO UPDATE)
|
|
289
|
+
values = {'id': 'abc123', 'username': 'john_updated', 'email': 'john@example.com'}
|
|
290
|
+
query = Users.upsert(values, conflict_on='id')
|
|
291
|
+
sql, params = query.render()
|
|
292
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?)
|
|
293
|
+
# ON CONFLICT (id) DO UPDATE SET username=EXCLUDED.username, email=EXCLUDED.email RETURNING *
|
|
294
|
+
|
|
295
|
+
# UPSERT with multiple conflict columns
|
|
296
|
+
query = Users.upsert(values, conflict_on=['email', 'username'])
|
|
297
|
+
# Can also use Column objects: conflict_on=Users.id or conflict_on=[Users.email, Users.username]
|
|
298
|
+
|
|
299
|
+
# UPDATE with WHERE conditions
|
|
300
|
+
query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
|
|
301
|
+
sql, params = query.render()
|
|
302
|
+
# UPDATE users SET email=? WHERE users.id = ? RETURNING *
|
|
303
|
+
|
|
304
|
+
# UPDATE with multiple conditions
|
|
305
|
+
query = (Users.update({'email': 'newemail@example.com'})
|
|
306
|
+
.where(Users.id == 'abc123')
|
|
307
|
+
.where(Users.username == 'john'))
|
|
308
|
+
|
|
309
|
+
# DELETE with WHERE conditions
|
|
310
|
+
query = Users.delete().where(Users.id == 'abc123')
|
|
311
|
+
sql, params = query.render()
|
|
312
|
+
# DELETE FROM users WHERE users.id = ? RETURNING *
|
|
313
|
+
|
|
314
|
+
# DELETE with multiple conditions
|
|
315
|
+
query = Users.delete().where(Users.id > 100).where(Users.email == None)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
All write operations return `RETURNING *` by default to retrieve the affected rows.
|
|
319
|
+
|
|
254
320
|
## Advanced Mixed Query (Query Builder + T-Strings)
|
|
255
321
|
|
|
256
322
|
You can combine the query builder's structured approach with raw t-string conditions for complex logic:
|
|
@@ -143,11 +143,29 @@ values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
|
|
|
143
143
|
tsql.insert(table, values)
|
|
144
144
|
# INSERT INTO users (id, name, email) VALUES ('abc123', 'bob', 'bob@example.com')
|
|
145
145
|
|
|
146
|
+
# insert with ignore_conflict
|
|
147
|
+
tsql.insert(table, values, ignore_conflict=True)
|
|
148
|
+
# INSERT INTO users (id, name, email) VALUES ('abc123', 'bob', 'bob@example.com') ON CONFLICT DO NOTHING RETURNING *
|
|
149
|
+
|
|
150
|
+
# upsert (insert or update on conflict)
|
|
151
|
+
values = {'id': 'abc123', 'name': 'joe', 'email': 'joe@example.com'}
|
|
152
|
+
tsql.upsert(table, values, conflict_on='id')
|
|
153
|
+
# INSERT INTO users (id, name, email) VALUES ('abc123', 'joe', 'joe@example.com')
|
|
154
|
+
# ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email RETURNING *
|
|
155
|
+
|
|
156
|
+
# upsert with multiple conflict columns
|
|
157
|
+
tsql.upsert(table, values, conflict_on=['email', 'name'])
|
|
158
|
+
# ON CONFLICT (email, name) DO UPDATE SET ...
|
|
159
|
+
|
|
146
160
|
# update values on a single row
|
|
147
161
|
table = 'users'
|
|
148
162
|
values = {'name': 'joe', 'email': 'joe@example.com'}
|
|
149
163
|
tsql.update(table, values, id='abc123')
|
|
150
|
-
# UPDATE users SET name='joe', email='joe@example.com' WHERE id='abc123'
|
|
164
|
+
# UPDATE users SET name='joe', email='joe@example.com' WHERE id='abc123' RETURNING *
|
|
165
|
+
|
|
166
|
+
# delete a single row
|
|
167
|
+
tsql.delete(table, id='abc123')
|
|
168
|
+
# DELETE FROM users WHERE id = 'abc123'
|
|
151
169
|
```
|
|
152
170
|
|
|
153
171
|
# Query Builder
|
|
@@ -240,6 +258,54 @@ query = (Posts.select(Posts.title, Users.username)
|
|
|
240
258
|
.limit(20))
|
|
241
259
|
```
|
|
242
260
|
|
|
261
|
+
## Write Operations
|
|
262
|
+
|
|
263
|
+
The query builder supports INSERT, UPDATE, UPSERT, and DELETE operations:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
# INSERT
|
|
267
|
+
values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
|
|
268
|
+
query = Users.insert(values)
|
|
269
|
+
sql, params = query.render()
|
|
270
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *
|
|
271
|
+
|
|
272
|
+
# INSERT with conflict handling (ignore)
|
|
273
|
+
query = Users.insert(values, ignore_conflict=True)
|
|
274
|
+
sql, params = query.render()
|
|
275
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING *
|
|
276
|
+
|
|
277
|
+
# UPSERT (INSERT ... ON CONFLICT DO UPDATE)
|
|
278
|
+
values = {'id': 'abc123', 'username': 'john_updated', 'email': 'john@example.com'}
|
|
279
|
+
query = Users.upsert(values, conflict_on='id')
|
|
280
|
+
sql, params = query.render()
|
|
281
|
+
# INSERT INTO users (id, username, email) VALUES (?, ?, ?)
|
|
282
|
+
# ON CONFLICT (id) DO UPDATE SET username=EXCLUDED.username, email=EXCLUDED.email RETURNING *
|
|
283
|
+
|
|
284
|
+
# UPSERT with multiple conflict columns
|
|
285
|
+
query = Users.upsert(values, conflict_on=['email', 'username'])
|
|
286
|
+
# Can also use Column objects: conflict_on=Users.id or conflict_on=[Users.email, Users.username]
|
|
287
|
+
|
|
288
|
+
# UPDATE with WHERE conditions
|
|
289
|
+
query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
|
|
290
|
+
sql, params = query.render()
|
|
291
|
+
# UPDATE users SET email=? WHERE users.id = ? RETURNING *
|
|
292
|
+
|
|
293
|
+
# UPDATE with multiple conditions
|
|
294
|
+
query = (Users.update({'email': 'newemail@example.com'})
|
|
295
|
+
.where(Users.id == 'abc123')
|
|
296
|
+
.where(Users.username == 'john'))
|
|
297
|
+
|
|
298
|
+
# DELETE with WHERE conditions
|
|
299
|
+
query = Users.delete().where(Users.id == 'abc123')
|
|
300
|
+
sql, params = query.render()
|
|
301
|
+
# DELETE FROM users WHERE users.id = ? RETURNING *
|
|
302
|
+
|
|
303
|
+
# DELETE with multiple conditions
|
|
304
|
+
query = Users.delete().where(Users.id > 100).where(Users.email == None)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
All write operations return `RETURNING *` by default to retrieve the affected rows.
|
|
308
|
+
|
|
243
309
|
## Advanced Mixed Query (Query Builder + T-Strings)
|
|
244
310
|
|
|
245
311
|
You can combine the query builder's structured approach with raw t-string conditions for complex logic:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"excludeFolders": [
|
|
3
|
+
"tests",
|
|
4
|
+
".github",
|
|
5
|
+
".git",
|
|
6
|
+
"__pycache__",
|
|
7
|
+
"*.egg-info"
|
|
8
|
+
],
|
|
9
|
+
"excludeFiles": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
".gitignore"
|
|
13
|
+
],
|
|
14
|
+
"rules": [
|
|
15
|
+
"CRITICAL: This library requires Python 3.14+ due to PEP 750 t-strings. Never suggest it for older Python versions.",
|
|
16
|
+
"Install with 'uv add t-sql' or 'pip install t-sql'. Use 't-sql[sqlalchemy]' for SQLAlchemy/Alembic integration.",
|
|
17
|
+
"Always use t-strings (t\"...\") not regular strings (\"\") or f-strings (f\"\"\") for SQL queries. The library is designed so raw strings won't work - this prevents SQL injection by design.",
|
|
18
|
+
"Use the query builder (@table decorator with typed columns) for complex multi-table joins and structured queries. Use t-string templating for simpler queries or custom SQL logic that the query builder doesn't support.",
|
|
19
|
+
"The :literal format spec is for dynamic table/column names that cannot be parameterized. It sanitizes against valid SQL identifiers.",
|
|
20
|
+
"The :unsafe format spec bypasses all safety checks. Only use with hardcoded strings, never with user input.",
|
|
21
|
+
"The :as_values format spec converts dicts to INSERT VALUES format. The :as_set format spec converts dicts to UPDATE SET format.",
|
|
22
|
+
"When mixing query builder with t-strings using .where(), t-string conditions are automatically wrapped in parentheses for proper operator precedence.",
|
|
23
|
+
"The @table decorator returns an instance - use the decorated class directly, don't instantiate it.",
|
|
24
|
+
"Default parameter style is QMARK (?). Use tsql.styles.NUMERIC_DOLLAR for PostgreSQL ($1, $2), or other styles as needed."
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncpg
|
|
2
|
+
import os
|
|
2
3
|
import pytest
|
|
3
4
|
|
|
4
5
|
import tsql
|
|
@@ -6,7 +7,7 @@ import tsql.styles
|
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
# Test configuration
|
|
9
|
-
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
10
|
+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5454/postgres")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@pytest.fixture
|
|
@@ -233,4 +234,95 @@ async def test_escaped_handles_comment_injection(conn):
|
|
|
233
234
|
malicious_input = "admin'--"
|
|
234
235
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
235
236
|
rows = await conn.fetch(query)
|
|
236
|
-
assert len(rows) == 0
|
|
237
|
+
assert len(rows) == 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def test_upsert_insert_new_row(conn):
|
|
241
|
+
"""Test upsert inserts a new row when no conflict exists"""
|
|
242
|
+
values = {
|
|
243
|
+
'id': 1,
|
|
244
|
+
'name': 'Alice',
|
|
245
|
+
'age': 30
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
query = tsql.upsert('test_users', values, conflict_on='id')
|
|
249
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
250
|
+
|
|
251
|
+
result = await conn.fetchrow(sql, *params)
|
|
252
|
+
|
|
253
|
+
assert result['id'] == 1
|
|
254
|
+
assert result['name'] == 'Alice'
|
|
255
|
+
assert result['age'] == 30
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def test_upsert_updates_on_conflict(conn):
|
|
259
|
+
"""Test upsert updates existing row on conflict"""
|
|
260
|
+
# Insert initial row
|
|
261
|
+
await conn.execute(
|
|
262
|
+
"INSERT INTO test_users (id, name, age) VALUES ($1, $2, $3)",
|
|
263
|
+
1, 'Alice', 30
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Upsert with same id but different values
|
|
267
|
+
values = {
|
|
268
|
+
'id': 1,
|
|
269
|
+
'name': 'Alice Updated',
|
|
270
|
+
'age': 31
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
query = tsql.upsert('test_users', values, conflict_on='id')
|
|
274
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
275
|
+
|
|
276
|
+
result = await conn.fetchrow(sql, *params)
|
|
277
|
+
|
|
278
|
+
# Should update the existing row
|
|
279
|
+
assert result['id'] == 1
|
|
280
|
+
assert result['name'] == 'Alice Updated'
|
|
281
|
+
assert result['age'] == 31
|
|
282
|
+
|
|
283
|
+
# Verify only one row exists
|
|
284
|
+
count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
285
|
+
assert count == 1
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def test_upsert_multiple_conflict_columns(conn):
|
|
289
|
+
"""Test upsert with composite unique constraint"""
|
|
290
|
+
# Create a table with composite unique constraint
|
|
291
|
+
await conn.execute("""
|
|
292
|
+
CREATE TABLE IF NOT EXISTS test_emails (
|
|
293
|
+
user_id INTEGER,
|
|
294
|
+
email VARCHAR(100),
|
|
295
|
+
verified BOOLEAN,
|
|
296
|
+
UNIQUE(user_id, email)
|
|
297
|
+
)
|
|
298
|
+
""")
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Insert initial row
|
|
302
|
+
await conn.execute(
|
|
303
|
+
"INSERT INTO test_emails (user_id, email, verified) VALUES ($1, $2, $3)",
|
|
304
|
+
1, 'alice@example.com', False
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Upsert with same user_id and email
|
|
308
|
+
values = {
|
|
309
|
+
'user_id': 1,
|
|
310
|
+
'email': 'alice@example.com',
|
|
311
|
+
'verified': True
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
query = tsql.upsert('test_emails', values, conflict_on=['user_id', 'email'])
|
|
315
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
316
|
+
|
|
317
|
+
result = await conn.fetchrow(sql, *params)
|
|
318
|
+
|
|
319
|
+
# Should update verified status
|
|
320
|
+
assert result['user_id'] == 1
|
|
321
|
+
assert result['email'] == 'alice@example.com'
|
|
322
|
+
assert result['verified'] is True
|
|
323
|
+
|
|
324
|
+
# Verify only one row exists
|
|
325
|
+
count = await conn.fetchval("SELECT COUNT(*) FROM test_emails")
|
|
326
|
+
assert count == 1
|
|
327
|
+
finally:
|
|
328
|
+
await conn.execute("DROP TABLE IF EXISTS test_emails")
|
|
@@ -151,4 +151,79 @@ def test_select_int_id_safe():
|
|
|
151
151
|
|
|
152
152
|
# Int should be parameterized
|
|
153
153
|
assert "?" in query
|
|
154
|
-
assert params == [42]
|
|
154
|
+
assert params == [42]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_upsert_single_conflict():
|
|
158
|
+
"""Test upsert with a single conflict column"""
|
|
159
|
+
values = {
|
|
160
|
+
'email': 'test@example.com',
|
|
161
|
+
'name': 'Alice',
|
|
162
|
+
'age': 30
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
query = tsql.upsert('users', values, conflict_on='email')
|
|
166
|
+
result = tsql.render(query)
|
|
167
|
+
|
|
168
|
+
assert "INSERT INTO users" in result[0]
|
|
169
|
+
assert "email" in result[0] and "name" in result[0] and "age" in result[0]
|
|
170
|
+
assert "ON CONFLICT (email)" in result[0]
|
|
171
|
+
assert "DO UPDATE SET" in result[0]
|
|
172
|
+
assert "name = EXCLUDED.name" in result[0]
|
|
173
|
+
assert "age = EXCLUDED.age" in result[0]
|
|
174
|
+
assert "email = EXCLUDED.email" not in result[0] # Conflict column shouldn't be in UPDATE
|
|
175
|
+
assert result[1] == ['test@example.com', 'Alice', 30]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_upsert_multiple_conflicts():
|
|
179
|
+
"""Test upsert with multiple conflict columns"""
|
|
180
|
+
values = {
|
|
181
|
+
'email': 'test@example.com',
|
|
182
|
+
'username': 'alice',
|
|
183
|
+
'name': 'Alice Smith'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
query = tsql.upsert('users', values, conflict_on=['email', 'username'])
|
|
187
|
+
result = tsql.render(query)
|
|
188
|
+
|
|
189
|
+
assert "INSERT INTO users" in result[0]
|
|
190
|
+
assert "ON CONFLICT (email, username)" in result[0]
|
|
191
|
+
assert "DO UPDATE SET" in result[0]
|
|
192
|
+
assert "name = EXCLUDED.name" in result[0]
|
|
193
|
+
assert "email = EXCLUDED.email" not in result[0]
|
|
194
|
+
assert "username = EXCLUDED.username" not in result[0]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_upsert_all_conflict_columns():
|
|
198
|
+
"""Test upsert where all columns are conflict columns (should DO NOTHING)"""
|
|
199
|
+
values = {
|
|
200
|
+
'email': 'test@example.com'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
query = tsql.upsert('users', values, conflict_on='email')
|
|
204
|
+
result = tsql.render(query)
|
|
205
|
+
|
|
206
|
+
assert "INSERT INTO users" in result[0]
|
|
207
|
+
assert "ON CONFLICT (email)" in result[0]
|
|
208
|
+
assert "DO NOTHING" in result[0]
|
|
209
|
+
assert "DO UPDATE" not in result[0]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_delete():
|
|
213
|
+
"""Test the delete helper function"""
|
|
214
|
+
query = tsql.delete('users', 123)
|
|
215
|
+
result = tsql.render(query)
|
|
216
|
+
|
|
217
|
+
assert "DELETE FROM users" in result[0]
|
|
218
|
+
assert "WHERE id = ?" in result[0]
|
|
219
|
+
assert result[1] == [123]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_delete_string_id():
|
|
223
|
+
"""Test delete with string ID"""
|
|
224
|
+
query = tsql.delete('users', 'abc-123')
|
|
225
|
+
result = tsql.render(query)
|
|
226
|
+
|
|
227
|
+
assert "DELETE FROM users" in result[0]
|
|
228
|
+
assert "WHERE id = ?" in result[0]
|
|
229
|
+
assert result[1] == ['abc-123']
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import asyncpg
|
|
2
|
+
import os
|
|
2
3
|
import pytest
|
|
3
4
|
|
|
4
5
|
import tsql
|
|
5
6
|
import tsql.styles
|
|
6
7
|
|
|
7
8
|
# Test configuration
|
|
8
|
-
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
9
|
+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5454/postgres")
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@pytest.fixture
|
|
@@ -3,12 +3,13 @@ Proof-of-concept test demonstrating that tsql actually prevents SQL injection at
|
|
|
3
3
|
against a real PostgreSQL database.
|
|
4
4
|
"""
|
|
5
5
|
import asyncpg
|
|
6
|
+
import os
|
|
6
7
|
import pytest
|
|
7
8
|
|
|
8
9
|
import tsql
|
|
9
10
|
import tsql.styles
|
|
10
11
|
|
|
11
|
-
DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
|
|
12
|
+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5454/postgres")
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@pytest.fixture
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import tsql
|
|
2
|
-
from tsql.query_builder import table, Column, Condition
|
|
2
|
+
from tsql.query_builder import table, Column, Condition, UpdateBuilder, DeleteBuilder
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
@table('users')
|
|
@@ -388,3 +388,145 @@ def test_where_with_tstring_complex():
|
|
|
388
388
|
assert 'WHERE users.id > ?' in sql
|
|
389
389
|
assert 'AND (username ILIKE ? OR email ILIKE ?)' in sql
|
|
390
390
|
assert params == [5, 'john', 'jane']
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_table_insert():
|
|
394
|
+
"""Test table.insert() method"""
|
|
395
|
+
query = Users.insert({'username': 'bob', 'email': 'bob@example.com'})
|
|
396
|
+
sql, params = query.render()
|
|
397
|
+
|
|
398
|
+
assert 'INSERT INTO users' in sql
|
|
399
|
+
assert 'username' in sql and 'email' in sql
|
|
400
|
+
assert 'VALUES' in sql
|
|
401
|
+
assert 'RETURNING *' in sql
|
|
402
|
+
assert params == ['bob', 'bob@example.com']
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_table_insert_ignore_conflict():
|
|
406
|
+
"""Test table.insert() with ignore_conflict"""
|
|
407
|
+
query = Users.insert({'username': 'bob', 'email': 'bob@example.com'}, ignore_conflict=True)
|
|
408
|
+
sql, params = query.render()
|
|
409
|
+
|
|
410
|
+
assert 'INSERT INTO users' in sql
|
|
411
|
+
assert 'ON CONFLICT DO NOTHING' in sql
|
|
412
|
+
assert 'RETURNING *' in sql
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_table_upsert():
|
|
416
|
+
"""Test table.upsert() method"""
|
|
417
|
+
query = Users.upsert({'email': 'bob@example.com', 'username': 'bob'}, conflict_on='email')
|
|
418
|
+
sql, params = query.render()
|
|
419
|
+
|
|
420
|
+
assert 'INSERT INTO users' in sql
|
|
421
|
+
assert 'ON CONFLICT (email)' in sql
|
|
422
|
+
assert 'DO UPDATE SET' in sql
|
|
423
|
+
assert 'username = EXCLUDED.username' in sql
|
|
424
|
+
assert 'RETURNING *' in sql
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_table_upsert_with_column_object():
|
|
428
|
+
"""Test table.upsert() with Column object"""
|
|
429
|
+
query = Users.upsert({'email': 'bob@example.com', 'username': 'bob'}, conflict_on=Users.email)
|
|
430
|
+
sql, params = query.render()
|
|
431
|
+
|
|
432
|
+
assert 'INSERT INTO users' in sql
|
|
433
|
+
assert 'ON CONFLICT (email)' in sql
|
|
434
|
+
assert 'DO UPDATE SET' in sql
|
|
435
|
+
assert 'username = EXCLUDED.username' in sql
|
|
436
|
+
assert 'RETURNING *' in sql
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_table_upsert_with_multiple_column_objects():
|
|
440
|
+
"""Test table.upsert() with multiple Column objects"""
|
|
441
|
+
query = Users.upsert(
|
|
442
|
+
{'email': 'bob@example.com', 'username': 'bob', 'created_at': '2024-01-01'},
|
|
443
|
+
conflict_on=[Users.email, Users.username]
|
|
444
|
+
)
|
|
445
|
+
sql, params = query.render()
|
|
446
|
+
|
|
447
|
+
assert 'INSERT INTO users' in sql
|
|
448
|
+
assert 'ON CONFLICT (email, username)' in sql
|
|
449
|
+
assert 'DO UPDATE SET' in sql
|
|
450
|
+
assert 'created_at = EXCLUDED.created_at' in sql
|
|
451
|
+
assert 'RETURNING *' in sql
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_table_update_with_where():
|
|
455
|
+
"""Test table.update() with WHERE clause"""
|
|
456
|
+
builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5)
|
|
457
|
+
assert isinstance(builder, UpdateBuilder)
|
|
458
|
+
|
|
459
|
+
sql, params = builder.render()
|
|
460
|
+
|
|
461
|
+
assert 'UPDATE users SET' in sql
|
|
462
|
+
assert 'username = ?' in sql
|
|
463
|
+
assert 'WHERE users.id = ?' in sql
|
|
464
|
+
assert 'RETURNING *' in sql
|
|
465
|
+
assert params == ['bob_updated', 5]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_table_update_multiple_conditions():
|
|
469
|
+
"""Test table.update() with multiple WHERE conditions"""
|
|
470
|
+
builder = (Users.update({'username': 'bob_updated', 'email': 'new@example.com'})
|
|
471
|
+
.where(Users.id > 10)
|
|
472
|
+
.where(Users.created_at == None))
|
|
473
|
+
|
|
474
|
+
sql, params = builder.render()
|
|
475
|
+
|
|
476
|
+
assert 'UPDATE users SET' in sql
|
|
477
|
+
assert 'WHERE users.id > ?' in sql
|
|
478
|
+
assert 'AND users.created_at IS NULL' in sql
|
|
479
|
+
assert 'RETURNING *' in sql
|
|
480
|
+
assert params == ['bob_updated', 'new@example.com', 10]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_table_delete_with_where():
|
|
484
|
+
"""Test table.delete() with WHERE clause"""
|
|
485
|
+
builder = Users.delete().where(Users.id == 5)
|
|
486
|
+
assert isinstance(builder, DeleteBuilder)
|
|
487
|
+
|
|
488
|
+
sql, params = builder.render()
|
|
489
|
+
|
|
490
|
+
assert 'DELETE FROM users' in sql
|
|
491
|
+
assert 'WHERE users.id = ?' in sql
|
|
492
|
+
assert 'RETURNING *' in sql
|
|
493
|
+
assert params == [5]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_table_delete_multiple_conditions():
|
|
497
|
+
"""Test table.delete() with multiple WHERE conditions"""
|
|
498
|
+
builder = (Users.delete()
|
|
499
|
+
.where(Users.id > 100)
|
|
500
|
+
.where(Users.email == None))
|
|
501
|
+
|
|
502
|
+
sql, params = builder.render()
|
|
503
|
+
|
|
504
|
+
assert 'DELETE FROM users' in sql
|
|
505
|
+
assert 'WHERE users.id > ?' in sql
|
|
506
|
+
assert 'AND users.email IS NULL' in sql
|
|
507
|
+
assert 'RETURNING *' in sql
|
|
508
|
+
assert params == [100]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_update_with_t_string_where():
|
|
512
|
+
"""Test UpdateBuilder with raw t-string WHERE clause"""
|
|
513
|
+
min_age = 18
|
|
514
|
+
builder = Users.update({'username': 'adult'}).where(t"age >= {min_age}")
|
|
515
|
+
|
|
516
|
+
sql, params = builder.render()
|
|
517
|
+
|
|
518
|
+
assert 'UPDATE users SET' in sql
|
|
519
|
+
assert 'WHERE (age >= ?)' in sql
|
|
520
|
+
assert params == ['adult', 18]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_delete_with_t_string_where():
|
|
524
|
+
"""Test DeleteBuilder with raw t-string WHERE clause"""
|
|
525
|
+
pattern = '%test%'
|
|
526
|
+
builder = Users.delete().where(t"email LIKE {pattern}")
|
|
527
|
+
|
|
528
|
+
sql, params = builder.render()
|
|
529
|
+
|
|
530
|
+
assert 'DELETE FROM users' in sql
|
|
531
|
+
assert 'WHERE (email LIKE ?)' in sql
|
|
532
|
+
assert params == ['%test%']
|
|
@@ -72,7 +72,12 @@ class TSQL:
|
|
|
72
72
|
|
|
73
73
|
@classmethod
|
|
74
74
|
def _check_literal(cls, val: str):
|
|
75
|
-
if not isinstance(val, str)
|
|
75
|
+
if not isinstance(val, str):
|
|
76
|
+
raise ValueError(f"Invalid literal {val}")
|
|
77
|
+
|
|
78
|
+
# Allow qualified identifiers (table.column, schema.table.column)
|
|
79
|
+
parts = val.split('.')
|
|
80
|
+
if not parts or not all(part.isidentifier() for part in parts):
|
|
76
81
|
raise ValueError(f"Invalid literal {val}")
|
|
77
82
|
return val
|
|
78
83
|
|
|
@@ -239,6 +244,68 @@ def update(table: str, values: dict[str, Any], id: str):
|
|
|
239
244
|
|
|
240
245
|
if not isinstance(values, dict):
|
|
241
246
|
raise ValueError("values must be a dictionary")
|
|
242
|
-
|
|
247
|
+
|
|
243
248
|
return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id} RETURNING *")
|
|
244
249
|
|
|
250
|
+
|
|
251
|
+
def upsert(table: str, values: dict[str, Any], conflict_on: str | list[str]):
|
|
252
|
+
"""Helper function to build INSERT ... ON CONFLICT DO UPDATE (upsert) queries
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
table: Table name
|
|
256
|
+
values: Dictionary of column names and values to insert
|
|
257
|
+
conflict_on: Column name(s) that define the conflict constraint
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
TSQL object representing the upsert query
|
|
261
|
+
"""
|
|
262
|
+
if not isinstance(values, dict):
|
|
263
|
+
raise TypeError("values must be a dict")
|
|
264
|
+
|
|
265
|
+
# Normalize conflict_on to a list
|
|
266
|
+
conflict_cols = [conflict_on] if isinstance(conflict_on, str) else conflict_on
|
|
267
|
+
|
|
268
|
+
# Build the conflict target: ON CONFLICT (col1, col2)
|
|
269
|
+
conflict_target_parts = ['(']
|
|
270
|
+
for i, col in enumerate(conflict_cols):
|
|
271
|
+
if i > 0:
|
|
272
|
+
conflict_target_parts.append(', ')
|
|
273
|
+
conflict_target_parts.append(col)
|
|
274
|
+
conflict_target_parts.append(')')
|
|
275
|
+
|
|
276
|
+
# Build the UPDATE SET clause with EXCLUDED.* for non-conflict columns
|
|
277
|
+
update_cols = {k: v for k, v in values.items() if k not in conflict_cols}
|
|
278
|
+
|
|
279
|
+
if not update_cols:
|
|
280
|
+
# If all columns are conflict columns, just do nothing
|
|
281
|
+
conflict_clause_parts = [' ON CONFLICT '] + conflict_target_parts + [' DO NOTHING']
|
|
282
|
+
else:
|
|
283
|
+
update_set_parts = []
|
|
284
|
+
for i, key in enumerate(update_cols.keys()):
|
|
285
|
+
if i > 0:
|
|
286
|
+
update_set_parts.append(', ')
|
|
287
|
+
update_set_parts.append(key)
|
|
288
|
+
update_set_parts.append(' = EXCLUDED.')
|
|
289
|
+
update_set_parts.append(key)
|
|
290
|
+
|
|
291
|
+
conflict_clause_parts = [' ON CONFLICT '] + conflict_target_parts + [' DO UPDATE SET '] + update_set_parts
|
|
292
|
+
|
|
293
|
+
# Create the conflict clause TSQL object
|
|
294
|
+
conflict_tsql = TSQL.__new__(TSQL)
|
|
295
|
+
conflict_tsql._sql_parts = conflict_clause_parts
|
|
296
|
+
|
|
297
|
+
return TSQL(t"INSERT INTO {table:literal} {values:as_values}{conflict_tsql} RETURNING *")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def delete(table: str, id: str|int):
|
|
301
|
+
"""Helper function to build DELETE queries for a single row
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
table: Table name
|
|
305
|
+
id: ID value to delete
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
TSQL object representing the DELETE query
|
|
309
|
+
"""
|
|
310
|
+
return TSQL(t"DELETE FROM {table:literal} WHERE id = {id}")
|
|
311
|
+
|
|
@@ -3,7 +3,7 @@ from string.templatelib import Template
|
|
|
3
3
|
from functools import wraps
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
|
-
from tsql import TSQL, t_join
|
|
6
|
+
from tsql import TSQL, t_join, insert as tsql_insert, upsert as tsql_upsert, delete as tsql_delete, as_set
|
|
7
7
|
|
|
8
8
|
# Optional SQLAlchemy support
|
|
9
9
|
try:
|
|
@@ -128,6 +128,104 @@ class Join:
|
|
|
128
128
|
return t'{join_type:unsafe} JOIN {table_name:literal} ON {condition_tsql}'
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
class UpdateBuilder:
|
|
132
|
+
"""Fluent interface for building UPDATE queries"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, base_table: 'Table', values: dict[str, Any]):
|
|
135
|
+
self.base_table = base_table
|
|
136
|
+
self.values = values
|
|
137
|
+
self._conditions: List[Union[Condition, Template]] = []
|
|
138
|
+
|
|
139
|
+
def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
|
|
140
|
+
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
141
|
+
self._conditions.append(condition)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def to_tsql(self) -> TSQL:
|
|
145
|
+
"""Build the final TSQL object"""
|
|
146
|
+
parts: List[Template] = []
|
|
147
|
+
|
|
148
|
+
table_name = self.base_table.table_name
|
|
149
|
+
values_dict = self.values
|
|
150
|
+
parts.append(t'UPDATE {table_name:literal} SET {values_dict:as_set}')
|
|
151
|
+
|
|
152
|
+
if self._conditions:
|
|
153
|
+
where_parts = []
|
|
154
|
+
for cond in self._conditions:
|
|
155
|
+
if isinstance(cond, Template):
|
|
156
|
+
where_parts.append(t'({cond})')
|
|
157
|
+
else:
|
|
158
|
+
where_parts.append(cond.to_tsql())
|
|
159
|
+
combined_where = t_join(t' AND ', where_parts)
|
|
160
|
+
parts.append(t'WHERE {combined_where}')
|
|
161
|
+
|
|
162
|
+
parts.append(t'RETURNING *')
|
|
163
|
+
|
|
164
|
+
return TSQL(t_join(t' ', parts))
|
|
165
|
+
|
|
166
|
+
def render(self, style=None):
|
|
167
|
+
"""Convenience method to render the query directly"""
|
|
168
|
+
return self.to_tsql().render(style)
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
"""Show the rendered SQL query for debugging"""
|
|
172
|
+
try:
|
|
173
|
+
query, params = self.to_tsql().render()
|
|
174
|
+
if params:
|
|
175
|
+
return f"UpdateBuilder(\n SQL: {query}\n Params: {params}\n)"
|
|
176
|
+
return f"UpdateBuilder({query})"
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return f"UpdateBuilder(<error rendering: {e}>)"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DeleteBuilder:
|
|
182
|
+
"""Fluent interface for building DELETE queries"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, base_table: 'Table'):
|
|
185
|
+
self.base_table = base_table
|
|
186
|
+
self._conditions: List[Union[Condition, Template]] = []
|
|
187
|
+
|
|
188
|
+
def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
|
|
189
|
+
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
190
|
+
self._conditions.append(condition)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def to_tsql(self) -> TSQL:
|
|
194
|
+
"""Build the final TSQL object"""
|
|
195
|
+
parts: List[Template] = []
|
|
196
|
+
|
|
197
|
+
table_name = self.base_table.table_name
|
|
198
|
+
parts.append(t'DELETE FROM {table_name:literal}')
|
|
199
|
+
|
|
200
|
+
if self._conditions:
|
|
201
|
+
where_parts = []
|
|
202
|
+
for cond in self._conditions:
|
|
203
|
+
if isinstance(cond, Template):
|
|
204
|
+
where_parts.append(t'({cond})')
|
|
205
|
+
else:
|
|
206
|
+
where_parts.append(cond.to_tsql())
|
|
207
|
+
combined_where = t_join(t' AND ', where_parts)
|
|
208
|
+
parts.append(t'WHERE {combined_where}')
|
|
209
|
+
|
|
210
|
+
parts.append(t'RETURNING *')
|
|
211
|
+
|
|
212
|
+
return TSQL(t_join(t' ', parts))
|
|
213
|
+
|
|
214
|
+
def render(self, style=None):
|
|
215
|
+
"""Convenience method to render the query directly"""
|
|
216
|
+
return self.to_tsql().render(style)
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
"""Show the rendered SQL query for debugging"""
|
|
220
|
+
try:
|
|
221
|
+
query, params = self.to_tsql().render()
|
|
222
|
+
if params:
|
|
223
|
+
return f"DeleteBuilder(\n SQL: {query}\n Params: {params}\n)"
|
|
224
|
+
return f"DeleteBuilder({query})"
|
|
225
|
+
except Exception as e:
|
|
226
|
+
return f"DeleteBuilder(<error rendering: {e}>)"
|
|
227
|
+
|
|
228
|
+
|
|
131
229
|
class QueryBuilder:
|
|
132
230
|
"""Fluent interface for building SQL queries"""
|
|
133
231
|
|
|
@@ -374,7 +472,60 @@ def table(name: str, *, metadata: Optional[Any] = None, schema: Optional[str] =
|
|
|
374
472
|
builder.select(*columns)
|
|
375
473
|
return builder
|
|
376
474
|
|
|
475
|
+
def insert(self, values: dict[str, Any], ignore_conflict: bool = False) -> TSQL:
|
|
476
|
+
"""Insert a row into the table
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
values: Dictionary of column names and values
|
|
480
|
+
ignore_conflict: If True, adds ON CONFLICT DO NOTHING
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
TSQL object representing the INSERT query
|
|
484
|
+
"""
|
|
485
|
+
return tsql_insert(self.table_name, values, ignore_conflict=ignore_conflict)
|
|
486
|
+
|
|
487
|
+
def upsert(self, values: dict[str, Any], conflict_on: str | list[str] | Column | list[Column]) -> TSQL:
|
|
488
|
+
"""Upsert (INSERT ... ON CONFLICT DO UPDATE) a row
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
values: Dictionary of column names and values
|
|
492
|
+
conflict_on: Column name(s) or Column object(s) that define the conflict constraint
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
TSQL object representing the UPSERT query
|
|
496
|
+
"""
|
|
497
|
+
# Convert Column objects to strings
|
|
498
|
+
if isinstance(conflict_on, Column):
|
|
499
|
+
conflict_on = conflict_on.column_name
|
|
500
|
+
elif isinstance(conflict_on, list):
|
|
501
|
+
conflict_on = [col.column_name if isinstance(col, Column) else col for col in conflict_on]
|
|
502
|
+
|
|
503
|
+
return tsql_upsert(self.table_name, values, conflict_on=conflict_on)
|
|
504
|
+
|
|
505
|
+
def update(self, values: dict[str, Any]) -> UpdateBuilder:
|
|
506
|
+
"""Start building an UPDATE query
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
values: Dictionary of column names and values to update
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
UpdateBuilder for adding WHERE conditions
|
|
513
|
+
"""
|
|
514
|
+
return UpdateBuilder(self, values)
|
|
515
|
+
|
|
516
|
+
def delete(self) -> DeleteBuilder:
|
|
517
|
+
"""Start building a DELETE query
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
DeleteBuilder for adding WHERE conditions
|
|
521
|
+
"""
|
|
522
|
+
return DeleteBuilder(self)
|
|
523
|
+
|
|
377
524
|
cls.select = select
|
|
525
|
+
cls.insert = insert
|
|
526
|
+
cls.upsert = upsert
|
|
527
|
+
cls.update = update
|
|
528
|
+
cls.delete = delete
|
|
378
529
|
|
|
379
530
|
# Return an instance instead of the class
|
|
380
531
|
return cls()
|
|
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
|