t-sql 2.0.0__tar.gz → 2.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. t_sql-2.1.1/.beads/tsql.db +0 -0
  2. t_sql-2.1.1/.idea/.gitignore +8 -0
  3. t_sql-2.1.1/.idea/inspectionProfiles/Project_Default.xml +24 -0
  4. t_sql-2.1.1/.idea/inspectionProfiles/profiles_settings.xml +6 -0
  5. t_sql-2.1.1/.idea/misc.xml +11 -0
  6. t_sql-2.1.1/.idea/tsql.iml +10 -0
  7. t_sql-2.1.1/.idea/vcs.xml +6 -0
  8. t_sql-2.1.1/.idea/workspace.xml +124 -0
  9. {t_sql-2.0.0 → t_sql-2.1.1}/PKG-INFO +2 -1
  10. {t_sql-2.0.0 → t_sql-2.1.1}/context7.json +10 -0
  11. {t_sql-2.0.0 → t_sql-2.1.1}/pyproject.toml +4 -2
  12. t_sql-2.1.1/tests/test_alembic_integration.py +363 -0
  13. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_helper_functions.py +2 -8
  14. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_injection_edge_cases.py +153 -1
  15. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_mysql_integration.py +5 -10
  16. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_query_builder.py +331 -7
  17. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_sqlite_integration.py +6 -12
  18. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_styles.py +24 -0
  19. {t_sql-2.0.0 → t_sql-2.1.1}/tsql/__init__.py +29 -7
  20. {t_sql-2.0.0 → t_sql-2.1.1}/tsql/query_builder.py +102 -13
  21. {t_sql-2.0.0 → t_sql-2.1.1}/.dockerignore +0 -0
  22. {t_sql-2.0.0 → t_sql-2.1.1}/.github/workflows/publish.yml +0 -0
  23. {t_sql-2.0.0 → t_sql-2.1.1}/.github/workflows/test.yml +0 -0
  24. {t_sql-2.0.0 → t_sql-2.1.1}/.gitignore +0 -0
  25. {t_sql-2.0.0 → t_sql-2.1.1}/Dockerfile +0 -0
  26. {t_sql-2.0.0 → t_sql-2.1.1}/LICENSE +0 -0
  27. {t_sql-2.0.0 → t_sql-2.1.1}/README.md +0 -0
  28. {t_sql-2.0.0 → t_sql-2.1.1}/compose.yaml +0 -0
  29. {t_sql-2.0.0 → t_sql-2.1.1}/pytest.ini +0 -0
  30. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_asyncpg_integration.py +0 -0
  31. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_different_object_types.py +0 -0
  32. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_escaped.py +0 -0
  33. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_escaped_binary_hex.py +0 -0
  34. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_injection_protection_validation.py +0 -0
  35. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_injections_for_escaped.py +0 -0
  36. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_sqlalchemy_integration.py +0 -0
  37. {t_sql-2.0.0 → t_sql-2.1.1}/tests/test_tsql.py +0 -0
  38. {t_sql-2.0.0 → t_sql-2.1.1}/tsql/styles.py +0 -0
