sqlpush 0.5.0__tar.gz → 0.5.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.
- sqlpush-0.5.1/PKG-INFO +299 -0
- sqlpush-0.5.1/README.md +270 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/pyproject.toml +3 -1
- {sqlpush-0.5.0 → sqlpush-0.5.1}/pyproject.toml.orig +3 -1
- sqlpush-0.5.0/PKG-INFO +0 -218
- sqlpush-0.5.0/README.md +0 -189
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/api.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/apply/executor.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/chain/__init__.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/chain/format.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/chain/migrate.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/cli.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/core/classify.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/core/diff.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/directives/timescale.py +0 -0
- {sqlpush-0.5.0 → sqlpush-0.5.1}/src/sqlpush/types.py +0 -0
sqlpush-0.5.1/PKG-INFO
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlpush
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
|
+
Keywords: sqlalchemy,sqlmodel,fastapi,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
|
+
Author: Juan Miguel Contreras
|
|
7
|
+
Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Dist: alembic>=1.18,<2
|
|
20
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Project-URL: Homepage, https://github.com/juanmicl/sqlpush
|
|
25
|
+
Project-URL: Repository, https://github.com/juanmicl/sqlpush
|
|
26
|
+
Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# sqlpush
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/sqlpush/)
|
|
33
|
+
[](https://pypi.org/project/sqlpush/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
**Prisma `db push` for SQLAlchemy.** Your models are the migration.
|
|
37
|
+
sqlpush diffs them (SQLAlchemy, SQLModel, anything built on `MetaData`)
|
|
38
|
+
against the live PostgreSQL / TimescaleDB database, classifies every
|
|
39
|
+
operation by risk (safe / risky / destructive), and applies the plan
|
|
40
|
+
atomically. No migration files to write, no `upgrade` step to forget.
|
|
41
|
+
Drift checks exit with codes your CI can gate on.
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
45
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
46
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you've run `Base.metadata.create_all()` in production and known it
|
|
50
|
+
was wrong, sqlpush is for you.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
pip install sqlpush
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or from source:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Python 3.10 or newer. PostgreSQL only.
|
|
65
|
+
|
|
66
|
+
## Why
|
|
67
|
+
|
|
68
|
+
Declarative models are already the source of truth. Migration files
|
|
69
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
70
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
71
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
72
|
+
|
|
73
|
+
- **No migration files required.** The diff *is* the migration: computed fresh
|
|
74
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
75
|
+
engine used as a library. Files exist as a second workflow when you want
|
|
76
|
+
them (see below).
|
|
77
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
78
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
79
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
80
|
+
- **Drift detection built for CI.** `check` plans once and reports
|
|
81
|
+
through its exit code, no output parsing; `--json` emits a stable
|
|
82
|
+
versioned contract.
|
|
83
|
+
- **Safe under concurrency.** An advisory lock coordinates workers: one
|
|
84
|
+
pusher at a time, losers wait bounded
|
|
85
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
86
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
87
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
88
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
89
|
+
|
|
90
|
+
If you know alembic: sqlpush is its autogenerate engine, productized
|
|
91
|
+
into apply and check verbs, with no revision scripts to maintain.
|
|
92
|
+
|
|
93
|
+
PostgreSQL only, by design.
|
|
94
|
+
|
|
95
|
+
## The 30-second tour
|
|
96
|
+
|
|
97
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
98
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
99
|
+
|
|
100
|
+
```console
|
|
101
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
102
|
+
|
|
103
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
104
|
+
-- safe
|
|
105
|
+
CREATE TABLE hero (
|
|
106
|
+
id SERIAL NOT NULL,
|
|
107
|
+
name VARCHAR(50) NOT NULL,
|
|
108
|
+
PRIMARY KEY (id)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
-- risky
|
|
112
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Push it (the destructive gate is on by default):
|
|
116
|
+
|
|
117
|
+
```console
|
|
118
|
+
$ sqlpush push "myapp.models:metadata"
|
|
119
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
120
|
+
$ echo $?
|
|
121
|
+
1
|
|
122
|
+
|
|
123
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
124
|
+
$ echo $?
|
|
125
|
+
0
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
129
|
+
repeated `--schema` / `--exclude` options.
|
|
130
|
+
|
|
131
|
+
## FastAPI: retire `create_all()`
|
|
132
|
+
|
|
133
|
+
Most FastAPI + SQLModel apps ship the lifespan the tutorials teach:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def lifespan(app):
|
|
138
|
+
async with engine.begin() as conn:
|
|
139
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
140
|
+
yield
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`create_all` creates tables that are missing. That is all it ever
|
|
144
|
+
does. Add a column to a model and the database never hears about it;
|
|
145
|
+
an index on an existing table, a type change, a drop: nothing.
|
|
146
|
+
Production drifts from the models in silence, so every real change
|
|
147
|
+
still rides the alembic treadmill: autogenerate, review, upgrade, and
|
|
148
|
+
two histories to keep in agreement forever.
|
|
149
|
+
|
|
150
|
+
The sqlpush lifespan is one line:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from contextlib import asynccontextmanager
|
|
154
|
+
from sqlpush import aensure_schema
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@asynccontextmanager
|
|
158
|
+
async def lifespan(app):
|
|
159
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
160
|
+
yield
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`mode="check"` verifies the models against the database at startup and
|
|
164
|
+
raises when they disagree: the app refuses to boot against a schema it
|
|
165
|
+
does not match, which beats failing on the first query at 3am. The
|
|
166
|
+
schema change itself comes from wherever you put it: `sqlpush push` in
|
|
167
|
+
the deploy pipeline (destructive ops gated), or
|
|
168
|
+
`aensure_schema(..., mode="push")` when you want the API to apply it.
|
|
169
|
+
|
|
170
|
+
asyncpg URLs work too: a DSN or `AsyncEngine` spelling
|
|
171
|
+
`postgresql+asyncpg` is translated to the psycopg driver automatically,
|
|
172
|
+
and asyncpg is never required in the sqlpush process.
|
|
173
|
+
|
|
174
|
+
Push in the deploy pipeline, check at boot.
|
|
175
|
+
|
|
176
|
+
## An inherited database
|
|
177
|
+
|
|
178
|
+
The first `check` against a database with history often reports drift:
|
|
179
|
+
hand-built indexes, audit tables, a column someone added by hand. If
|
|
180
|
+
any of the drift looks destructive, `check` exits `3` and `push`
|
|
181
|
+
blocks. That is the tool refusing to silently drop your legacy
|
|
182
|
+
objects. Two escape hatches: `--exclude` accepts objects you choose to
|
|
183
|
+
keep (fnmatch patterns, repeatable), and `--allow-destructive` accepts
|
|
184
|
+
the drops when you really do want them.
|
|
185
|
+
|
|
186
|
+
## When you want files: the chain
|
|
187
|
+
|
|
188
|
+
Most changes never need a file. When one does, sqlpush has a second
|
|
189
|
+
workflow built on the same diff engine: the chain. `revision` writes
|
|
190
|
+
the next numbered SQL file from your models against a reference DB,
|
|
191
|
+
`migrate` replays pending files with gates and checksums, and `stamp`
|
|
192
|
+
adopts an existing database without executing anything.
|
|
193
|
+
|
|
194
|
+
The files are plain SQL you can review, edit before first apply, and
|
|
195
|
+
run under `psql`. Schema change and data backfill ship as one file.
|
|
196
|
+
The [chain guide](docs/the-chain.md) covers the format, the gates and
|
|
197
|
+
the workflows.
|
|
198
|
+
|
|
199
|
+
## Exit codes
|
|
200
|
+
|
|
201
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
202
|
+
| --- | --- | --- | --- | --- |
|
|
203
|
+
| `diff` | always | | | |
|
|
204
|
+
| `check` | clean | | drift | destructive drift |
|
|
205
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
206
|
+
| `revision` | file written | error (empty drift refuses) | | |
|
|
207
|
+
| `migrate` | clean | blocked or partial failure | | |
|
|
208
|
+
| `stamp` | registered | blocked or refused | | |
|
|
209
|
+
|
|
210
|
+
Failures print a typed error on stderr, never a traceback.
|
|
211
|
+
|
|
212
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
213
|
+
informationally (exit `0`). Indexes on existing tables build
|
|
214
|
+
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
215
|
+
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
216
|
+
instead of silently half-applying, and leaves an INVALID index: drop it
|
|
217
|
+
(`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
|
|
218
|
+
checksum no longer matches the registry; `--force` accepts the new
|
|
219
|
+
content.
|
|
220
|
+
|
|
221
|
+
The knobs, per verb:
|
|
222
|
+
|
|
223
|
+
| verb | flags |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
|
|
226
|
+
| `revision` | `--ref-dsn` (required) `-m/--message` `--no-concurrently` `--dir` |
|
|
227
|
+
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
228
|
+
| `stamp` | `--force` `--dir` |
|
|
229
|
+
|
|
230
|
+
Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`).
|
|
231
|
+
`revision` requires `--ref-dsn`, with no env fallback: the reference
|
|
232
|
+
DB is a different database from the push target. `diff`, `check`,
|
|
233
|
+
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
234
|
+
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
235
|
+
waits on a lock before failing, `statement_timeout` bounds each
|
|
236
|
+
statement's runtime, and an exhausted `advisory-wait` raises instead
|
|
237
|
+
of hanging on a stuck lock holder.
|
|
238
|
+
|
|
239
|
+
## How it works
|
|
240
|
+
|
|
241
|
+
```mermaid
|
|
242
|
+
flowchart LR
|
|
243
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
244
|
+
db[("live PostgreSQL")] --> diff
|
|
245
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
246
|
+
risk --> plan["plan"]
|
|
247
|
+
plan --> render["render"]
|
|
248
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
249
|
+
apply --> report["report"]
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **Diff engine** scopes reflection to your target schemas and prunes
|
|
253
|
+
system catalogs (TimescaleDB internals included) before reflection
|
|
254
|
+
even starts.
|
|
255
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
256
|
+
are `risky`, never silently safe.
|
|
257
|
+
- **Executor** splits the plan: concurrent index builds run one per
|
|
258
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
259
|
+
transaction with a bounded `lock_timeout`.
|
|
260
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
261
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
262
|
+
|
|
263
|
+
Scoping: `--schema` restricts the diff to named schemas (default: the
|
|
264
|
+
session's real `search_path`). Extension-owned schemas never enter
|
|
265
|
+
scope automatically, and schemas you pass explicitly are never
|
|
266
|
+
filtered. The chain's registry table (`public.sqlpush_versions`)
|
|
267
|
+
always lives in `public` and is pruned from every diff, so `check`
|
|
268
|
+
after `migrate` is clean. `alembic_version` gets the same treatment.
|
|
269
|
+
|
|
270
|
+
## Comparison
|
|
271
|
+
|
|
272
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
273
|
+
|
|
274
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
275
|
+
| --- | --- | --- | --- | --- | --- |
|
|
276
|
+
| **sqlpush** | optional: push needs none; the chain has reviewable, checksummed files | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
277
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
278
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
279
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
280
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
281
|
+
|
|
282
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
283
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
284
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
285
|
+
|
|
286
|
+
Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
|
|
287
|
+
[migrating from alembic](docs/migrating-from-alembic.md), and
|
|
288
|
+
[migrating from migra](docs/migrating-from-migra.md) (deprecated).
|
|
289
|
+
Changes land in the [CHANGELOG](CHANGELOG.md).
|
|
290
|
+
|
|
291
|
+
## Design notes
|
|
292
|
+
|
|
293
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
294
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
295
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
296
|
+
of the same database contend for the same lock.
|
|
297
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
298
|
+
tooling; additive changes only within a version (operations now carry
|
|
299
|
+
a `concurrent` boolean).
|
sqlpush-0.5.1/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# sqlpush
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/sqlpush/)
|
|
4
|
+
[](https://pypi.org/project/sqlpush/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**Prisma `db push` for SQLAlchemy.** Your models are the migration.
|
|
8
|
+
sqlpush diffs them (SQLAlchemy, SQLModel, anything built on `MetaData`)
|
|
9
|
+
against the live PostgreSQL / TimescaleDB database, classifies every
|
|
10
|
+
operation by risk (safe / risky / destructive), and applies the plan
|
|
11
|
+
atomically. No migration files to write, no `upgrade` step to forget.
|
|
12
|
+
Drift checks exit with codes your CI can gate on.
|
|
13
|
+
|
|
14
|
+
```console
|
|
15
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
16
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
17
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If you've run `Base.metadata.create_all()` in production and known it
|
|
21
|
+
was wrong, sqlpush is for you.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
pip install sqlpush
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or from source:
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.10 or newer. PostgreSQL only.
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
Declarative models are already the source of truth. Migration files
|
|
40
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
41
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
42
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
43
|
+
|
|
44
|
+
- **No migration files required.** The diff *is* the migration: computed fresh
|
|
45
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
46
|
+
engine used as a library. Files exist as a second workflow when you want
|
|
47
|
+
them (see below).
|
|
48
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
49
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
50
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
51
|
+
- **Drift detection built for CI.** `check` plans once and reports
|
|
52
|
+
through its exit code, no output parsing; `--json` emits a stable
|
|
53
|
+
versioned contract.
|
|
54
|
+
- **Safe under concurrency.** An advisory lock coordinates workers: one
|
|
55
|
+
pusher at a time, losers wait bounded
|
|
56
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
57
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
58
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
59
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
60
|
+
|
|
61
|
+
If you know alembic: sqlpush is its autogenerate engine, productized
|
|
62
|
+
into apply and check verbs, with no revision scripts to maintain.
|
|
63
|
+
|
|
64
|
+
PostgreSQL only, by design.
|
|
65
|
+
|
|
66
|
+
## The 30-second tour
|
|
67
|
+
|
|
68
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
69
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
73
|
+
|
|
74
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
75
|
+
-- safe
|
|
76
|
+
CREATE TABLE hero (
|
|
77
|
+
id SERIAL NOT NULL,
|
|
78
|
+
name VARCHAR(50) NOT NULL,
|
|
79
|
+
PRIMARY KEY (id)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- risky
|
|
83
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Push it (the destructive gate is on by default):
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
$ sqlpush push "myapp.models:metadata"
|
|
90
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
91
|
+
$ echo $?
|
|
92
|
+
1
|
|
93
|
+
|
|
94
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
95
|
+
$ echo $?
|
|
96
|
+
0
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
100
|
+
repeated `--schema` / `--exclude` options.
|
|
101
|
+
|
|
102
|
+
## FastAPI: retire `create_all()`
|
|
103
|
+
|
|
104
|
+
Most FastAPI + SQLModel apps ship the lifespan the tutorials teach:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
@asynccontextmanager
|
|
108
|
+
async def lifespan(app):
|
|
109
|
+
async with engine.begin() as conn:
|
|
110
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
111
|
+
yield
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`create_all` creates tables that are missing. That is all it ever
|
|
115
|
+
does. Add a column to a model and the database never hears about it;
|
|
116
|
+
an index on an existing table, a type change, a drop: nothing.
|
|
117
|
+
Production drifts from the models in silence, so every real change
|
|
118
|
+
still rides the alembic treadmill: autogenerate, review, upgrade, and
|
|
119
|
+
two histories to keep in agreement forever.
|
|
120
|
+
|
|
121
|
+
The sqlpush lifespan is one line:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from contextlib import asynccontextmanager
|
|
125
|
+
from sqlpush import aensure_schema
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def lifespan(app):
|
|
130
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
131
|
+
yield
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`mode="check"` verifies the models against the database at startup and
|
|
135
|
+
raises when they disagree: the app refuses to boot against a schema it
|
|
136
|
+
does not match, which beats failing on the first query at 3am. The
|
|
137
|
+
schema change itself comes from wherever you put it: `sqlpush push` in
|
|
138
|
+
the deploy pipeline (destructive ops gated), or
|
|
139
|
+
`aensure_schema(..., mode="push")` when you want the API to apply it.
|
|
140
|
+
|
|
141
|
+
asyncpg URLs work too: a DSN or `AsyncEngine` spelling
|
|
142
|
+
`postgresql+asyncpg` is translated to the psycopg driver automatically,
|
|
143
|
+
and asyncpg is never required in the sqlpush process.
|
|
144
|
+
|
|
145
|
+
Push in the deploy pipeline, check at boot.
|
|
146
|
+
|
|
147
|
+
## An inherited database
|
|
148
|
+
|
|
149
|
+
The first `check` against a database with history often reports drift:
|
|
150
|
+
hand-built indexes, audit tables, a column someone added by hand. If
|
|
151
|
+
any of the drift looks destructive, `check` exits `3` and `push`
|
|
152
|
+
blocks. That is the tool refusing to silently drop your legacy
|
|
153
|
+
objects. Two escape hatches: `--exclude` accepts objects you choose to
|
|
154
|
+
keep (fnmatch patterns, repeatable), and `--allow-destructive` accepts
|
|
155
|
+
the drops when you really do want them.
|
|
156
|
+
|
|
157
|
+
## When you want files: the chain
|
|
158
|
+
|
|
159
|
+
Most changes never need a file. When one does, sqlpush has a second
|
|
160
|
+
workflow built on the same diff engine: the chain. `revision` writes
|
|
161
|
+
the next numbered SQL file from your models against a reference DB,
|
|
162
|
+
`migrate` replays pending files with gates and checksums, and `stamp`
|
|
163
|
+
adopts an existing database without executing anything.
|
|
164
|
+
|
|
165
|
+
The files are plain SQL you can review, edit before first apply, and
|
|
166
|
+
run under `psql`. Schema change and data backfill ship as one file.
|
|
167
|
+
The [chain guide](docs/the-chain.md) covers the format, the gates and
|
|
168
|
+
the workflows.
|
|
169
|
+
|
|
170
|
+
## Exit codes
|
|
171
|
+
|
|
172
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
173
|
+
| --- | --- | --- | --- | --- |
|
|
174
|
+
| `diff` | always | | | |
|
|
175
|
+
| `check` | clean | | drift | destructive drift |
|
|
176
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
177
|
+
| `revision` | file written | error (empty drift refuses) | | |
|
|
178
|
+
| `migrate` | clean | blocked or partial failure | | |
|
|
179
|
+
| `stamp` | registered | blocked or refused | | |
|
|
180
|
+
|
|
181
|
+
Failures print a typed error on stderr, never a traceback.
|
|
182
|
+
|
|
183
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
184
|
+
informationally (exit `0`). Indexes on existing tables build
|
|
185
|
+
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
186
|
+
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
187
|
+
instead of silently half-applying, and leaves an INVALID index: drop it
|
|
188
|
+
(`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
|
|
189
|
+
checksum no longer matches the registry; `--force` accepts the new
|
|
190
|
+
content.
|
|
191
|
+
|
|
192
|
+
The knobs, per verb:
|
|
193
|
+
|
|
194
|
+
| verb | flags |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
|
|
197
|
+
| `revision` | `--ref-dsn` (required) `-m/--message` `--no-concurrently` `--dir` |
|
|
198
|
+
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
199
|
+
| `stamp` | `--force` `--dir` |
|
|
200
|
+
|
|
201
|
+
Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`).
|
|
202
|
+
`revision` requires `--ref-dsn`, with no env fallback: the reference
|
|
203
|
+
DB is a different database from the push target. `diff`, `check`,
|
|
204
|
+
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
205
|
+
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
206
|
+
waits on a lock before failing, `statement_timeout` bounds each
|
|
207
|
+
statement's runtime, and an exhausted `advisory-wait` raises instead
|
|
208
|
+
of hanging on a stuck lock holder.
|
|
209
|
+
|
|
210
|
+
## How it works
|
|
211
|
+
|
|
212
|
+
```mermaid
|
|
213
|
+
flowchart LR
|
|
214
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
215
|
+
db[("live PostgreSQL")] --> diff
|
|
216
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
217
|
+
risk --> plan["plan"]
|
|
218
|
+
plan --> render["render"]
|
|
219
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
220
|
+
apply --> report["report"]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- **Diff engine** scopes reflection to your target schemas and prunes
|
|
224
|
+
system catalogs (TimescaleDB internals included) before reflection
|
|
225
|
+
even starts.
|
|
226
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
227
|
+
are `risky`, never silently safe.
|
|
228
|
+
- **Executor** splits the plan: concurrent index builds run one per
|
|
229
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
230
|
+
transaction with a bounded `lock_timeout`.
|
|
231
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
232
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
233
|
+
|
|
234
|
+
Scoping: `--schema` restricts the diff to named schemas (default: the
|
|
235
|
+
session's real `search_path`). Extension-owned schemas never enter
|
|
236
|
+
scope automatically, and schemas you pass explicitly are never
|
|
237
|
+
filtered. The chain's registry table (`public.sqlpush_versions`)
|
|
238
|
+
always lives in `public` and is pruned from every diff, so `check`
|
|
239
|
+
after `migrate` is clean. `alembic_version` gets the same treatment.
|
|
240
|
+
|
|
241
|
+
## Comparison
|
|
242
|
+
|
|
243
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
244
|
+
|
|
245
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
246
|
+
| --- | --- | --- | --- | --- | --- |
|
|
247
|
+
| **sqlpush** | optional: push needs none; the chain has reviewable, checksummed files | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
248
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
249
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
250
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
251
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
252
|
+
|
|
253
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
254
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
255
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
256
|
+
|
|
257
|
+
Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
|
|
258
|
+
[migrating from alembic](docs/migrating-from-alembic.md), and
|
|
259
|
+
[migrating from migra](docs/migrating-from-migra.md) (deprecated).
|
|
260
|
+
Changes land in the [CHANGELOG](CHANGELOG.md).
|
|
261
|
+
|
|
262
|
+
## Design notes
|
|
263
|
+
|
|
264
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
265
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
266
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
267
|
+
of the same database contend for the same lock.
|
|
268
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
269
|
+
tooling; additive changes only within a version (operations now carry
|
|
270
|
+
a `concurrent` boolean).
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.1"
|
|
4
4
|
description = "Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = "MIT"
|
|
8
8
|
keywords = [
|
|
9
9
|
"sqlalchemy",
|
|
10
|
+
"sqlmodel",
|
|
11
|
+
"fastapi",
|
|
10
12
|
"alembic",
|
|
11
13
|
"postgresql",
|
|
12
14
|
"timescaledb",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.1"
|
|
4
4
|
description = "Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -10,6 +10,8 @@ requires-python = ">=3.10"
|
|
|
10
10
|
license = "MIT"
|
|
11
11
|
keywords = [
|
|
12
12
|
"sqlalchemy",
|
|
13
|
+
"sqlmodel",
|
|
14
|
+
"fastapi",
|
|
13
15
|
"alembic",
|
|
14
16
|
"postgresql",
|
|
15
17
|
"timescaledb",
|
sqlpush-0.5.0/PKG-INFO
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sqlpush
|
|
3
|
-
Version: 0.5.0
|
|
4
|
-
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
|
-
Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
|
-
Author: Juan Miguel Contreras
|
|
7
|
-
Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
Classifier: Development Status :: 4 - Beta
|
|
10
|
-
Classifier: Environment :: Console
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Database
|
|
18
|
-
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
-
Requires-Dist: alembic>=1.18,<2
|
|
20
|
-
Requires-Dist: psycopg[binary]>=3.2
|
|
21
|
-
Requires-Dist: sqlalchemy>=2.0
|
|
22
|
-
Requires-Dist: typer>=0.12
|
|
23
|
-
Requires-Python: >=3.10
|
|
24
|
-
Project-URL: Homepage, https://github.com/juanmicl/sqlpush
|
|
25
|
-
Project-URL: Repository, https://github.com/juanmicl/sqlpush
|
|
26
|
-
Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
|
|
27
|
-
Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
|
|
28
|
-
Description-Content-Type: text/markdown
|
|
29
|
-
|
|
30
|
-
# sqlpush
|
|
31
|
-
|
|
32
|
-
[](https://pypi.org/project/sqlpush/)
|
|
33
|
-
[](https://pypi.org/project/sqlpush/)
|
|
34
|
-
[](LICENSE)
|
|
35
|
-
|
|
36
|
-
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
37
|
-
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
38
|
-
database directly, no migration files. sqlpush
|
|
39
|
-
diffs your models against the real schema, classifies every operation by
|
|
40
|
-
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
41
|
-
checks exit with codes your CI can gate on.
|
|
42
|
-
|
|
43
|
-
```console
|
|
44
|
-
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
45
|
-
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
46
|
-
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
50
|
-
was wrong, then sighed at the migration-script treadmill when you reached
|
|
51
|
-
for alembic: sqlpush is for you.
|
|
52
|
-
|
|
53
|
-
## Why
|
|
54
|
-
|
|
55
|
-
Declarative models are already the source of truth. Migration files
|
|
56
|
-
re-encode what the models say, drift from them, and pile up forever.
|
|
57
|
-
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
58
|
-
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
59
|
-
|
|
60
|
-
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
61
|
-
from models vs. live database on every run, via alembic's autogenerate
|
|
62
|
-
engine used as a library.
|
|
63
|
-
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
64
|
-
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
65
|
-
`--allow-destructive`**: nothing executes at all while any is present.
|
|
66
|
-
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
67
|
-
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
68
|
-
`--json` emits a stable versioned contract.
|
|
69
|
-
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
70
|
-
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
71
|
-
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
72
|
-
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
73
|
-
`@hypertable` and the `create_hypertable` directive is planned
|
|
74
|
-
state-aware: idempotent pushes, clean checks, no false drift.
|
|
75
|
-
|
|
76
|
-
PostgreSQL only, by design.
|
|
77
|
-
|
|
78
|
-
## Install
|
|
79
|
-
|
|
80
|
-
```console
|
|
81
|
-
pip install sqlpush
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Or from source:
|
|
85
|
-
|
|
86
|
-
```console
|
|
87
|
-
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## The 30-second tour
|
|
91
|
-
|
|
92
|
-
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
93
|
-
(`--dsn` or `$DATABASE_URL`):
|
|
94
|
-
|
|
95
|
-
```console
|
|
96
|
-
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
97
|
-
|
|
98
|
-
$ sqlpush diff "myapp.models:metadata"
|
|
99
|
-
-- safe
|
|
100
|
-
|
|
101
|
-
CREATE TABLE hero (
|
|
102
|
-
id SERIAL NOT NULL PRIMARY KEY,
|
|
103
|
-
name VARCHAR(50) NOT NULL
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
-- risky
|
|
107
|
-
|
|
108
|
-
CREATE INDEX ix_hero_name ON hero (name);
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Push it (the destructive gate is on by default):
|
|
112
|
-
|
|
113
|
-
```console
|
|
114
|
-
$ sqlpush push "myapp.models:metadata"
|
|
115
|
-
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
116
|
-
$ echo $?
|
|
117
|
-
1
|
|
118
|
-
|
|
119
|
-
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
120
|
-
$ echo $?
|
|
121
|
-
0
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
125
|
-
repeated `--schema` / `--exclude` options.
|
|
126
|
-
|
|
127
|
-
## Exit codes
|
|
128
|
-
|
|
129
|
-
| verb | 0 | 1 | 2 | 3 |
|
|
130
|
-
| --- | --- | --- | --- | --- |
|
|
131
|
-
| `diff` | always | | | |
|
|
132
|
-
| `check` | clean | | drift | destructive drift |
|
|
133
|
-
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
134
|
-
|
|
135
|
-
`push --safe-only` runs only safe operations and skips the rest
|
|
136
|
-
informationally (exit `0`). Indexes on existing tables build
|
|
137
|
-
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
138
|
-
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
139
|
-
instead of silently half-applying, and leaves an INVALID index — drop it
|
|
140
|
-
(`DROP INDEX CONCURRENTLY`) and re-push.
|
|
141
|
-
|
|
142
|
-
## FastAPI / SQLModel: replace `create_all`
|
|
143
|
-
|
|
144
|
-
```python
|
|
145
|
-
from contextlib import asynccontextmanager
|
|
146
|
-
from sqlpush import aensure_schema
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@asynccontextmanager
|
|
150
|
-
async def lifespan(app):
|
|
151
|
-
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
152
|
-
yield
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Push in the deploy pipeline, check at startup.
|
|
156
|
-
|
|
157
|
-
## How it works
|
|
158
|
-
|
|
159
|
-
```mermaid
|
|
160
|
-
flowchart LR
|
|
161
|
-
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
162
|
-
db[("live PostgreSQL")] --> diff
|
|
163
|
-
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
164
|
-
risk --> plan["plan"]
|
|
165
|
-
plan --> render["render"]
|
|
166
|
-
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
167
|
-
apply --> report["report"]
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
171
|
-
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
172
|
-
internals included) before reflection even starts.
|
|
173
|
-
- **Classifier** maps each operation to a risk class; unknown operations
|
|
174
|
-
are `risky`, never silently safe.
|
|
175
|
-
- **Executor** splits the plan: existing-table indexes render
|
|
176
|
-
`CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
|
|
177
|
-
(`--no-concurrently` opts out; indexes on tables the same plan creates
|
|
178
|
-
stay in the atomic transaction), everything else applies in a single
|
|
179
|
-
atomic transaction with a bounded `lock_timeout`.
|
|
180
|
-
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
181
|
-
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
182
|
-
|
|
183
|
-
## Comparison
|
|
184
|
-
|
|
185
|
-
An honest view of the neighborhood (stars as of 2026-08):
|
|
186
|
-
|
|
187
|
-
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
188
|
-
| --- | --- | --- | --- | --- | --- |
|
|
189
|
-
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
190
|
-
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
191
|
-
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
192
|
-
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
193
|
-
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
194
|
-
|
|
195
|
-
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
196
|
-
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
197
|
-
SQLAlchemy models, safely enough to run from CI.
|
|
198
|
-
|
|
199
|
-
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
200
|
-
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
201
|
-
|
|
202
|
-
## Design notes
|
|
203
|
-
|
|
204
|
-
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
205
|
-
annotations module carries none of alembic/typer/psycopg.
|
|
206
|
-
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
207
|
-
of the same database contend for the same lock.
|
|
208
|
-
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
209
|
-
tooling; additive changes only within a version (operations now carry
|
|
210
|
-
a `concurrent` boolean).
|
|
211
|
-
|
|
212
|
-
## Roadmap (0.1.x)
|
|
213
|
-
|
|
214
|
-
- jsonschema-validated `--json` output
|
|
215
|
-
|
|
216
|
-
## License
|
|
217
|
-
|
|
218
|
-
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
sqlpush-0.5.0/README.md
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
# sqlpush
|
|
2
|
-
|
|
3
|
-
[](https://pypi.org/project/sqlpush/)
|
|
4
|
-
[](https://pypi.org/project/sqlpush/)
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
|
|
7
|
-
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
8
|
-
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
9
|
-
database directly, no migration files. sqlpush
|
|
10
|
-
diffs your models against the real schema, classifies every operation by
|
|
11
|
-
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
12
|
-
checks exit with codes your CI can gate on.
|
|
13
|
-
|
|
14
|
-
```console
|
|
15
|
-
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
16
|
-
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
17
|
-
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
21
|
-
was wrong, then sighed at the migration-script treadmill when you reached
|
|
22
|
-
for alembic: sqlpush is for you.
|
|
23
|
-
|
|
24
|
-
## Why
|
|
25
|
-
|
|
26
|
-
Declarative models are already the source of truth. Migration files
|
|
27
|
-
re-encode what the models say, drift from them, and pile up forever.
|
|
28
|
-
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
29
|
-
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
30
|
-
|
|
31
|
-
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
32
|
-
from models vs. live database on every run, via alembic's autogenerate
|
|
33
|
-
engine used as a library.
|
|
34
|
-
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
35
|
-
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
36
|
-
`--allow-destructive`**: nothing executes at all while any is present.
|
|
37
|
-
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
38
|
-
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
39
|
-
`--json` emits a stable versioned contract.
|
|
40
|
-
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
41
|
-
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
42
|
-
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
43
|
-
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
44
|
-
`@hypertable` and the `create_hypertable` directive is planned
|
|
45
|
-
state-aware: idempotent pushes, clean checks, no false drift.
|
|
46
|
-
|
|
47
|
-
PostgreSQL only, by design.
|
|
48
|
-
|
|
49
|
-
## Install
|
|
50
|
-
|
|
51
|
-
```console
|
|
52
|
-
pip install sqlpush
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Or from source:
|
|
56
|
-
|
|
57
|
-
```console
|
|
58
|
-
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## The 30-second tour
|
|
62
|
-
|
|
63
|
-
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
64
|
-
(`--dsn` or `$DATABASE_URL`):
|
|
65
|
-
|
|
66
|
-
```console
|
|
67
|
-
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
68
|
-
|
|
69
|
-
$ sqlpush diff "myapp.models:metadata"
|
|
70
|
-
-- safe
|
|
71
|
-
|
|
72
|
-
CREATE TABLE hero (
|
|
73
|
-
id SERIAL NOT NULL PRIMARY KEY,
|
|
74
|
-
name VARCHAR(50) NOT NULL
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
-- risky
|
|
78
|
-
|
|
79
|
-
CREATE INDEX ix_hero_name ON hero (name);
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Push it (the destructive gate is on by default):
|
|
83
|
-
|
|
84
|
-
```console
|
|
85
|
-
$ sqlpush push "myapp.models:metadata"
|
|
86
|
-
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
87
|
-
$ echo $?
|
|
88
|
-
1
|
|
89
|
-
|
|
90
|
-
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
91
|
-
$ echo $?
|
|
92
|
-
0
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
96
|
-
repeated `--schema` / `--exclude` options.
|
|
97
|
-
|
|
98
|
-
## Exit codes
|
|
99
|
-
|
|
100
|
-
| verb | 0 | 1 | 2 | 3 |
|
|
101
|
-
| --- | --- | --- | --- | --- |
|
|
102
|
-
| `diff` | always | | | |
|
|
103
|
-
| `check` | clean | | drift | destructive drift |
|
|
104
|
-
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
105
|
-
|
|
106
|
-
`push --safe-only` runs only safe operations and skips the rest
|
|
107
|
-
informationally (exit `0`). Indexes on existing tables build
|
|
108
|
-
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
109
|
-
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
110
|
-
instead of silently half-applying, and leaves an INVALID index — drop it
|
|
111
|
-
(`DROP INDEX CONCURRENTLY`) and re-push.
|
|
112
|
-
|
|
113
|
-
## FastAPI / SQLModel: replace `create_all`
|
|
114
|
-
|
|
115
|
-
```python
|
|
116
|
-
from contextlib import asynccontextmanager
|
|
117
|
-
from sqlpush import aensure_schema
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
@asynccontextmanager
|
|
121
|
-
async def lifespan(app):
|
|
122
|
-
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
123
|
-
yield
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Push in the deploy pipeline, check at startup.
|
|
127
|
-
|
|
128
|
-
## How it works
|
|
129
|
-
|
|
130
|
-
```mermaid
|
|
131
|
-
flowchart LR
|
|
132
|
-
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
133
|
-
db[("live PostgreSQL")] --> diff
|
|
134
|
-
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
135
|
-
risk --> plan["plan"]
|
|
136
|
-
plan --> render["render"]
|
|
137
|
-
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
138
|
-
apply --> report["report"]
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
142
|
-
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
143
|
-
internals included) before reflection even starts.
|
|
144
|
-
- **Classifier** maps each operation to a risk class; unknown operations
|
|
145
|
-
are `risky`, never silently safe.
|
|
146
|
-
- **Executor** splits the plan: existing-table indexes render
|
|
147
|
-
`CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
|
|
148
|
-
(`--no-concurrently` opts out; indexes on tables the same plan creates
|
|
149
|
-
stay in the atomic transaction), everything else applies in a single
|
|
150
|
-
atomic transaction with a bounded `lock_timeout`.
|
|
151
|
-
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
152
|
-
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
153
|
-
|
|
154
|
-
## Comparison
|
|
155
|
-
|
|
156
|
-
An honest view of the neighborhood (stars as of 2026-08):
|
|
157
|
-
|
|
158
|
-
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
159
|
-
| --- | --- | --- | --- | --- | --- |
|
|
160
|
-
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
161
|
-
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
162
|
-
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
163
|
-
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
164
|
-
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
165
|
-
|
|
166
|
-
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
167
|
-
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
168
|
-
SQLAlchemy models, safely enough to run from CI.
|
|
169
|
-
|
|
170
|
-
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
171
|
-
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
172
|
-
|
|
173
|
-
## Design notes
|
|
174
|
-
|
|
175
|
-
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
176
|
-
annotations module carries none of alembic/typer/psycopg.
|
|
177
|
-
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
178
|
-
of the same database contend for the same lock.
|
|
179
|
-
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
180
|
-
tooling; additive changes only within a version (operations now carry
|
|
181
|
-
a `concurrent` boolean).
|
|
182
|
-
|
|
183
|
-
## Roadmap (0.1.x)
|
|
184
|
-
|
|
185
|
-
- jsonschema-validated `--json` output
|
|
186
|
-
|
|
187
|
-
## License
|
|
188
|
-
|
|
189
|
-
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|