morphdb 0.1.4__tar.gz → 0.2.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.
- {morphdb-0.1.4 → morphdb-0.2.0}/PKG-INFO +56 -16
- {morphdb-0.1.4 → morphdb-0.2.0}/README.md +52 -14
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/__init__.py +1 -1
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/__main__.py +4 -2
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/associations.py +2 -2
- morphdb-0.2.0/morphdb/backend.py +320 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/dashboard.py +38 -29
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/main.py +6 -4
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/service.py +32 -8
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/db.py +87 -55
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/objects.py +8 -6
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/server.py +8 -3
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/skill/SKILL.md +26 -10
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/skill/scripts/morphdb_schema.py +12 -2
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb.egg-info/PKG-INFO +56 -16
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb.egg-info/SOURCES.txt +2 -0
- morphdb-0.2.0/morphdb.egg-info/requires.txt +6 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/pyproject.toml +6 -3
- morphdb-0.2.0/tests/test_backend.py +104 -0
- morphdb-0.1.4/morphdb.egg-info/requires.txt +0 -3
- {morphdb-0.1.4 → morphdb-0.2.0}/LICENSE +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/apps.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/__init__.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/mcp.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/cli/skill.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/errors.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/fieldindex.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/fieldtypes.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/router.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/routes.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/schema.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb/util.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb.egg-info/dependency_links.txt +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb.egg-info/entry_points.txt +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/morphdb.egg-info/top_level.txt +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/setup.cfg +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_apps.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_cli.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_core.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_field_index.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_hardening.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_includes.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_mcp.py +0 -0
- {morphdb-0.1.4 → morphdb-0.2.0}/tests/test_relations.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: morphdb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A coding-agent-friendly, multi-tenant backend for vibe-coded websites. One process hosts many isolated apps; reshape each app's schema as fast as your agent iterates while the frontend keeps calling the same generic endpoints.
|
|
5
5
|
Author: morphdb contributors
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Savcab/morphdb
|
|
8
8
|
Project-URL: Repository, https://github.com/Savcab/morphdb
|
|
9
9
|
Project-URL: Issues, https://github.com/Savcab/morphdb/issues
|
|
10
|
-
Keywords: database,ai,agent,schema,sqlite,backend,vibe-coding
|
|
10
|
+
Keywords: database,ai,agent,schema,sqlite,postgres,postgresql,backend,vibe-coding
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -23,6 +23,8 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Provides-Extra: postgres
|
|
27
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
26
28
|
Dynamic: license-file
|
|
27
29
|
|
|
28
30
|
# MorphDB
|
|
@@ -31,8 +33,9 @@ Dynamic: license-file
|
|
|
31
33
|
|
|
32
34
|
Reshape the data model as fast as your coding agent iterates — the frontend
|
|
33
35
|
keeps calling the same small set of generic, deterministic endpoints. One
|
|
34
|
-
process hosts many isolated apps (one per site)
|
|
35
|
-
SQLite
|
|
36
|
+
process hosts many isolated apps (one per site). Zero dependencies on the
|
|
37
|
+
default SQLite engine; point it at PostgreSQL when you want a networked, managed
|
|
38
|
+
database — same API, same code.
|
|
36
39
|
|
|
37
40
|
📖 **[Visual explainer → morphdb.pages.dev](https://morphdb.pages.dev)** — the whole idea (schema-fluid, API-stable), the agent/frontend split, relations, and how Claude plugs in over MCP, on one page.
|
|
38
41
|
|
|
@@ -53,8 +56,9 @@ morphdb dashboard # read-only web view of every app + its tables
|
|
|
53
56
|
morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
|
|
54
57
|
```
|
|
55
58
|
|
|
56
|
-
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH
|
|
57
|
-
`--db :memory
|
|
59
|
+
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH`,
|
|
60
|
+
`--db :memory:`, or a Postgres `--db postgresql://…` URL; move the state dir with
|
|
61
|
+
`$MORPHDB_HOME`). Server flags:
|
|
58
62
|
`--host`, `--port`, `--db`. From a source checkout with no install, the
|
|
59
63
|
foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
|
|
60
64
|
|
|
@@ -67,6 +71,29 @@ to reload the new code (data in `~/.morphdb` is preserved across `0.1.x`).
|
|
|
67
71
|
of localhost. It's a client-side setting that names a *backend*, not a database
|
|
68
72
|
connection string.
|
|
69
73
|
|
|
74
|
+
### Persistence: SQLite (default) or PostgreSQL
|
|
75
|
+
|
|
76
|
+
By default MorphDB is an embedded SQLite database — zero dependencies, one file.
|
|
77
|
+
To persist to **PostgreSQL** instead (a managed/networked database — RDS, Neon,
|
|
78
|
+
Supabase, or your own server), install the extra and point the server at a
|
|
79
|
+
connection URL:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install 'morphdb[postgres]' # adds the psycopg driver
|
|
83
|
+
|
|
84
|
+
# pass a URL as --db …
|
|
85
|
+
morphdb start --db postgresql://user:pass@host:5432/mydb
|
|
86
|
+
# … or set it in the environment (handy for containers / serverless)
|
|
87
|
+
export MORPHDB_DATABASE_URL=postgresql://user:pass@host:5432/mydb
|
|
88
|
+
morphdb start
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Nothing else changes — the same endpoints, schema model, queries, includes, CLI,
|
|
92
|
+
and dashboard work identically; the engine just talks to Postgres. This makes the
|
|
93
|
+
MorphDB process a **stateless API tier** you can run as a container (or several,
|
|
94
|
+
against one Postgres) with the durable state in your managed database. The core
|
|
95
|
+
stays zero-dependency; `psycopg` is pulled in only for the Postgres backend.
|
|
96
|
+
|
|
70
97
|
## Use it
|
|
71
98
|
|
|
72
99
|
With the server running (`morphdb start`):
|
|
@@ -116,7 +143,8 @@ via a pid file under the state dir.
|
|
|
116
143
|
| `morphdb --version` | Print the version. |
|
|
117
144
|
|
|
118
145
|
`start` / `run` accept `--host` (default `127.0.0.1`), `--port` (default `8787`),
|
|
119
|
-
and `--db` (a SQLite path
|
|
146
|
+
and `--db` (a SQLite path, `:memory:`, or a `postgresql://…` URL; default
|
|
147
|
+
`$MORPHDB_DATABASE_URL` or `~/.morphdb/data.sqlite3`).
|
|
120
148
|
`dashboard` accepts `--port` (default `8788`), `--db`, and `--no-open`. Service
|
|
121
149
|
state (pid, log, the default db) lives under `~/.morphdb` — relocate it with
|
|
122
150
|
`$MORPHDB_HOME`.
|
|
@@ -169,7 +197,7 @@ exists. Meanwhile the frontend talks to generic endpoints that never change.
|
|
|
169
197
|
DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
|
|
170
198
|
│ │
|
|
171
199
|
└────────────── MorphDB ───────────┘
|
|
172
|
-
|
|
200
|
+
(one process · many apps · SQLite or Postgres)
|
|
173
201
|
every call: X-App-Key: <app>
|
|
174
202
|
```
|
|
175
203
|
|
|
@@ -228,7 +256,7 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
228
256
|
|
|
229
257
|
## Features
|
|
230
258
|
|
|
231
|
-
- **Zero dependencies.** Pure Python standard library + SQLite
|
|
259
|
+
- **Zero dependencies by default.** Pure Python standard library + embedded SQLite (`python3 -m morphdb` and go). An optional PostgreSQL backend (`pip install morphdb[postgres]`) swaps in a networked, managed database with no other changes.
|
|
232
260
|
- **Generic CRUD** over arbitrary object types with typed fields.
|
|
233
261
|
- **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
|
|
234
262
|
- **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
|
|
@@ -238,8 +266,9 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
238
266
|
- **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
|
|
239
267
|
- **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
|
|
240
268
|
|
|
241
|
-
> Scope: a
|
|
242
|
-
>
|
|
269
|
+
> Scope: a small-scale developer tool. With the PostgreSQL backend it can run as
|
|
270
|
+
> one or more stateless instances behind a managed database, but it ships no
|
|
271
|
+
> multi-tenant auth or production durability guarantees.
|
|
243
272
|
|
|
244
273
|
## Data model
|
|
245
274
|
|
|
@@ -399,9 +428,12 @@ allowed, `413` body too large, `500` internal.
|
|
|
399
428
|
filter by it, so apps can reuse type names and never see each other's data,
|
|
400
429
|
and deleting an app is a single cascading delete. Type identity is the
|
|
401
430
|
`(app, name)` pair, and relation targets must live in the same app.
|
|
402
|
-
- **
|
|
403
|
-
|
|
404
|
-
|
|
431
|
+
- **Pluggable persistence.** The engine writes one SQLite-flavored dialect of
|
|
432
|
+
SQL behind a thin backend seam (`morphdb/backend.py`); SQLite (default) and
|
|
433
|
+
PostgreSQL are both first-class, selected by the `--db` target. All access is
|
|
434
|
+
serialized through a single connection guarded by a reentrant lock — simple and
|
|
435
|
+
correct at single-instance scale; threaded request handling stays safe. Run
|
|
436
|
+
several stateless instances against one Postgres for horizontal scale.
|
|
405
437
|
|
|
406
438
|
## Limitations
|
|
407
439
|
|
|
@@ -425,12 +457,20 @@ allowed, `413` body too large, `500` internal.
|
|
|
425
457
|
a plain header — it isolates data between apps but is **not** authentication.
|
|
426
458
|
Anyone who knows a key can use that app; the absence of a list-apps endpoint is
|
|
427
459
|
light obscurity, not a security boundary.
|
|
428
|
-
-
|
|
460
|
+
- **Scale & auth.** With the default SQLite engine it's a localhost-scale tool;
|
|
461
|
+
with the PostgreSQL backend it can run as one or more stateless instances
|
|
462
|
+
against a managed database. Either way it ships no built-in authentication or
|
|
463
|
+
multi-tenant authorization.
|
|
429
464
|
|
|
430
465
|
## Development
|
|
431
466
|
|
|
432
467
|
```bash
|
|
433
|
-
python3 -m unittest discover -s tests # full suite, zero deps
|
|
468
|
+
python3 -m unittest discover -s tests # full suite on SQLite, zero deps
|
|
469
|
+
|
|
470
|
+
# run the same engine suite against PostgreSQL too:
|
|
471
|
+
pip install 'morphdb[postgres,dev]'
|
|
472
|
+
MORPHDB_TEST_DATABASE_URL=postgresql://localhost/morphdb_test \
|
|
473
|
+
python3 -m pytest tests/ # SQLite-specific tests auto-skip
|
|
434
474
|
```
|
|
435
475
|
|
|
436
476
|
## License
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
Reshape the data model as fast as your coding agent iterates — the frontend
|
|
6
6
|
keeps calling the same small set of generic, deterministic endpoints. One
|
|
7
|
-
process hosts many isolated apps (one per site)
|
|
8
|
-
SQLite
|
|
7
|
+
process hosts many isolated apps (one per site). Zero dependencies on the
|
|
8
|
+
default SQLite engine; point it at PostgreSQL when you want a networked, managed
|
|
9
|
+
database — same API, same code.
|
|
9
10
|
|
|
10
11
|
📖 **[Visual explainer → morphdb.pages.dev](https://morphdb.pages.dev)** — the whole idea (schema-fluid, API-stable), the agent/frontend split, relations, and how Claude plugs in over MCP, on one page.
|
|
11
12
|
|
|
@@ -26,8 +27,9 @@ morphdb dashboard # read-only web view of every app + its tables
|
|
|
26
27
|
morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH
|
|
30
|
-
`--db :memory
|
|
30
|
+
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH`,
|
|
31
|
+
`--db :memory:`, or a Postgres `--db postgresql://…` URL; move the state dir with
|
|
32
|
+
`$MORPHDB_HOME`). Server flags:
|
|
31
33
|
`--host`, `--port`, `--db`. From a source checkout with no install, the
|
|
32
34
|
foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
|
|
33
35
|
|
|
@@ -40,6 +42,29 @@ to reload the new code (data in `~/.morphdb` is preserved across `0.1.x`).
|
|
|
40
42
|
of localhost. It's a client-side setting that names a *backend*, not a database
|
|
41
43
|
connection string.
|
|
42
44
|
|
|
45
|
+
### Persistence: SQLite (default) or PostgreSQL
|
|
46
|
+
|
|
47
|
+
By default MorphDB is an embedded SQLite database — zero dependencies, one file.
|
|
48
|
+
To persist to **PostgreSQL** instead (a managed/networked database — RDS, Neon,
|
|
49
|
+
Supabase, or your own server), install the extra and point the server at a
|
|
50
|
+
connection URL:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install 'morphdb[postgres]' # adds the psycopg driver
|
|
54
|
+
|
|
55
|
+
# pass a URL as --db …
|
|
56
|
+
morphdb start --db postgresql://user:pass@host:5432/mydb
|
|
57
|
+
# … or set it in the environment (handy for containers / serverless)
|
|
58
|
+
export MORPHDB_DATABASE_URL=postgresql://user:pass@host:5432/mydb
|
|
59
|
+
morphdb start
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Nothing else changes — the same endpoints, schema model, queries, includes, CLI,
|
|
63
|
+
and dashboard work identically; the engine just talks to Postgres. This makes the
|
|
64
|
+
MorphDB process a **stateless API tier** you can run as a container (or several,
|
|
65
|
+
against one Postgres) with the durable state in your managed database. The core
|
|
66
|
+
stays zero-dependency; `psycopg` is pulled in only for the Postgres backend.
|
|
67
|
+
|
|
43
68
|
## Use it
|
|
44
69
|
|
|
45
70
|
With the server running (`morphdb start`):
|
|
@@ -89,7 +114,8 @@ via a pid file under the state dir.
|
|
|
89
114
|
| `morphdb --version` | Print the version. |
|
|
90
115
|
|
|
91
116
|
`start` / `run` accept `--host` (default `127.0.0.1`), `--port` (default `8787`),
|
|
92
|
-
and `--db` (a SQLite path
|
|
117
|
+
and `--db` (a SQLite path, `:memory:`, or a `postgresql://…` URL; default
|
|
118
|
+
`$MORPHDB_DATABASE_URL` or `~/.morphdb/data.sqlite3`).
|
|
93
119
|
`dashboard` accepts `--port` (default `8788`), `--db`, and `--no-open`. Service
|
|
94
120
|
state (pid, log, the default db) lives under `~/.morphdb` — relocate it with
|
|
95
121
|
`$MORPHDB_HOME`.
|
|
@@ -142,7 +168,7 @@ exists. Meanwhile the frontend talks to generic endpoints that never change.
|
|
|
142
168
|
DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
|
|
143
169
|
│ │
|
|
144
170
|
└────────────── MorphDB ───────────┘
|
|
145
|
-
|
|
171
|
+
(one process · many apps · SQLite or Postgres)
|
|
146
172
|
every call: X-App-Key: <app>
|
|
147
173
|
```
|
|
148
174
|
|
|
@@ -201,7 +227,7 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
201
227
|
|
|
202
228
|
## Features
|
|
203
229
|
|
|
204
|
-
- **Zero dependencies.** Pure Python standard library + SQLite
|
|
230
|
+
- **Zero dependencies by default.** Pure Python standard library + embedded SQLite (`python3 -m morphdb` and go). An optional PostgreSQL backend (`pip install morphdb[postgres]`) swaps in a networked, managed database with no other changes.
|
|
205
231
|
- **Generic CRUD** over arbitrary object types with typed fields.
|
|
206
232
|
- **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
|
|
207
233
|
- **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
|
|
@@ -211,8 +237,9 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
211
237
|
- **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
|
|
212
238
|
- **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
|
|
213
239
|
|
|
214
|
-
> Scope: a
|
|
215
|
-
>
|
|
240
|
+
> Scope: a small-scale developer tool. With the PostgreSQL backend it can run as
|
|
241
|
+
> one or more stateless instances behind a managed database, but it ships no
|
|
242
|
+
> multi-tenant auth or production durability guarantees.
|
|
216
243
|
|
|
217
244
|
## Data model
|
|
218
245
|
|
|
@@ -372,9 +399,12 @@ allowed, `413` body too large, `500` internal.
|
|
|
372
399
|
filter by it, so apps can reuse type names and never see each other's data,
|
|
373
400
|
and deleting an app is a single cascading delete. Type identity is the
|
|
374
401
|
`(app, name)` pair, and relation targets must live in the same app.
|
|
375
|
-
- **
|
|
376
|
-
|
|
377
|
-
|
|
402
|
+
- **Pluggable persistence.** The engine writes one SQLite-flavored dialect of
|
|
403
|
+
SQL behind a thin backend seam (`morphdb/backend.py`); SQLite (default) and
|
|
404
|
+
PostgreSQL are both first-class, selected by the `--db` target. All access is
|
|
405
|
+
serialized through a single connection guarded by a reentrant lock — simple and
|
|
406
|
+
correct at single-instance scale; threaded request handling stays safe. Run
|
|
407
|
+
several stateless instances against one Postgres for horizontal scale.
|
|
378
408
|
|
|
379
409
|
## Limitations
|
|
380
410
|
|
|
@@ -398,12 +428,20 @@ allowed, `413` body too large, `500` internal.
|
|
|
398
428
|
a plain header — it isolates data between apps but is **not** authentication.
|
|
399
429
|
Anyone who knows a key can use that app; the absence of a list-apps endpoint is
|
|
400
430
|
light obscurity, not a security boundary.
|
|
401
|
-
-
|
|
431
|
+
- **Scale & auth.** With the default SQLite engine it's a localhost-scale tool;
|
|
432
|
+
with the PostgreSQL backend it can run as one or more stateless instances
|
|
433
|
+
against a managed database. Either way it ships no built-in authentication or
|
|
434
|
+
multi-tenant authorization.
|
|
402
435
|
|
|
403
436
|
## Development
|
|
404
437
|
|
|
405
438
|
```bash
|
|
406
|
-
python3 -m unittest discover -s tests # full suite, zero deps
|
|
439
|
+
python3 -m unittest discover -s tests # full suite on SQLite, zero deps
|
|
440
|
+
|
|
441
|
+
# run the same engine suite against PostgreSQL too:
|
|
442
|
+
pip install 'morphdb[postgres,dev]'
|
|
443
|
+
MORPHDB_TEST_DATABASE_URL=postgresql://localhost/morphdb_test \
|
|
444
|
+
python3 -m pytest tests/ # SQLite-specific tests auto-skip
|
|
407
445
|
```
|
|
408
446
|
|
|
409
447
|
## License
|
|
@@ -16,8 +16,10 @@ def main(argv=None):
|
|
|
16
16
|
help="Host/interface to bind (default: 127.0.0.1).")
|
|
17
17
|
parser.add_argument("--port", type=int, default=8787,
|
|
18
18
|
help="Port to listen on (default: 8787).")
|
|
19
|
-
parser.add_argument("--db", default=
|
|
20
|
-
help="SQLite file path,
|
|
19
|
+
parser.add_argument("--db", default=None,
|
|
20
|
+
help="SQLite file path, ':memory:', or a Postgres URL "
|
|
21
|
+
"(postgresql://...). Defaults to $MORPHDB_DATABASE_URL "
|
|
22
|
+
"or morphdb.sqlite3.")
|
|
21
23
|
parser.add_argument("--version", action="version",
|
|
22
24
|
version=f"morphdb {__version__}")
|
|
23
25
|
args = parser.parse_args(argv)
|
|
@@ -145,7 +145,7 @@ def upsert_relation(c, app, from_type, key, raw):
|
|
|
145
145
|
if exists:
|
|
146
146
|
c.execute(
|
|
147
147
|
"UPDATE association_schemas SET from_type=?, to_type=?, forward_name=?, "
|
|
148
|
-
"inverse_name=?, cardinality=?, symmetric=?, forward_description=?, "
|
|
148
|
+
"inverse_name=?, cardinality=?, \"symmetric\"=?, forward_description=?, "
|
|
149
149
|
"inverse_description=?, updated_at=? WHERE app=? AND name=?",
|
|
150
150
|
(d["from_type"], d["to_type"], d["forward_name"], d["inverse_name"],
|
|
151
151
|
d["cardinality"], int(d["symmetric"]), d["forward_description"],
|
|
@@ -154,7 +154,7 @@ def upsert_relation(c, app, from_type, key, raw):
|
|
|
154
154
|
else:
|
|
155
155
|
c.execute(
|
|
156
156
|
"INSERT INTO association_schemas (app, name, from_type, to_type, "
|
|
157
|
-
"forward_name, inverse_name, cardinality, symmetric, "
|
|
157
|
+
"forward_name, inverse_name, cardinality, \"symmetric\", "
|
|
158
158
|
"forward_description, inverse_description, created_at, updated_at) "
|
|
159
159
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
160
160
|
(app, d["name"], d["from_type"], d["to_type"], d["forward_name"],
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Storage backend abstraction — target SQLite or PostgreSQL behind one interface.
|
|
2
|
+
|
|
3
|
+
MorphDB was born talking SQLite directly. This module pulls that coupling into a
|
|
4
|
+
single seam so the engine can persist to either:
|
|
5
|
+
|
|
6
|
+
* **SQLite** (default, zero-dependency) — an embedded file, exactly as before.
|
|
7
|
+
* **PostgreSQL** (optional: ``pip install morphdb[postgres]``) — a networked,
|
|
8
|
+
managed database (RDS / Neon / Supabase / a plain server). The MorphDB process
|
|
9
|
+
becomes a stateless API tier; the durable state lives in Postgres.
|
|
10
|
+
|
|
11
|
+
The rest of the codebase keeps writing ONE dialect of SQL — SQLite-flavored:
|
|
12
|
+
``?`` placeholders, ``INSERT OR IGNORE`` — and the backend translates it per
|
|
13
|
+
engine at execute time. That works because the data model is vanilla relational
|
|
14
|
+
(a JSON blob per object + a typed EAV index table + an edge table); the only real
|
|
15
|
+
differences are dialect surface:
|
|
16
|
+
|
|
17
|
+
placeholders ? -> %s
|
|
18
|
+
upsert INSERT OR IGNORE -> INSERT ... ON CONFLICT DO NOTHING
|
|
19
|
+
autoincrement PK INTEGER ... AUTOINCREMENT -> BIGINT GENERATED ... AS IDENTITY
|
|
20
|
+
case-insensitive LIKE -> ILIKE
|
|
21
|
+
schema version PRAGMA user_version -> a morphdb_meta table
|
|
22
|
+
column introspection PRAGMA table_info -> information_schema.columns
|
|
23
|
+
booleans (stored as 0/1 ints; a Python bool is coerced on bind)
|
|
24
|
+
|
|
25
|
+
Concurrency: as in the original design, a single connection guarded by a
|
|
26
|
+
reentrant lock serializes all access — simple and correct at single-instance
|
|
27
|
+
scale. Multiple MorphDB instances against the same Postgres each hold their own
|
|
28
|
+
connection and rely on Postgres (MVCC + unique constraints) for cross-process
|
|
29
|
+
consistency. A connection pool is a future optimization, not a correctness need.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_url(target):
|
|
38
|
+
"""True if ``target`` is a Postgres connection URL (vs a SQLite path)."""
|
|
39
|
+
return isinstance(target, str) and (
|
|
40
|
+
target.startswith("postgresql://") or target.startswith("postgres://"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def from_target(target=None):
|
|
44
|
+
"""Build a backend from a target.
|
|
45
|
+
|
|
46
|
+
``target`` may be a Postgres URL, a SQLite file path, ``":memory:"``, or
|
|
47
|
+
``None`` — in which case ``$MORPHDB_DATABASE_URL`` is consulted, else error.
|
|
48
|
+
"""
|
|
49
|
+
if target is None:
|
|
50
|
+
target = os.environ.get("MORPHDB_DATABASE_URL")
|
|
51
|
+
if not target:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"No database target given and $MORPHDB_DATABASE_URL is unset.")
|
|
54
|
+
if is_url(target):
|
|
55
|
+
return PostgresBackend(target)
|
|
56
|
+
return SqliteBackend(target)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def adapt_params(params):
|
|
60
|
+
"""Normalize bind parameters across backends.
|
|
61
|
+
|
|
62
|
+
MorphDB stores booleans as 0/1 integers (there is no native boolean column),
|
|
63
|
+
so a Python ``bool`` must bind as an ``int`` — required for Postgres' INTEGER
|
|
64
|
+
columns, and a harmless no-op for SQLite (where ``bool`` already adapts to
|
|
65
|
+
0/1). Everything else passes through unchanged.
|
|
66
|
+
"""
|
|
67
|
+
if not params:
|
|
68
|
+
return params
|
|
69
|
+
return [int(p) if isinstance(p, bool) else p for p in params]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _split_sql(sql):
|
|
73
|
+
"""Split a multi-statement DDL string into individual statements.
|
|
74
|
+
|
|
75
|
+
Strips ``--`` line comments first (some carry a ``;`` mid-comment, which a
|
|
76
|
+
naive split would mishandle), then splits on ``;``.
|
|
77
|
+
"""
|
|
78
|
+
no_comments = re.sub(r"--[^\n]*", "", sql)
|
|
79
|
+
return [s.strip() for s in no_comments.split(";") if s.strip()]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Result:
|
|
83
|
+
"""A buffered query result.
|
|
84
|
+
|
|
85
|
+
Rows are fetched eagerly while the shared lock is held, so a bare read is
|
|
86
|
+
atomic on the single shared connection. Exposes only the slice of the DB-API
|
|
87
|
+
the engine actually uses.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("_rows",)
|
|
91
|
+
|
|
92
|
+
def __init__(self, rows):
|
|
93
|
+
self._rows = rows
|
|
94
|
+
|
|
95
|
+
def fetchone(self):
|
|
96
|
+
return self._rows[0] if self._rows else None
|
|
97
|
+
|
|
98
|
+
def fetchall(self):
|
|
99
|
+
return self._rows
|
|
100
|
+
|
|
101
|
+
def __iter__(self):
|
|
102
|
+
return iter(self._rows)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Connection:
|
|
106
|
+
"""Backend-agnostic connection facade used throughout the engine.
|
|
107
|
+
|
|
108
|
+
``execute`` / ``executemany`` accept the engine's SQLite-flavored SQL; the
|
|
109
|
+
backend translates it, parameters are adapted, and every call is serialized
|
|
110
|
+
by the shared reentrant lock so the one underlying DB-API connection is safe
|
|
111
|
+
to use from the server's request threads.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, backend, raw, lock):
|
|
115
|
+
self.backend = backend
|
|
116
|
+
self.raw = raw
|
|
117
|
+
self._lock = lock
|
|
118
|
+
|
|
119
|
+
def execute(self, sql, params=()):
|
|
120
|
+
sql2 = self.backend.translate(sql)
|
|
121
|
+
params2 = adapt_params(params)
|
|
122
|
+
with self._lock:
|
|
123
|
+
cur = self.raw.cursor()
|
|
124
|
+
try:
|
|
125
|
+
cur.execute(sql2, params2)
|
|
126
|
+
rows = cur.fetchall() if cur.description is not None else []
|
|
127
|
+
finally:
|
|
128
|
+
cur.close()
|
|
129
|
+
return _Result(rows)
|
|
130
|
+
|
|
131
|
+
def executemany(self, sql, seq):
|
|
132
|
+
seq2 = [adapt_params(p) for p in seq]
|
|
133
|
+
if not seq2:
|
|
134
|
+
return
|
|
135
|
+
sql2 = self.backend.translate(sql)
|
|
136
|
+
with self._lock:
|
|
137
|
+
cur = self.raw.cursor()
|
|
138
|
+
try:
|
|
139
|
+
cur.executemany(sql2, seq2)
|
|
140
|
+
finally:
|
|
141
|
+
cur.close()
|
|
142
|
+
|
|
143
|
+
def commit(self):
|
|
144
|
+
self.raw.commit()
|
|
145
|
+
|
|
146
|
+
def rollback(self):
|
|
147
|
+
self.raw.rollback()
|
|
148
|
+
|
|
149
|
+
def close(self):
|
|
150
|
+
try:
|
|
151
|
+
self.raw.close()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- SQLite -------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SqliteBackend:
|
|
160
|
+
"""The default, zero-dependency backend: an embedded SQLite file."""
|
|
161
|
+
|
|
162
|
+
name = "sqlite"
|
|
163
|
+
|
|
164
|
+
def __init__(self, path):
|
|
165
|
+
self.path = path
|
|
166
|
+
|
|
167
|
+
def describe(self):
|
|
168
|
+
return self.path
|
|
169
|
+
|
|
170
|
+
def connect(self):
|
|
171
|
+
import sqlite3
|
|
172
|
+
conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
173
|
+
conn.row_factory = sqlite3.Row
|
|
174
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
175
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
176
|
+
conn.execute("PRAGMA busy_timeout=5000;")
|
|
177
|
+
conn.execute("PRAGMA foreign_keys=ON;") # enforce the app cascade + FKs
|
|
178
|
+
return conn
|
|
179
|
+
|
|
180
|
+
def translate(self, sql):
|
|
181
|
+
return sql # the engine speaks SQLite already
|
|
182
|
+
|
|
183
|
+
def like_ci(self):
|
|
184
|
+
return "LIKE" # SQLite LIKE is ASCII case-insensitive
|
|
185
|
+
|
|
186
|
+
def create_schema(self, raw, schema_sql):
|
|
187
|
+
raw.executescript(schema_sql)
|
|
188
|
+
raw.commit()
|
|
189
|
+
|
|
190
|
+
def reset(self, raw):
|
|
191
|
+
"""Drop every MorphDB table (test isolation). Rarely used: ``:memory:``
|
|
192
|
+
is already fresh, so this only matters for a reused file."""
|
|
193
|
+
for t in ("field_index", "associations", "association_schemas",
|
|
194
|
+
"objects", "object_schemas", "apps", "morphdb_meta"):
|
|
195
|
+
raw.execute(f"DROP TABLE IF EXISTS {t}")
|
|
196
|
+
raw.commit()
|
|
197
|
+
|
|
198
|
+
@contextmanager
|
|
199
|
+
def transaction(self, raw):
|
|
200
|
+
try:
|
|
201
|
+
yield
|
|
202
|
+
raw.commit()
|
|
203
|
+
except Exception:
|
|
204
|
+
raw.rollback()
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
def get_user_version(self, raw):
|
|
208
|
+
return raw.execute("PRAGMA user_version").fetchone()[0]
|
|
209
|
+
|
|
210
|
+
def set_user_version(self, raw, version):
|
|
211
|
+
raw.execute(f"PRAGMA user_version = {int(version)}")
|
|
212
|
+
|
|
213
|
+
def table_columns(self, raw, table):
|
|
214
|
+
# Ordered by definition (cid) so callers can render columns in order.
|
|
215
|
+
rows = raw.execute(f"PRAGMA table_info({table})").fetchall()
|
|
216
|
+
return [r["name"] for r in rows]
|
|
217
|
+
|
|
218
|
+
def list_tables(self, raw):
|
|
219
|
+
rows = raw.execute(
|
|
220
|
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
221
|
+
"AND name NOT LIKE 'sqlite_%'").fetchall()
|
|
222
|
+
return [r["name"] for r in rows]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- PostgreSQL ---------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class PostgresBackend:
|
|
229
|
+
"""Optional backend targeting PostgreSQL via psycopg (v3).
|
|
230
|
+
|
|
231
|
+
Requires ``pip install morphdb[postgres]``. The same engine SQL is translated
|
|
232
|
+
to the Postgres dialect; identity columns, upserts, versioning, and column
|
|
233
|
+
introspection use their Postgres equivalents.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
name = "postgres"
|
|
237
|
+
|
|
238
|
+
def __init__(self, url):
|
|
239
|
+
self.url = url
|
|
240
|
+
|
|
241
|
+
def describe(self):
|
|
242
|
+
# Never echo credentials embedded in the URL.
|
|
243
|
+
return re.sub(r"//[^@/]+@", "//***@", self.url)
|
|
244
|
+
|
|
245
|
+
def connect(self):
|
|
246
|
+
try:
|
|
247
|
+
import psycopg
|
|
248
|
+
from psycopg.rows import dict_row
|
|
249
|
+
except ImportError as e: # pragma: no cover - environment-dependent
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
"PostgreSQL support needs the psycopg driver. Install it with:\n"
|
|
252
|
+
" pip install 'morphdb[postgres]'\n"
|
|
253
|
+
f"(import error: {e})")
|
|
254
|
+
return psycopg.connect(self.url, autocommit=True, row_factory=dict_row)
|
|
255
|
+
|
|
256
|
+
def translate(self, sql):
|
|
257
|
+
s = sql
|
|
258
|
+
if "INSERT OR IGNORE" in s:
|
|
259
|
+
s = s.replace("INSERT OR IGNORE", "INSERT", 1) + " ON CONFLICT DO NOTHING"
|
|
260
|
+
# Escape any literal % (psycopg treats the query as a format string when
|
|
261
|
+
# params are passed), then convert ? placeholders to %s.
|
|
262
|
+
s = s.replace("%", "%%").replace("?", "%s")
|
|
263
|
+
return s
|
|
264
|
+
|
|
265
|
+
def like_ci(self):
|
|
266
|
+
return "ILIKE" # Postgres LIKE is case-sensitive
|
|
267
|
+
|
|
268
|
+
def _ddl(self, schema_sql):
|
|
269
|
+
return schema_sql.replace(
|
|
270
|
+
"INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
271
|
+
"BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY")
|
|
272
|
+
|
|
273
|
+
def create_schema(self, raw, schema_sql):
|
|
274
|
+
with raw.cursor() as cur:
|
|
275
|
+
for stmt in _split_sql(self._ddl(schema_sql)):
|
|
276
|
+
cur.execute(stmt)
|
|
277
|
+
cur.execute(
|
|
278
|
+
"CREATE TABLE IF NOT EXISTS morphdb_meta (version INTEGER NOT NULL)")
|
|
279
|
+
|
|
280
|
+
def reset(self, raw):
|
|
281
|
+
with raw.cursor() as cur:
|
|
282
|
+
cur.execute("DROP SCHEMA IF EXISTS public CASCADE")
|
|
283
|
+
cur.execute("CREATE SCHEMA public")
|
|
284
|
+
|
|
285
|
+
@contextmanager
|
|
286
|
+
def transaction(self, raw):
|
|
287
|
+
# psycopg manages BEGIN/COMMIT/ROLLBACK; works even in autocommit mode.
|
|
288
|
+
with raw.transaction():
|
|
289
|
+
yield
|
|
290
|
+
|
|
291
|
+
def get_user_version(self, raw):
|
|
292
|
+
with raw.cursor() as cur:
|
|
293
|
+
cur.execute(
|
|
294
|
+
"CREATE TABLE IF NOT EXISTS morphdb_meta (version INTEGER NOT NULL)")
|
|
295
|
+
cur.execute("SELECT version FROM morphdb_meta LIMIT 1")
|
|
296
|
+
row = cur.fetchone()
|
|
297
|
+
if row is None:
|
|
298
|
+
cur.execute("INSERT INTO morphdb_meta (version) VALUES (0)")
|
|
299
|
+
return 0
|
|
300
|
+
return row["version"]
|
|
301
|
+
|
|
302
|
+
def set_user_version(self, raw, version):
|
|
303
|
+
with raw.cursor() as cur:
|
|
304
|
+
cur.execute("UPDATE morphdb_meta SET version = %s", (int(version),))
|
|
305
|
+
|
|
306
|
+
def table_columns(self, raw, table):
|
|
307
|
+
with raw.cursor() as cur:
|
|
308
|
+
cur.execute(
|
|
309
|
+
"SELECT column_name FROM information_schema.columns "
|
|
310
|
+
"WHERE table_schema = current_schema() AND table_name = %s "
|
|
311
|
+
"ORDER BY ordinal_position",
|
|
312
|
+
(table,))
|
|
313
|
+
return [r["column_name"] for r in cur.fetchall()]
|
|
314
|
+
|
|
315
|
+
def list_tables(self, raw):
|
|
316
|
+
with raw.cursor() as cur:
|
|
317
|
+
cur.execute(
|
|
318
|
+
"SELECT table_name FROM information_schema.tables "
|
|
319
|
+
"WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")
|
|
320
|
+
return [r["table_name"] for r in cur.fetchall()]
|