pgdevkit 0.2.4__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. pgdevkit-0.3.0/.github/workflows/python-test.yml +56 -0
  2. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/PKG-INFO +57 -5
  3. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/README.md +54 -4
  4. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/docs/database-layout.md +15 -0
  5. pgdevkit-0.3.0/pgdevkit/backends/__init__.py +21 -0
  6. pgdevkit-0.3.0/pgdevkit/backends/base.py +27 -0
  7. pgdevkit-0.3.0/pgdevkit/backends/mssql.py +27 -0
  8. pgdevkit-0.3.0/pgdevkit/backends/postgres.py +19 -0
  9. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/cli.py +16 -7
  10. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/__init__.py +2 -1
  11. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/model.py +9 -2
  12. pgdevkit-0.3.0/pgdevkit/db/mssql_crud.py +290 -0
  13. pgdevkit-0.3.0/pgdevkit/db/mssql_sql.py +48 -0
  14. pgdevkit-0.3.0/pgdevkit/dialect.py +93 -0
  15. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/diff.py +25 -38
  16. pgdevkit-0.3.0/pgdevkit/mssql_introspect.py +275 -0
  17. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/parser.py +103 -42
  18. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/__init__.py +2 -2
  19. pgdevkit-0.3.0/pgdevkit/testdb/_docker.py +41 -0
  20. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/api.py +36 -4
  21. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/config.py +13 -0
  22. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/container.py +1 -34
  23. pgdevkit-0.3.0/pgdevkit/testdb/mssql/__init__.py +1 -0
  24. pgdevkit-0.3.0/pgdevkit/testdb/mssql/api.py +216 -0
  25. pgdevkit-0.3.0/pgdevkit/testdb/mssql/constants.py +60 -0
  26. pgdevkit-0.3.0/pgdevkit/testdb/mssql/container.py +90 -0
  27. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/query.py +38 -0
  28. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/schema.py +25 -7
  29. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pyproject.toml +3 -1
  30. pgdevkit-0.3.0/tests/db/test_mssql_crud_live.py +106 -0
  31. pgdevkit-0.3.0/tests/db/test_mssql_crud_sql.py +163 -0
  32. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_cli_compare.py +2 -2
  33. pgdevkit-0.3.0/tests/test_compare_mssql_live.py +39 -0
  34. pgdevkit-0.3.0/tests/test_dialect.py +44 -0
  35. pgdevkit-0.3.0/tests/test_diff_mssql.py +60 -0
  36. pgdevkit-0.3.0/tests/test_mssql_introspect.py +18 -0
  37. pgdevkit-0.3.0/tests/test_parser_mssql.py +107 -0
  38. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/conftest.py +22 -6
  39. pgdevkit-0.3.0/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +13 -0
  40. pgdevkit-0.3.0/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +1 -0
  41. pgdevkit-0.3.0/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +2 -0
  42. pgdevkit-0.3.0/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +2 -0
  43. pgdevkit-0.3.0/tests/testdb/fixtures/database_mssql/schema/app.sql +4 -0
  44. pgdevkit-0.3.0/tests/testdb/test_api_mssql.py +58 -0
  45. pgdevkit-0.3.0/tests/testdb/test_api_mssql_live.py +66 -0
  46. pgdevkit-0.3.0/tests/testdb/test_config_mssql.py +43 -0
  47. pgdevkit-0.3.0/tests/testdb/test_mssql_constants.py +31 -0
  48. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_query.py +27 -1
  49. pgdevkit-0.3.0/tests/testdb/test_schema_mssql.py +35 -0
  50. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/uv.lock +38 -2
  51. pgdevkit-0.2.4/.github/workflows/python-test.yml +0 -34
  52. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/.github/workflows/python-publish.yml +0 -0
  53. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/.gitignore +0 -0
  54. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/.python-version +0 -0
  55. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/__init__.py +0 -0
  56. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/connection.py +0 -0
  57. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/complex_types.py +0 -0
  58. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/connection.py +0 -0
  59. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/crud.py +0 -0
  60. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/db/loader.py +0 -0
  61. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/fetch_missing.py +0 -0
  62. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/introspect.py +0 -0
  63. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/lakebase.py +0 -0
  64. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/models.py +0 -0
  65. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/constants.py +0 -0
  66. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/pgdevkit/testdb/naming.py +0 -0
  67. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/skills/pgdevkit/SKILL.md +0 -0
  68. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  69. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  70. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/__init__.py +0 -0
  71. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/conftest.py +0 -0
  72. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/db/__init__.py +0 -0
  73. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/db/test_complex_types.py +0 -0
  74. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/db/test_connection.py +0 -0
  75. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/db/test_crud.py +0 -0
  76. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/db/test_loader.py +0 -0
  77. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/01_schema.sql +0 -0
  78. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/02_types.sql +0 -0
  79. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/03_tables.sql +0 -0
  80. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/04_views.sql +0 -0
  81. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/05_functions.sql +0 -0
  82. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/fixtures/06_indexes.sql +0 -0
  83. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_compare.py +0 -0
  84. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_connection.py +0 -0
  85. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_fetch_missing.py +0 -0
  86. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_fetch_missing_cli.py +0 -0
  87. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/test_lakebase.py +0 -0
  88. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/__init__.py +0 -0
  89. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  90. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  91. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  92. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  93. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  94. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  95. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  96. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  97. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  98. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  99. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  100. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  101. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_api.py +0 -0
  102. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_cli.py +0 -0
  103. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_config.py +0 -0
  104. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_constants.py +0 -0
  105. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_container.py +0 -0
  106. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_naming.py +0 -0
  107. {pgdevkit-0.2.4 → pgdevkit-0.3.0}/tests/testdb/test_schema.py +0 -0