Binary file
@@ -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,124 @@
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$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
9
+ <change beforePath="$PROJECT_DIR$/tests/test_mysql_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_mysql_integration.py" afterDir="false" />
10
+ <change beforePath="$PROJECT_DIR$/tests/test_query_builder.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_query_builder.py" afterDir="false" />
11
+ <change beforePath="$PROJECT_DIR$/tests/test_sqlalchemy_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_sqlalchemy_integration.py" afterDir="false" />
12
+ <change beforePath="$PROJECT_DIR$/tests/test_sqlite_integration.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_sqlite_integration.py" afterDir="false" />
13
+ <change beforePath="$PROJECT_DIR$/tsql/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/tsql/__init__.py" afterDir="false" />
14
+ <change beforePath="$PROJECT_DIR$/tsql/query_builder.py" beforeDir="false" afterPath="$PROJECT_DIR$/tsql/query_builder.py" afterDir="false" />
15
+ </list>
16
+ <option name="SHOW_DIALOG" value="false" />
17
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
18
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
19
+ <option name="LAST_RESOLUTION" value="IGNORE" />
20
+ </component>
21
+ <component name="Git.Settings">
22
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
23
+ </component>
24
+ <component name="GitHubPullRequestSearchHistory">{
25
+ &quot;lastFilter&quot;: {
26
+ &quot;state&quot;: &quot;OPEN&quot;,
27
+ &quot;assignee&quot;: &quot;nhumrich&quot;
28
+ }
29
+ }</component>
30
+ <component name="GithubPullRequestsUISettings">{
31
+ &quot;selectedUrlAndAccountId&quot;: {
32
+ &quot;url&quot;: &quot;git@github.com:nhumrich/tsql.git&quot;,
33
+ &quot;accountId&quot;: &quot;f308fc0d-c429-47fb-8e52-74bdc95408d8&quot;
34
+ }
35
+ }</component>
36
+ <component name="ProjectColorInfo">{
37
+ &quot;associatedIndex&quot;: 2
38
+ }</component>
39
+ <component name="ProjectId" id="33DagtaPqCq8RWQhigWGQcgOtLh" />
40
+ <component name="ProjectViewState">
41
+ <option name="compactDirectories" value="true" />
42
+ <option name="hideEmptyMiddlePackages" value="true" />
43
+ <option name="showLibraryContents" value="true" />
44
+ </component>
45
+ <component name="PropertiesComponent">{
46
+ &quot;keyToString&quot;: {
47
+ &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
48
+ &quot;Python tests.pytest in test_sqlalchemy_integration.py.executor&quot;: &quot;Debug&quot;,
49
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
50
+ &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
51
+ &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
52
+ &quot;git-widget-placeholder&quot;: &quot;main&quot;,
53
+ &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
54
+ &quot;last_opened_file_path&quot;: &quot;/home/nhumrich/personal/tsql&quot;,
55
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
56
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
57
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
58
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
59
+ &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
60
+ &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
61
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
62
+ }
63
+ }</component>
64
+ <component name="RunManager">
65
+ <configuration name="pytest in test_sqlalchemy_integration.py" type="tests" factoryName="py.test" temporary="true" nameIsGenerated="true">
66
+ <module name="tsql" />
67
+ <option name="ENV_FILES" value="" />
68
+ <option name="INTERPRETER_OPTIONS" value="" />
69
+ <option name="PARENT_ENVS" value="true" />
70
+ <option name="SDK_HOME" value="" />
71
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
72
+ <option name="IS_MODULE_SDK" value="true" />
73
+ <option name="ADD_CONTENT_ROOTS" value="true" />
74
+ <option name="ADD_SOURCE_ROOTS" value="true" />
75
+ <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
76
+ <option name="_new_keywords" value="&quot;&quot;" />
77
+ <option name="_new_parameters" value="&quot;&quot;" />
78
+ <option name="_new_additionalArguments" value="&quot;&quot;" />
79
+ <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_sqlalchemy_integration.py&quot;" />
80
+ <option name="_new_targetType" value="&quot;PATH&quot;" />
81
+ <method v="2" />
82
+ </configuration>
83
+ <recent_temporary>
84
+ <list>
85
+ <item itemvalue="Python tests.pytest in test_sqlalchemy_integration.py" />
86
+ </list>
87
+ </recent_temporary>
88
+ </component>
89
+ <component name="SharedIndexes">
90
+ <attachedChunks>
91
+ <set>
92
+ <option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-PY-252.26830.99" />
93
+ <option value="bundled-python-sdk-164cda30dcd9-0af03a5fa574-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.26830.99" />
94
+ </set>
95
+ </attachedChunks>
96
+ </component>
97
+ <component name="TaskManager">
98
+ <task active="true" id="Default" summary="Default task">
99
+ <changelist id="059146b3-62bd-4ad4-890d-4356cf237b89" name="Changes" comment="" />
100
+ <created>1758854212149</created>
101
+ <option name="number" value="Default" />
102
+ <option name="presentableId" value="Default" />
103
+ <updated>1758854212149</updated>
104
+ </task>
105
+ <servers />
106
+ </component>
107
+ <component name="TypeScriptGeneratedFilesManager">
108
+ <option name="version" value="3" />
109
+ </component>
110
+ <component name="Vcs.Log.Tabs.Properties">
111
+ <option name="TAB_STATES">
112
+ <map>
113
+ <entry key="MAIN">
114
+ <value>
115
+ <State />
116
+ </value>
117
+ </entry>
118
+ </map>
119
+ </option>
120
+ </component>
121
+ <component name="com.intellij.coverage.CoverageDataManagerImpl">
122
+ <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$" />
123
+ </component>
124
+ </project>
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 2.0.0
3
+ Version: 2.1.1
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
7
7
  Requires-Python: >=3.14
