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.
Files changed (34) hide show
  1. {t_sql-1.1.0 → t_sql-1.2.1}/.github/workflows/test.yml +17 -0
  2. t_sql-1.2.1/.idea/.gitignore +8 -0
  3. t_sql-1.2.1/.idea/inspectionProfiles/Project_Default.xml +24 -0
  4. t_sql-1.2.1/.idea/inspectionProfiles/profiles_settings.xml +6 -0
  5. t_sql-1.2.1/.idea/misc.xml +11 -0
  6. t_sql-1.2.1/.idea/tsql.iml +10 -0
  7. t_sql-1.2.1/.idea/vcs.xml +6 -0
  8. t_sql-1.2.1/.idea/workspace.xml +118 -0
  9. {t_sql-1.1.0 → t_sql-1.2.1}/PKG-INFO +68 -2
  10. {t_sql-1.1.0 → t_sql-1.2.1}/README.md +67 -1
  11. t_sql-1.2.1/context7.json +26 -0
  12. {t_sql-1.1.0 → t_sql-1.2.1}/pyproject.toml +1 -1
  13. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_asyncpg_integration.py +94 -2
  14. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_helper_functions.py +76 -1
  15. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injection_protection_validation.py +2 -1
  16. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injections_for_escaped.py +2 -1
  17. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_query_builder.py +143 -1
  18. {t_sql-1.1.0 → t_sql-1.2.1}/tsql/__init__.py +69 -2
  19. {t_sql-1.1.0 → t_sql-1.2.1}/tsql/query_builder.py +152 -1
  20. {t_sql-1.1.0 → t_sql-1.2.1}/.dockerignore +0 -0
  21. {t_sql-1.1.0 → t_sql-1.2.1}/.github/workflows/publish.yml +0 -0
  22. {t_sql-1.1.0 → t_sql-1.2.1}/.gitignore +0 -0
  23. {t_sql-1.1.0 → t_sql-1.2.1}/Dockerfile +0 -0
  24. {t_sql-1.1.0 → t_sql-1.2.1}/LICENSE +0 -0
  25. {t_sql-1.1.0 → t_sql-1.2.1}/compose.yaml +0 -0
  26. {t_sql-1.1.0 → t_sql-1.2.1}/pytest.ini +0 -0
  27. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_different_object_types.py +0 -0
  28. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_escaped.py +0 -0
  29. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_escaped_binary_hex.py +0 -0
  30. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_injection_edge_cases.py +0 -0
  31. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_sqlalchemy_integration.py +0 -0
  32. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_styles.py +0 -0
  33. {t_sql-1.1.0 → t_sql-1.2.1}/tests/test_tsql.py +0 -0
  34. {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,8 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
@@ -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,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </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,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -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
+ &quot;lastFilter&quot;: {
20
+ &quot;state&quot;: &quot;OPEN&quot;,
21
+ &quot;assignee&quot;: &quot;nhumrich&quot;
22
+ }
23
+ }</component>
24
+ <component name="GithubPullRequestsUISettings">{
25
+ &quot;selectedUrlAndAccountId&quot;: {
26
+ &quot;url&quot;: &quot;git@github.com:nhumrich/tsql.git&quot;,
27
+ &quot;accountId&quot;: &quot;f308fc0d-c429-47fb-8e52-74bdc95408d8&quot;
28
+ }
29
+ }</component>
30
+ <component name="ProjectColorInfo">{
31
+ &quot;associatedIndex&quot;: 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
+ &quot;keyToString&quot;: {
41
+ &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
42
+ &quot;Python tests.pytest in test_sqlalchemy_integration.py.executor&quot;: &quot;Debug&quot;,
43
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
44
+ &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
45
+ &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
46
+ &quot;git-widget-placeholder&quot;: &quot;main&quot;,
47
+ &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
48
+ &quot;last_opened_file_path&quot;: &quot;/home/nhumrich/personal/tsql&quot;,
49
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
50
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
51
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
52
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
53
+ &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
54
+ &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
55
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
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="&quot;&quot;" />
71
+ <option name="_new_parameters" value="&quot;&quot;" />
72
+ <option name="_new_additionalArguments" value="&quot;&quot;" />
73
+ <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_sqlalchemy_integration.py&quot;" />
74
+ <option name="_new_targetType" value="&quot;PATH&quot;" />
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.0
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
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "1.1.0"
7
+ version = "1.2.1"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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) or not val.isidentifier():
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