@@ -0,0 +1,56 @@
1
+ name: Python Test
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ paths-ignore: ["README.md", "docs", ".github"]
7
+ pull_request:
8
+ branches: ["main"]
9
+ paths-ignore: ["README.md", "docs", ".github"]
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.14"]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ submodules: "recursive"
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+ - name: Install uv
28
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
29
+ - name: Install project dependencies
30
+ run: uv sync --all-extras --all-groups
31
+ - name: ty check
32
+ run: uv run ty check pgdevkit
33
+ - name: Test with pytest
34
+ run: uv run -m pytest --capture=tee-sys --maxfail=3 -m "not mssql" tests
35
+
36
+ mssql-test:
37
+ # Separate job (not folded into `build`) so a live SQL Server -- a much
38
+ # larger image and slower cold start than the Postgres container --
39
+ # never slows down or blocks the fast, always-run Postgres suite above.
40
+ # mssql-python bundles its own ODBC driver, so unlike pyodbc this needs
41
+ # no system driver package install here.
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ with:
46
+ submodules: "recursive"
47
+ - name: Set up Python 3.14
48
+ uses: actions/setup-python@v5
49
+ with:
50
+ python-version: "3.14"
51
+ - name: Install uv
52
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
53
+ - name: Install project dependencies
54
+ run: uv sync --all-extras --all-groups
55
+ - name: Run MSSQL-only tests
56
+ run: uv run -m pytest --capture=tee-sys --maxfail=3 -m mssql tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgdevkit
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -14,6 +14,8 @@ Requires-Dist: typer>=0.26.7; extra == 'cli'
14
14
  Provides-Extra: db
15
15
  Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
16
16
  Requires-Dist: pydantic>=2.0; extra == 'db'