8
+ Requires-Dist: alembic>=1.17.0
8
9
  Description-Content-Type: text/markdown
9
10
 
10
11
  # t-sql
@@ -1,4 +1,8 @@
1
1
  {
2
+ "$schema": "https://context7.com/schema/context7.json",
3
+ "projectTitle": "t-sql",
4
+ "description": "t-sql is a lightweight SQL templating library for Python 3.14+ that leverages t-strings to safely build SQL queries, preventing injection attacks, and includes a query builder and SQLAlchemy integration.",
5
+ "branch": "main",
2
6
  "excludeFolders": [
3
7
  "tests",
4
8
  ".github",
@@ -22,5 +26,11 @@
22
26
  "When mixing query builder with t-strings using .where(), t-string conditions are automatically wrapped in parentheses for proper operator precedence.",
23
27
  "The @table decorator returns an instance - use the decorated class directly, don't instantiate it.",
24
28
  "Default parameter style is QMARK (?). Use tsql.styles.NUMERIC_DOLLAR for PostgreSQL ($1, $2), or other styles as needed."
29
+ ],
30
+ "previousVersions": [
31
+ {
32
+ "tag": "v1_2_1",
33
+ "title": "v1.2.1"
34
+ }
25
35
  ]
26
36
  }
@@ -4,11 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "2.0.0"
7
+ version = "2.1.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"
11
- dependencies = []
11
+ dependencies = [
12
+ "alembic>=1.17.0",
13
+ ]
12
14
 
13
15
  [project.urls]
14
16
  Homepage = "https://github.com/nhumrich/t-sql"