17
+ Provides-Extra: mssql
18
+ Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  # pgdevkit
@@ -56,6 +58,26 @@ pgdb compare --url postgresql://instance-abc.database.azuredatabricks.net:5432/d
56
58
  (`--url`'s own user/password, if any, are discarded and replaced — `--entra-user`
57
59
  plus the fetched token become the connection's actual credentials.)
58
60
 
61
+ ### MSSQL
62
+
63
+ `pgdb compare`/`pgdb fetch-missing` default to Postgres. Pass `--dialect mssql`
64
+ to compare against a SQL Server database instead:
65
+
66
+ ```bash
67
+ pgdb compare --dialect mssql --url "Server=host,1433;Database=db;UID=user;PWD=pass" path/to/database/
68
+ ```
69
+
70
+ Requires the `mssql` extra: `pip install pgdevkit[mssql]` (pulls in
71
+ [mssql-python](https://github.com/microsoft/mssql-python), which bundles its
72
+ own driver — no system ODBC driver install needed). MSSQL has no composite
73
+ type or native enum equivalent, so those areas of a `database/` tree don't
74
+ have a direct equivalent on this backend — see `docs/database-layout.md`.
75
+ Current Azure SQL/SQL Server (2025+) does have a native `json` column type,
76
+ which parses/introspects/diffs like any other column type; see
77
+ "`pgdevkit.db` — helpers for application code" below for how JSON values are
78
+ handled on the CRUD side (write-side serialization only, no auto-parsing on
79
+ read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
80
+
59
81
  ## `pgdb testdb`
60
82
 
61
83
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -107,12 +129,29 @@ The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
107
129
  (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
108
130
  auth for local connections (Debian/Ubuntu Postgres ships this by default).
109
131
 
132
+ ### MSSQL
133
+
134
+ Add `engine = "mssql"` to `[tool.pgdevkit]` (or set
135
+ `PGDEVKIT_TESTDB_ENGINE=mssql` for a one-off run) to manage a shared SQL
136
+ Server container instead of Postgres — same one-container-per-machine,
137
+ one-database-per-workspace model. Requires the `mssql` extra (see above).
138
+
139
+ Container defaults (`localhost:14330`, `sa`/a generated complexity-valid
140
+ password) can be overridden with `PGDEVKIT_TESTDB_MSSQL_HOST`, `_PORT`,
141
+ `_USER`, `_PASSWORD`, `_IMAGE`, `_MEMORY_LIMIT_MB`. The container only
142
+ bootstraps the `sa` login — additional logins are a known limitation.
143
+ `pgdb testdb shell` execs into
144
+ [`sqlcmd`](https://github.com/microsoft/go-sqlcmd) (an external prerequisite,
145
+ the same category as `psql` for the Postgres path) rather than a Python
146
+ REPL.
147
+
110
148
  ## `pgdevkit.db` — helpers for application code
111
149
 
112
150
  Install with the `db` extra: `pip install pgdevkit[db]`.
113
151
 
114
- - **`PostgresTableModel`** a `pydantic.BaseModel` base class for models
115
- that map 1:1 to a table row. Implement `get_table_name()` (returns
152
+ - **`TableModel`** (formerly `PostgresTableModel`, still importable under
153
+ that name) a `pydantic.BaseModel` base class for models that map 1:1 to
154
+ a table row, for either engine. Implement `get_table_name()` (returns
116
155
  `(schema, table)`) and `get_primary_key()` on each model.
117
156
  - **`PgPool`** — an async connection pool keyed off
118
157
  `{env_prefix}HOST/PORT/DB/USER/PASSWORD` env vars. Call `await pool.open()`
@@ -125,8 +164,21 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
125
164
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
126
165
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
127
166
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
128
- `pg_delete_dict` — typed (`PostgresTableModel`-based) or dict-based CRUD
129
- against a table, built on `psycopg` for safe identifier/value handling.
167
+ `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
168
+ table, built on `psycopg` for safe identifier/value handling. The `mssql`
169
+ extra provides an `mssql_*`-prefixed mirror of the same functions in
170
+ `pgdevkit.db.mssql_crud`, built on `mssql-python` (`MERGE`-based upsert,
171
+ `OUTPUT` instead of `RETURNING`) — MSSQL has no composite/enum equivalent,
172
+ so `complex_helper` is always `None` on that path. It does have a native
173
+ `json` column type on current versions (and the older
174
+ `NVARCHAR(MAX)`-plus-`OPENJSON()` convention works on any version), but
175
+ `mssql-python` has no auto-serialization for dict/list parameter values
176
+ (binding one raises `TypeError`) and no way to distinguish a `json`
177
+ column from `nvarchar` on fetch — so every `mssql_*` write function
178
+ serializes dict/list values to JSON text automatically
179
+ (`db.mssql_sql.json_encode_value`), while reads always come back as plain
180
+ `str`; deserialize with `json.loads()` yourself if you need the parsed
181
+ value back.
130
182
  - **`SqlLoader`** — loads and caches `.sql` files from
131
183
  `{root}/<topic>/<name>.sql`, for keeping hand-written queries out of
132
184
  Python source.
@@ -38,6 +38,26 @@ pgdb compare --url postgresql://instance-abc.database.azuredatabricks.net:5432/d
38
38
  (`--url`'s own user/password, if any, are discarded and replaced — `--entra-user`
39
39
  plus the fetched token become the connection's actual credentials.)
40
40
 
41
+ ### MSSQL
42
+
43
+ `pgdb compare`/`pgdb fetch-missing` default to Postgres. Pass `--dialect mssql`
44
+ to compare against a SQL Server database instead:
45
+
46
+ ```bash
47
+ pgdb compare --dialect mssql --url "Server=host,1433;Database=db;UID=user;PWD=pass" path/to/database/
48
+ ```
49
+
50
+ Requires the `mssql` extra: `pip install pgdevkit[mssql]` (pulls in
51
+ [mssql-python](https://github.com/microsoft/mssql-python), which bundles its
52
+ own driver — no system ODBC driver install needed). MSSQL has no composite
53
+ type or native enum equivalent, so those areas of a `database/` tree don't
54
+ have a direct equivalent on this backend — see `docs/database-layout.md`.
55
+ Current Azure SQL/SQL Server (2025+) does have a native `json` column type,
56
+ which parses/introspects/diffs like any other column type; see
57
+ "`pgdevkit.db` — helpers for application code" below for how JSON values are
58
+ handled on the CRUD side (write-side serialization only, no auto-parsing on
59
+ read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
60
+
41
61
  ## `pgdb testdb`
42
62
 
43
63
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -89,12 +109,29 @@ The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
89
109
  (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
90
110
  auth for local connections (Debian/Ubuntu Postgres ships this by default).
91
111
 
112
+ ### MSSQL
113
+
114
+ Add `engine = "mssql"` to `[tool.pgdevkit]` (or set
115
+ `PGDEVKIT_TESTDB_ENGINE=mssql` for a one-off run) to manage a shared SQL
116
+ Server container instead of Postgres — same one-container-per-machine,
117
+ one-database-per-workspace model. Requires the `mssql` extra (see above).
118
+
119
+ Container defaults (`localhost:14330`, `sa`/a generated complexity-valid
120
+ password) can be overridden with `PGDEVKIT_TESTDB_MSSQL_HOST`, `_PORT`,
121
+ `_USER`, `_PASSWORD`, `_IMAGE`, `_MEMORY_LIMIT_MB`. The container only
122
+ bootstraps the `sa` login — additional logins are a known limitation.
123
+ `pgdb testdb shell` execs into
124
+ [`sqlcmd`](https://github.com/microsoft/go-sqlcmd) (an external prerequisite,
125
+ the same category as `psql` for the Postgres path) rather than a Python
126
+ REPL.
127
+
92
128
  ## `pgdevkit.db` — helpers for application code
93
129
 
94
130
  Install with the `db` extra: `pip install pgdevkit[db]`.
95
131
 
96
- - **`PostgresTableModel`** a `pydantic.BaseModel` base class for models
97
- that map 1:1 to a table row. Implement `get_table_name()` (returns
132
+ - **`TableModel`** (formerly `PostgresTableModel`, still importable under
133
+ that name) a `pydantic.BaseModel` base class for models that map 1:1 to
134
+ a table row, for either engine. Implement `get_table_name()` (returns
98
135
  `(schema, table)`) and `get_primary_key()` on each model.
99
136
  - **`PgPool`** — an async connection pool keyed off
100
137
  `{env_prefix}HOST/PORT/DB/USER/PASSWORD` env vars. Call `await pool.open()`
@@ -107,8 +144,21 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
107
144
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
108
145
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
109
146
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
110
- `pg_delete_dict` — typed (`PostgresTableModel`-based) or dict-based CRUD
111
- against a table, built on `psycopg` for safe identifier/value handling.
147
+ `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
148
+ table, built on `psycopg` for safe identifier/value handling. The `mssql`
149
+ extra provides an `mssql_*`-prefixed mirror of the same functions in
150
+ `pgdevkit.db.mssql_crud`, built on `mssql-python` (`MERGE`-based upsert,
151
+ `OUTPUT` instead of `RETURNING`) — MSSQL has no composite/enum equivalent,
152
+ so `complex_helper` is always `None` on that path. It does have a native
153
+ `json` column type on current versions (and the older
154
+ `NVARCHAR(MAX)`-plus-`OPENJSON()` convention works on any version), but
155
+ `mssql-python` has no auto-serialization for dict/list parameter values
156
+ (binding one raises `TypeError`) and no way to distinguish a `json`
157
+ column from `nvarchar` on fetch — so every `mssql_*` write function
158
+ serializes dict/list values to JSON text automatically
159
+ (`db.mssql_sql.json_encode_value`), while reads always come back as plain
160
+ `str`; deserialize with `json.loads()` yourself if you need the parsed
161
+ value back.
112
162
  - **`SqlLoader`** — loads and caches `.sql` files from
113
163
  `{root}/<topic>/<name>.sql`, for keeping hand-written queries out of
114
164
  Python source.
@@ -121,6 +121,21 @@ comment on column dim.user.is_active is 'False once a user is soft-deleted; keep
121
121
 
122
122
  ---
123
123
 
124
+ ## MSSQL projects (`engine = "mssql"`)
125
+
126
+ Everything above is engine-agnostic *as a folder/apply-order convention*,
127
+ with two exceptions:
128
+
129
+ - `types/` (custom types / enums) has no direct T-SQL equivalent -- MSSQL
130
+ has neither a native enum type nor composite types, so a `CREATE TYPE ...
131
+ AS ENUM`/composite `.sql` file is a Postgres-only construct. `pgdb compare`
132
+ reports every such object as missing on an MSSQL database (correctly --
133
+ it genuinely doesn't exist there), rather than erroring.
134
+ - T-SQL scripts conventionally separate batches with a standalone `GO` line
135
+ (an `sqlcmd`/SSMS scripting convention, not valid inside a single
136
+ driver `execute()` call). `pgdb testdb` splits on these automatically when
137
+ applying a file; hand-written `.sql` files may use `GO` freely.
138
+
124
139
  ## Backfilling untracked objects
125
140
 
126
141
  If a table, scalar function, or table function was created directly on the
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from ..dialect import Dialect, resolve_dialect
4
+ from .base import Backend
5
+ from .mssql import MssqlBackend
6
+ from .postgres import PostgresBackend
7
+
8
+ _REGISTRY: dict[str, Backend] = {
9
+ "postgres": PostgresBackend(),
10
+ "mssql": MssqlBackend(),
11
+ }
12
+
13
+
14
+ def get_backend(dialect: str | Dialect = "postgres") -> Backend:
15
+ """Look up the `Backend` for a dialect name (or an already-resolved
16
+ `Dialect`). Defaults to postgres."""
17
+ resolved = resolve_dialect(dialect)
18
+ return _REGISTRY[resolved.name]
19
+
20
+
21
+ __all__ = ["Backend", "MssqlBackend", "PostgresBackend", "get_backend"]
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Protocol
4
+
5
+ from ..dialect import Dialect
6
+ from ..models import DatabaseSchema
7
+
8
+
9
+ class Backend(Protocol):
10
+ """Introspection + a couple of engine facts, behind one interface.
11
+
12
+ CRUD is deliberately NOT part of this protocol -- psycopg's
13
+ `AsyncConnection` and an MSSQL driver's connection type are unrelated,
14
+ so a unified `backend.retrieve()`/`backend.insert()` surface would force
15
+ existing Postgres callers to go through a new indirection just to keep
16
+ working. Callers that want CRUD import `pgdevkit.db.crud`'s `pg_*`
17
+ functions or `pgdevkit.db.mssql_crud`'s `mssql_*` functions directly,
18
+ exactly as `db/crud.py`'s functions are imported today."""
19
+
20
+ dialect: Dialect
21
+
22
+ def introspect(self, conninfo: str) -> DatabaseSchema: ...
23
+
24
+ def complex_helper_factory(self) -> Callable[..., Any] | None:
25
+ """A `ComplexHelper`-like factory for composite/enum/JSONB columns,
26
+ or None when the engine has no equivalent (MSSQL)."""
27
+ ...
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from ..dialect import MSSQL, Dialect
6
+ from ..models import DatabaseSchema
7
+
8
+
9
+ class MssqlBackend:
10
+ dialect: Dialect = MSSQL
11
+
12
+ def introspect(self, conninfo: str) -> DatabaseSchema:
13
+ # Imported lazily so `import pgdevkit.backends` (and thus
14
+ # `pgdevkit.cli`) doesn't require mssql-python/the mssql extra to
15
+ # be installed unless a caller actually asks for the mssql backend.
16
+ from ..mssql_introspect import introspect_mssql_db
17
+
18
+ return introspect_mssql_db(conninfo)
19
+
20
+ def complex_helper_factory(self) -> Callable[..., Any] | None:
21
+ # MSSQL has no composite type, native enum, or first-class JSONB
22
+ # column type -- there is nothing for a ComplexHelper to adapt.
23
+ # Every `complex_helper` parameter in db/crud.py (and its
24
+ # db/mssql_crud.py counterpart) is already Optional, so callers on
25
+ # this backend simply pass/receive None and every complex-type
26
+ # branch takes its existing no-op path.
27
+ return None
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from ..dialect import POSTGRES, Dialect
6
+ from ..introspect import introspect_db
7
+ from ..models import DatabaseSchema
8
+
9
+
10
+ class PostgresBackend:
11
+ dialect: Dialect = POSTGRES
12
+
13
+ def introspect(self, conninfo: str) -> DatabaseSchema:
14
+ return introspect_db(conninfo)
15
+
16
+ def complex_helper_factory(self) -> Callable[..., Any] | None:
17
+ from ..db.complex_types import ComplexHelper
18
+
19
+ return ComplexHelper
@@ -10,10 +10,10 @@ from rich.table import Table
10
10
  from rich import box
11
11
 
12
12
  from . import testdb
13
+ from .backends import get_backend
13
14
  from .connection import build_conninfo
14
15
  from .diff import DiffKind, compute_diff
15
16
  from .fetch_missing import SUBFOLDER, find_missing_objects, layer_folder_for, reconstruct_ddl
16
- from .introspect import introspect_db
17
17
  from .parser import parse_directory
18
18
 
19
19
  app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
@@ -37,9 +37,10 @@ def compare(
37
37
  None, "--databricks-instance", help="Lakebase instance name (required for Lakebase hosts)"
38
38
  ),
39
39
  report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
40
+ dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
40
41
  scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
41
42
  ) -> None:
42
- """Compare SQL scripts to a live PostgreSQL database and report differences."""
43
+ """Compare SQL scripts to a live database and report differences."""
43
44
  if not scripts_dir.is_dir():
44
45
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
45
46
  raise typer.Exit(2)
@@ -55,13 +56,19 @@ def compare(
55
56
  err_console.print(f"[red]Error:[/red] {e}")
56
57
  raise typer.Exit(2)
57
58
 
59
+ try:
60
+ backend = get_backend(dialect)
61
+ except ValueError as e:
62
+ err_console.print(f"[red]Error:[/red] {e}")
63
+ raise typer.Exit(2)
64
+
58
65
  with console.status("Parsing SQL scripts..."):
59
- scripts_schema = parse_directory(scripts_dir)
66
+ scripts_schema = parse_directory(scripts_dir, dialect=backend.dialect)
60
67
 
61
68
  with console.status("Introspecting database..."):
62
- db_schema = introspect_db(conninfo)
69
+ db_schema = backend.introspect(conninfo)
63
70
 
64
- diffs = compute_diff(scripts_schema, db_schema, report_extra_db=report_extra_db)
71
+ diffs = compute_diff(scripts_schema, db_schema, report_extra_db=report_extra_db, dialect=backend.dialect)
65
72
 
66
73
  if not diffs:
67
74
  console.print("[green]No differences found.[/green]")
@@ -204,8 +211,10 @@ def testdb_status() -> None:
204
211
 
205
212
  @testdb_app.command("shell")
206
213
  def testdb_shell() -> None:
207
- """Drop into psql against this workspace's database."""
208
- os.execvp("psql", ["psql", testdb.dsn_for()])
214
+ """Drop into an interactive shell (psql, or sqlcmd for MSSQL) against
215
+ this workspace's database."""
216
+ binary, argv = testdb.shell_argv()
217
+ os.execvp(binary, argv)
209
218
 
210
219
 
211
220
  @testdb_app.command("clean")
@@ -17,13 +17,14 @@ from .crud import (
17
17
  pg_upsert_many_dict,
18
18
  )
19
19
  from .loader import SqlLoader
20
- from .model import PostgresTableModel
20
+ from .model import PostgresTableModel, TableModel
21
21
 
22
22
  __all__ = [
23
23
  "ComplexHelper",
24
24
  "PgPool",
25
25
  "PostgresTableModel",
26
26
  "SqlLoader",
27
+ "TableModel",
27
28
  "pg_delete",
28
29
  "pg_delete_dict",
29
30
  "pg_insert",
@@ -6,8 +6,10 @@ from typing import Sequence
6
6
  from pydantic import BaseModel
7
7
 
8
8
 
9
- class PostgresTableModel(BaseModel, ABC):
10
- """Base class for models that map 1:1 to a database table/row.
9
+ class TableModel(BaseModel, ABC):
10
+ """Base class for models that map 1:1 to a database table/row (any
11
+ engine -- schema/table naming is equally meaningful for Postgres and
12
+ MSSQL, this base class was never actually Postgres-specific).
11
13
 
12
14
  Models representing partial results (joins, aggregations, projections)
13
15
  should extend `pydantic.BaseModel` directly instead."""
@@ -21,3 +23,8 @@ class PostgresTableModel(BaseModel, ABC):
21
23
  @abstractmethod
22
24
  def get_primary_key() -> Sequence[str]:
23
25
  """Return the primary key column name(s)."""
26
+
27
+
28
+ # Backward-compat alias -- this class was named PostgresTableModel before
29
+ # MSSQL support existed; kept so existing imports keep working unchanged.
30
+ PostgresTableModel = TableModel