@@ -0,0 +1,363 @@
1
+ import tempfile
2
+ import shutil
3
+ from pathlib import Path
4
+ from textwrap import dedent
5
+ import re
6
+
7
+ import pytest
8
+ from sqlalchemy import MetaData, Column, String, Integer, Boolean, ForeignKey, TIMESTAMP, create_engine
9
+ from sqlalchemy.dialects.postgresql import JSONB
10
+ from alembic.config import Config
11
+ from alembic.script import ScriptDirectory
12
+ from alembic.runtime.migration import MigrationContext
13
+ from alembic.autogenerate import compare_metadata
14
+
15
+ from tsql.query_builder import Table
16
+
17
+
18
+ @pytest.fixture
19
+ def temp_alembic_env():
20
+ temp_dir = tempfile.mkdtemp()
21
+ alembic_dir = Path(temp_dir) / "alembic"
22
+ alembic_dir.mkdir()
23
+ versions_dir = alembic_dir / "versions"
24
+ versions_dir.mkdir()
25
+
26
+ env_py = alembic_dir / "env.py"
27
+ env_py.write_text(dedent("""
28
+ from alembic import context
29
+ from sqlalchemy import engine_from_config, pool
30
+
31
+ config = context.config
32
+ target_metadata = config.attributes.get('target_metadata', None)
33
+
34
+ def run_migrations_offline():
35
+ context.configure(
36
+ url=config.get_main_option("sqlalchemy.url"),
37
+ target_metadata=target_metadata,
38
+ literal_binds=True,
39
+ dialect_opts={"paramstyle": "named"},
40
+ )
41
+ with context.begin_transaction():
42
+ context.run_migrations()
43
+
44
+ def run_migrations_online():
45
+ connectable = config.attributes.get('connection', None)
46
+ if connectable is None:
47
+ connectable = engine_from_config(
48
+ config.get_section(config.config_ini_section),
49
+ prefix="sqlalchemy.",
50
+ poolclass=pool.NullPool,
51
+ )
52
+
53
+ with connectable.connect() as connection:
54
+ context.configure(
55
+ connection=connection,
56
+ target_metadata=target_metadata
57
+ )
58
+ with context.begin_transaction():
59
+ context.run_migrations()
60
+
61
+ if context.is_offline_mode():
62
+ run_migrations_offline()
63
+ else:
64
+ run_migrations_online()
65
+ """))
66
+
67
+ script_py = alembic_dir / "script.py.mako"
68
+ script_py.write_text(dedent('''
69
+ """${message}"""
70
+ from alembic import op
71
+ import sqlalchemy as sa
72
+ ${imports if imports else ""}
73
+
74
+ revision = ${repr(up_revision)}
75
+ down_revision = ${repr(down_revision)}
76
+ branch_labels = ${repr(branch_labels)}
77
+ depends_on = ${repr(depends_on)}
78
+
79
+ def upgrade():
80
+ ${upgrades if upgrades else "pass"}
81
+
82
+ def downgrade():
83
+ ${downgrades if downgrades else "pass"}
84
+ '''))
85
+
86
+ alembic_ini = Path(temp_dir) / "alembic.ini"
87
+ alembic_ini.write_text(dedent(f"""
88
+ [alembic]
89
+ script_location = {alembic_dir}
90
+ sqlalchemy.url = sqlite:///:memory:
91
+
92
+ [loggers]
93
+ keys = root
94
+
95
+ [handlers]
96
+ keys = console
97
+
98
+ [formatters]
99
+ keys = generic
100
+
101
+ [logger_root]
102
+ level = WARN
103
+ handlers = console
104
+
105
+ [handler_console]
106
+ class = StreamHandler
107
+ args = (sys.stderr,)
108
+ level = NOTSET
109
+ formatter = generic
110
+
111
+ [formatter_generic]
112
+ format = %(levelname)-5.5s [%(name)s] %(message)s
113
+ """))
114
+
115
+ yield temp_dir, alembic_ini
116
+
117
+ shutil.rmtree(temp_dir)
118
+
119
+
120
+ def test_alembic_detects_new_table_with_annotations(temp_alembic_env):
121
+ temp_dir, alembic_ini = temp_alembic_env
122
+ metadata = MetaData()
123
+ engine = create_engine("sqlite:///:memory:")
124
+
125
+ class Users(Table, table_name='users', metadata=metadata):
126
+ id: int
127
+ name: str
128
+ email: str
129
+ age: int
130
+
131
+ cfg = Config(str(alembic_ini))
132
+ cfg.attributes['target_metadata'] = metadata
133
+ cfg.attributes['connection'] = engine
134
+
135
+ with engine.begin() as connection:
136
+ mc = MigrationContext.configure(connection)
137
+ diff = compare_metadata(mc, metadata)
138
+
139
+ assert len(diff) > 0
140
+
141
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
142
+ assert len(add_table_ops) == 1
143
+
144
+ table = add_table_ops[0][1]
145
+ assert table.name == 'users'
146
+ assert 'id' in [c.name for c in table.columns]
147
+ assert 'name' in [c.name for c in table.columns]
148
+ assert 'email' in [c.name for c in table.columns]
149
+ assert 'age' in [c.name for c in table.columns]
150
+
151
+
152
+ def test_alembic_detects_new_table_with_sa_columns(temp_alembic_env):
153
+ temp_dir, alembic_ini = temp_alembic_env
154
+ metadata = MetaData()
155
+ engine = create_engine("sqlite:///:memory:")
156
+
157
+ class Posts(Table, table_name='posts', metadata=metadata):
158
+ id = Column(String, primary_key=True)
159
+ title = Column(String(255), nullable=False)
160
+ content = Column(String)
161
+ published = Column(Boolean, server_default='false')
162
+
163
+ cfg = Config(str(alembic_ini))
164
+ cfg.attributes['target_metadata'] = metadata
165
+ cfg.attributes['connection'] = engine
166
+
167
+ with engine.begin() as connection:
168
+ mc = MigrationContext.configure(connection)
169
+ diff = compare_metadata(mc, metadata)
170
+
171
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
172
+ assert len(add_table_ops) == 1
173
+
174
+ table = add_table_ops[0][1]
175
+ assert table.name == 'posts'
176
+
177
+ id_col = next(c for c in table.columns if c.name == 'id')
178
+ assert id_col.primary_key
179
+
180
+ title_col = next(c for c in table.columns if c.name == 'title')
181
+ assert not title_col.nullable
182
+
183
+
184
+ def test_alembic_detects_mixed_table_definition(temp_alembic_env):
185
+ temp_dir, alembic_ini = temp_alembic_env
186
+ metadata = MetaData()
187
+ engine = create_engine("sqlite:///:memory:")
188
+
189
+ class Users(Table, table_name='users', metadata=metadata):
190
+ id = Column(String, primary_key=True)
191
+
192
+ class Comments(Table, table_name='comments', metadata=metadata):
193
+ id = Column(String, primary_key=True)
194
+ post_id: str
195
+ user_id = Column(String, ForeignKey('users.id'))
196
+ content: str
197
+ upvotes: int
198
+ created_at = Column(TIMESTAMP, nullable=False)
199
+
200
+ cfg = Config(str(alembic_ini))
201
+ cfg.attributes['target_metadata'] = metadata
202
+ cfg.attributes['connection'] = engine
203
+
204
+ with engine.begin() as connection:
205
+ mc = MigrationContext.configure(connection)
206
+ diff = compare_metadata(mc, metadata)
207
+
208
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
209
+ assert len(add_table_ops) == 2
210
+
211
+ table = next(op[1] for op in add_table_ops if op[1].name == 'comments')
212
+ assert table.name == 'comments'
213
+
214
+ column_names = [c.name for c in table.columns]
215
+ assert 'id' in column_names
216
+ assert 'post_id' in column_names
217
+ assert 'user_id' in column_names
218
+ assert 'content' in column_names
219
+ assert 'upvotes' in column_names
220
+ assert 'created_at' in column_names
221
+
222
+ user_id_col = next(c for c in table.columns if c.name == 'user_id')
223
+ assert len(list(user_id_col.foreign_keys)) == 1
224
+
225
+
226
+ def test_alembic_detects_column_additions(temp_alembic_env):
227
+ temp_dir, alembic_ini = temp_alembic_env
228
+ metadata = MetaData()
229
+ engine = create_engine("sqlite:///:memory:")
230
+
231
+ with engine.begin() as connection:
232
+ connection.execute(sa_text("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"))
233
+
234
+ class Users(Table, table_name='users', metadata=metadata):
235
+ id: int
236
+ name: str
237
+ email: str
238
+ age: int
239
+
240
+ cfg = Config(str(alembic_ini))
241
+ cfg.attributes['target_metadata'] = metadata
242
+ cfg.attributes['connection'] = engine
243
+
244
+ with engine.begin() as connection:
245
+ mc = MigrationContext.configure(connection)
246
+ diff = compare_metadata(mc, metadata)
247
+
248
+ add_column_ops = [op for op in diff if op[0] == 'add_column']
249
+ assert len(add_column_ops) == 2
250
+
251
+ added_column_names = [op[3].name for op in add_column_ops]
252
+ assert 'email' in added_column_names
253
+ assert 'age' in added_column_names
254
+
255
+
256
+ def test_alembic_detects_column_removals(temp_alembic_env):
257
+ temp_dir, alembic_ini = temp_alembic_env
258
+ metadata = MetaData()
259
+ engine = create_engine("sqlite:///:memory:")
260
+
261
+ with engine.begin() as connection:
262
+ connection.execute(sa_text("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, old_field TEXT, deprecated TEXT)"))
263
+
264
+ class Users(Table, table_name='users', metadata=metadata):
265
+ id: int
266
+ name: str
267
+
268
+ cfg = Config(str(alembic_ini))
269
+ cfg.attributes['target_metadata'] = metadata
270
+ cfg.attributes['connection'] = engine
271
+
272
+ with engine.begin() as connection:
273
+ mc = MigrationContext.configure(connection)
274
+ diff = compare_metadata(mc, metadata)
275
+
276
+ remove_column_ops = [op for op in diff if op[0] == 'remove_column']
277
+ assert len(remove_column_ops) == 2
278
+
279
+ removed_column_names = [op[3].name for op in remove_column_ops]
280
+ assert 'old_field' in removed_column_names
281
+ assert 'deprecated' in removed_column_names
282
+
283
+
284
+ def test_alembic_no_spurious_changes_for_identical_schema(temp_alembic_env):
285
+ temp_dir, alembic_ini = temp_alembic_env
286
+ metadata = MetaData()
287
+ engine = create_engine("sqlite:///:memory:")
288
+
289
+ class Users(Table, table_name='users', metadata=metadata):
290
+ id = Column(Integer, primary_key=True)
291
+ name = Column(String)
292
+ email = Column(String)
293
+
294
+ metadata.create_all(engine)
295
+
296
+ cfg = Config(str(alembic_ini))
297
+ cfg.attributes['target_metadata'] = metadata
298
+ cfg.attributes['connection'] = engine
299
+
300
+ with engine.begin() as connection:
301
+ mc = MigrationContext.configure(connection)
302
+ diff = compare_metadata(mc, metadata)
303
+
304
+ assert len(diff) == 0
305
+
306
+
307
+ def test_alembic_handles_foreign_keys_correctly(temp_alembic_env):
308
+ temp_dir, alembic_ini = temp_alembic_env
309
+ metadata = MetaData()
310
+ engine = create_engine("sqlite:///:memory:")
311
+
312
+ class Users(Table, table_name='users', metadata=metadata):
313
+ id = Column(String, primary_key=True)
314
+
315
+ class Posts(Table, table_name='posts', metadata=metadata):
316
+ id = Column(String, primary_key=True)
317
+ user_id = Column(String, ForeignKey('users.id', ondelete='CASCADE'))
318
+ title: str
319
+
320
+ cfg = Config(str(alembic_ini))
321
+ cfg.attributes['target_metadata'] = metadata
322
+ cfg.attributes['connection'] = engine
323
+
324
+ with engine.begin() as connection:
325
+ mc = MigrationContext.configure(connection)
326
+ diff = compare_metadata(mc, metadata)
327
+
328
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
329
+ assert len(add_table_ops) == 2
330
+
331
+ posts_table = next(op[1] for op in add_table_ops if op[1].name == 'posts')
332
+ user_id_col = next(c for c in posts_table.columns if c.name == 'user_id')
333
+
334
+ fks = list(user_id_col.foreign_keys)
335
+ assert len(fks) == 1
336
+ assert fks[0].column.table.name == 'users'
337
+
338
+
339
+ def test_alembic_with_schema_parameter(temp_alembic_env):
340
+ temp_dir, alembic_ini = temp_alembic_env
341
+ metadata = MetaData()
342
+ engine = create_engine("sqlite:///:memory:")
343
+
344
+ class Users(Table, table_name='users', metadata=metadata, schema='public'):
345
+ id: int
346
+ name: str
347
+
348
+ cfg = Config(str(alembic_ini))
349
+ cfg.attributes['target_metadata'] = metadata
350
+ cfg.attributes['connection'] = engine
351
+
352
+ with engine.begin() as connection:
353
+ mc = MigrationContext.configure(connection)
354
+ diff = compare_metadata(mc, metadata)
355
+
356
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
357
+ assert len(add_table_ops) == 1
358
+
359
+ table = add_table_ops[0][1]
360
+ assert table.schema == 'public'
361
+
362
+
363
+ from sqlalchemy import text as sa_text
@@ -47,15 +47,9 @@ def test_as_set():
47
47
 
48
48
  def test_insert():
49
49
  """Test the insert helper function"""
50
- values = {
51
- 'name': 'Alice',
52
- 'age': 25,
53
- 'active': True
54
- }
55
-
56
- query = tsql.insert('users', values)
50
+ query = tsql.insert('users', name='Alice', age=25, active=True)
57
51
  result = tsql.render(query)
58
-
52
+
59
53
  assert "INSERT INTO users" in result[0]
60
54
  assert "name" in result[0] and "age" in result[0] and "active" in result[0]
61
55
  assert "VALUES" in result[0]