morphdb 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- morphdb/__main__.py +34 -0
- morphdb/apps.py +86 -0
- morphdb/associations.py +494 -0
- morphdb/db.py +168 -0
- morphdb/errors.py +36 -0
- morphdb/fieldtypes.py +351 -0
- morphdb/objects.py +395 -0
- morphdb/router.py +58 -0
- morphdb/routes.py +254 -0
- morphdb/schema.py +203 -0
- morphdb/server.py +194 -0
- morphdb/util.py +19 -0
- morphdb-0.1.0.dist-info/METADATA +324 -0
- morphdb-0.1.0.dist-info/RECORD +19 -0
- morphdb-0.1.0.dist-info/WHEEL +5 -0
- morphdb-0.1.0.dist-info/entry_points.txt +2 -0
- morphdb-0.1.0.dist-info/licenses/LICENSE +21 -0
- morphdb-0.1.0.dist-info/top_level.txt +1 -0
morphdb/__init__.py
ADDED
morphdb/__main__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CLI entry point: ``python -m morphdb`` or the ``morphdb`` console script."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .server import serve
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="morphdb",
|
|
13
|
+
description="MorphDB — a schema-fluid, API-stable database for AI-generated apps.",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("--host", default="127.0.0.1",
|
|
16
|
+
help="Host/interface to bind (default: 127.0.0.1).")
|
|
17
|
+
parser.add_argument("--port", type=int, default=8787,
|
|
18
|
+
help="Port to listen on (default: 8787).")
|
|
19
|
+
parser.add_argument("--db", default="morphdb.sqlite3",
|
|
20
|
+
help="SQLite file path, or ':memory:' (default: morphdb.sqlite3).")
|
|
21
|
+
parser.add_argument("--version", action="version",
|
|
22
|
+
version=f"morphdb {__version__}")
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
serve(host=args.host, port=args.port, db_path=args.db)
|
|
27
|
+
except OSError as e:
|
|
28
|
+
sys.stderr.write(f"[morphdb] failed to start: {e}\n")
|
|
29
|
+
return 1
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
raise SystemExit(main())
|
morphdb/apps.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Apps — the multi-tenant root.
|
|
2
|
+
|
|
3
|
+
One MorphDB process can back many independent websites. Each is an **app**,
|
|
4
|
+
identified by a unique *key* the client picks at registration (no UUID needed —
|
|
5
|
+
any reasonable string). Every schema and object lives under exactly one app and
|
|
6
|
+
is invisible to the others, so two apps may reuse the same type names without
|
|
7
|
+
colliding.
|
|
8
|
+
|
|
9
|
+
Wire protocol: every schema and object request carries its app via the
|
|
10
|
+
``X-App-Key`` HTTP header. A request with a missing or unknown key is refused —
|
|
11
|
+
there is no global, app-less namespace.
|
|
12
|
+
|
|
13
|
+
By design there is **no "list all apps" endpoint**: an agent only ever talks to
|
|
14
|
+
an app whose key it already knows (the key it registered). The surface is just
|
|
15
|
+
two calls, ``POST /app`` (register) and ``DELETE /app/{key}`` (delete + cascade).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from . import db
|
|
21
|
+
from .errors import bad_request, conflict, not_found
|
|
22
|
+
from .util import now_iso
|
|
23
|
+
|
|
24
|
+
# Header- and path-safe: letters, digits, '.', '_', '-'; must start
|
|
25
|
+
# alphanumeric; 1–128 chars. \Z (not $) so a trailing newline can't sneak in.
|
|
26
|
+
_APP_KEY_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_app_key(key):
|
|
30
|
+
if not isinstance(key, str) or not _APP_KEY_RE.match(key):
|
|
31
|
+
raise bad_request(
|
|
32
|
+
"Invalid app key. Use 1-128 characters from letters, digits, '.', "
|
|
33
|
+
"'_', or '-', starting with a letter or digit."
|
|
34
|
+
)
|
|
35
|
+
return key
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_app(key):
|
|
39
|
+
"""Create a new app under ``key``. Rejects (409) if the key already exists."""
|
|
40
|
+
validate_app_key(key)
|
|
41
|
+
with db.transaction() as c:
|
|
42
|
+
if c.execute("SELECT 1 FROM apps WHERE key = ?", (key,)).fetchone():
|
|
43
|
+
raise conflict(
|
|
44
|
+
f"App '{key}' already exists. Pick a different, unused key."
|
|
45
|
+
)
|
|
46
|
+
c.execute("INSERT INTO apps (key, created_at) VALUES (?, ?)", (key, now_iso()))
|
|
47
|
+
return {"key": key, "created": True}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def delete_app(key):
|
|
51
|
+
"""Delete an app and (via ON DELETE CASCADE) all of its schemas, objects,
|
|
52
|
+
relationship definitions, and edges. Other apps are untouched.
|
|
53
|
+
"""
|
|
54
|
+
with db.transaction() as c:
|
|
55
|
+
if c.execute("SELECT 1 FROM apps WHERE key = ?", (key,)).fetchone() is None:
|
|
56
|
+
raise not_found(f"No app '{key}'.")
|
|
57
|
+
c.execute("DELETE FROM apps WHERE key = ?", (key,))
|
|
58
|
+
return {"deleted": key}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def app_exists(key):
|
|
62
|
+
return db.conn().execute(
|
|
63
|
+
"SELECT 1 FROM apps WHERE key = ?", (key,)
|
|
64
|
+
).fetchone() is not None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require_app(req):
|
|
68
|
+
"""Resolve and validate the app key for a schema/object request.
|
|
69
|
+
|
|
70
|
+
Reads the ``X-App-Key`` header, checks its format, and confirms the app is
|
|
71
|
+
registered. Raises 400 if the header is missing/malformed, 404 if the app is
|
|
72
|
+
unknown. Returns the validated key for the handler to scope its work to.
|
|
73
|
+
"""
|
|
74
|
+
key = req.headers.get("X-App-Key")
|
|
75
|
+
if key is None or not key.strip():
|
|
76
|
+
raise bad_request(
|
|
77
|
+
"Missing X-App-Key header. Register an app with POST /app, then send "
|
|
78
|
+
"its key as X-App-Key on every schema and object request."
|
|
79
|
+
)
|
|
80
|
+
key = key.strip()
|
|
81
|
+
validate_app_key(key)
|
|
82
|
+
if not app_exists(key):
|
|
83
|
+
raise not_found(
|
|
84
|
+
f"Unknown app '{key}'. Register it first with POST /app."
|
|
85
|
+
)
|
|
86
|
+
return key
|
morphdb/associations.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Relationships between objects — exposed as relation *fields* on a type.
|
|
2
|
+
|
|
3
|
+
A relationship is declared inside an object type's schema, under ``relations``::
|
|
4
|
+
|
|
5
|
+
"assignee": {"to": "user", "cardinality": "many_to_one", "inverse": "tasks"}
|
|
6
|
+
|
|
7
|
+
Declared once (on the ``from`` side), it automatically shows up on the other
|
|
8
|
+
type as the inverse relation (``user.tasks``). The frontend then reads and
|
|
9
|
+
writes relations exactly like ordinary fields: a relation value is a neighbor
|
|
10
|
+
guid (to-one) or a list of neighbor guids (to-many), right inside the object
|
|
11
|
+
body. Behind the scenes each relationship is still its own type in the
|
|
12
|
+
``association_schemas`` table and each edge is a single canonical row in
|
|
13
|
+
``associations`` — single-row storage keeps bidirectional traversal consistent
|
|
14
|
+
with no dual-write hazard.
|
|
15
|
+
|
|
16
|
+
Everything here is scoped to one **app**: every query carries the app key, so
|
|
17
|
+
relationships and edges never cross tenant boundaries (a relation target must be
|
|
18
|
+
an object in the same app).
|
|
19
|
+
|
|
20
|
+
Multiplicity, from a given object's point of view:
|
|
21
|
+
cardinality "X_to_Y" → the from-side sees Y neighbors, the to-side sees X.
|
|
22
|
+
So ``many_to_one`` (many tasks → one user): a task's ``assignee`` is one guid;
|
|
23
|
+
a user's ``tasks`` is a list.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from . import db
|
|
27
|
+
from .errors import bad_request
|
|
28
|
+
from .fieldtypes import validate_member_name
|
|
29
|
+
from .util import now_iso
|
|
30
|
+
|
|
31
|
+
CARDINALITIES = {"one_to_one", "one_to_many", "many_to_one", "many_to_many"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _assoc_name(from_type, forward_name):
|
|
35
|
+
"""Internal name for a relationship: ``{from_type}__{forward_name}``.
|
|
36
|
+
|
|
37
|
+
Unique within an app (paired with the app key in the table's primary key);
|
|
38
|
+
never exposed in the API — the agent addresses relations by their field name
|
|
39
|
+
on a type, not by this synthetic id.
|
|
40
|
+
"""
|
|
41
|
+
return f"{from_type}__{forward_name}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _mult(cardinality, side):
|
|
45
|
+
"""Multiplicity ('one'/'many') seen from one end of a relationship.
|
|
46
|
+
|
|
47
|
+
side='from' → the to-part of the cardinality; side='to' → the from-part.
|
|
48
|
+
"""
|
|
49
|
+
frm, to = cardinality.split("_to_")
|
|
50
|
+
return to if side == "from" else frm
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- declaring relations (called by schema.py inside a transaction) -----------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_bool(v):
|
|
57
|
+
if isinstance(v, str):
|
|
58
|
+
return v.strip().lower() in ("true", "1", "yes", "on")
|
|
59
|
+
return bool(v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_relation_def(from_type, key, raw):
|
|
63
|
+
"""Validate one ``relations`` entry and return a normalized assoc-schema dict.
|
|
64
|
+
|
|
65
|
+
``key`` is the relation's name on ``from_type`` (its forward_name).
|
|
66
|
+
"""
|
|
67
|
+
validate_member_name(key, "relation")
|
|
68
|
+
if not isinstance(raw, dict):
|
|
69
|
+
raise bad_request(
|
|
70
|
+
f"Relation '{key}' must be an object like "
|
|
71
|
+
"{\"to\": \"user\", \"cardinality\": \"many_to_one\", \"inverse\": \"tasks\"}."
|
|
72
|
+
)
|
|
73
|
+
to_type = raw.get("to")
|
|
74
|
+
if not to_type or not isinstance(to_type, str):
|
|
75
|
+
raise bad_request(f"Relation '{key}' needs a 'to' object type.")
|
|
76
|
+
cardinality = raw.get("cardinality")
|
|
77
|
+
if cardinality not in CARDINALITIES:
|
|
78
|
+
raise bad_request(
|
|
79
|
+
f"Relation '{key}' has unknown cardinality {cardinality!r}. "
|
|
80
|
+
f"One of {sorted(CARDINALITIES)}."
|
|
81
|
+
)
|
|
82
|
+
symmetric = _parse_bool(raw.get("symmetric", False))
|
|
83
|
+
fdesc = raw.get("description")
|
|
84
|
+
idesc = raw.get("inverse_description")
|
|
85
|
+
|
|
86
|
+
if symmetric:
|
|
87
|
+
# A symmetric relationship is mutual (friends, peers): edge A–B == B–A.
|
|
88
|
+
# Only meaningful within one type, with a single shared label.
|
|
89
|
+
if to_type != from_type:
|
|
90
|
+
raise bad_request(
|
|
91
|
+
f"Relation '{key}' is symmetric, so 'to' must be '{from_type}'."
|
|
92
|
+
)
|
|
93
|
+
if cardinality not in ("one_to_one", "many_to_many"):
|
|
94
|
+
raise bad_request(
|
|
95
|
+
f"Symmetric relation '{key}' must be one_to_one or many_to_many."
|
|
96
|
+
)
|
|
97
|
+
inverse = key # one label describes both ends
|
|
98
|
+
idesc = fdesc
|
|
99
|
+
else:
|
|
100
|
+
inverse = raw.get("inverse")
|
|
101
|
+
if not inverse or not isinstance(inverse, str):
|
|
102
|
+
raise bad_request(
|
|
103
|
+
f"Relation '{key}' needs an 'inverse' name (the label the other "
|
|
104
|
+
"side sees), e.g. \"inverse\": \"tasks\"."
|
|
105
|
+
)
|
|
106
|
+
validate_member_name(inverse, "relation")
|
|
107
|
+
if from_type == to_type and inverse == key:
|
|
108
|
+
raise bad_request(
|
|
109
|
+
f"Relation '{key}' is a self-relation with identical forward and "
|
|
110
|
+
"inverse names; either give the inverse a different name or set "
|
|
111
|
+
"\"symmetric\": true."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"name": _assoc_name(from_type, key),
|
|
116
|
+
"from_type": from_type,
|
|
117
|
+
"to_type": to_type,
|
|
118
|
+
"forward_name": key,
|
|
119
|
+
"inverse_name": inverse,
|
|
120
|
+
"cardinality": cardinality,
|
|
121
|
+
"symmetric": symmetric,
|
|
122
|
+
"forward_description": fdesc,
|
|
123
|
+
"inverse_description": idesc,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def upsert_relation(c, app, from_type, key, raw):
|
|
128
|
+
"""Create or update one relationship declared on ``from_type`` in ``app``.
|
|
129
|
+
|
|
130
|
+
Runs inside the caller's transaction cursor ``c``. The ``to`` type must
|
|
131
|
+
already exist in the same app. Symmetric edges are canonicalized if the flag
|
|
132
|
+
is (re)set.
|
|
133
|
+
"""
|
|
134
|
+
d = normalize_relation_def(from_type, key, raw)
|
|
135
|
+
# The neighbor type must exist (in this app) so traversal/validation works.
|
|
136
|
+
if c.execute("SELECT 1 FROM object_schemas WHERE app=? AND name=?",
|
|
137
|
+
(app, d["to_type"])).fetchone() is None:
|
|
138
|
+
raise bad_request(
|
|
139
|
+
f"Relation '{key}' points to unknown type '{d['to_type']}'. Define it first."
|
|
140
|
+
)
|
|
141
|
+
ts = now_iso()
|
|
142
|
+
exists = c.execute(
|
|
143
|
+
"SELECT 1 FROM association_schemas WHERE app=? AND name=?", (app, d["name"])
|
|
144
|
+
).fetchone()
|
|
145
|
+
if exists:
|
|
146
|
+
c.execute(
|
|
147
|
+
"UPDATE association_schemas SET from_type=?, to_type=?, forward_name=?, "
|
|
148
|
+
"inverse_name=?, cardinality=?, symmetric=?, forward_description=?, "
|
|
149
|
+
"inverse_description=?, updated_at=? WHERE app=? AND name=?",
|
|
150
|
+
(d["from_type"], d["to_type"], d["forward_name"], d["inverse_name"],
|
|
151
|
+
d["cardinality"], int(d["symmetric"]), d["forward_description"],
|
|
152
|
+
d["inverse_description"], ts, app, d["name"]),
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
c.execute(
|
|
156
|
+
"INSERT INTO association_schemas (app, name, from_type, to_type, "
|
|
157
|
+
"forward_name, inverse_name, cardinality, symmetric, "
|
|
158
|
+
"forward_description, inverse_description, created_at, updated_at) "
|
|
159
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
160
|
+
(app, d["name"], d["from_type"], d["to_type"], d["forward_name"],
|
|
161
|
+
d["inverse_name"], d["cardinality"], int(d["symmetric"]),
|
|
162
|
+
d["forward_description"], d["inverse_description"], ts, ts),
|
|
163
|
+
)
|
|
164
|
+
if d["symmetric"]:
|
|
165
|
+
_canonicalize_symmetric_edges(c, app, d["name"])
|
|
166
|
+
return d
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def prune_forward_relations(c, app, from_type, keep_keys):
|
|
170
|
+
"""On a replace (merge=false), drop relations authored on ``from_type`` that
|
|
171
|
+
are no longer listed — along with their edges. Inverse relations (authored on
|
|
172
|
+
the other type) are never touched here.
|
|
173
|
+
"""
|
|
174
|
+
rows = c.execute(
|
|
175
|
+
"SELECT name, forward_name FROM association_schemas WHERE app=? AND from_type=?",
|
|
176
|
+
(app, from_type),
|
|
177
|
+
).fetchall()
|
|
178
|
+
for r in rows:
|
|
179
|
+
if r["forward_name"] not in keep_keys:
|
|
180
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?",
|
|
181
|
+
(app, r["name"]))
|
|
182
|
+
c.execute("DELETE FROM association_schemas WHERE app=? AND name=?",
|
|
183
|
+
(app, r["name"]))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def delete_relations_touching_type(c, app, type_name):
|
|
187
|
+
"""Delete every relationship (and its edges) in ``app`` where ``type_name``
|
|
188
|
+
is an endpoint. Used when a whole object type is deleted. Neighbor objects on
|
|
189
|
+
the other side are NOT removed by this — only the relationship metadata + edges.
|
|
190
|
+
"""
|
|
191
|
+
rows = c.execute(
|
|
192
|
+
"SELECT name FROM association_schemas WHERE app=? AND (from_type=? OR to_type=?)",
|
|
193
|
+
(app, type_name, type_name),
|
|
194
|
+
).fetchall()
|
|
195
|
+
for r in rows:
|
|
196
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?",
|
|
197
|
+
(app, r["name"]))
|
|
198
|
+
c.execute(
|
|
199
|
+
"DELETE FROM association_schemas WHERE app=? AND (from_type=? OR to_type=?)",
|
|
200
|
+
(app, type_name, type_name),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _canonicalize_symmetric_edges(c, app, name):
|
|
205
|
+
"""Rewrite a now-symmetric relationship's edges into canonical (sorted)
|
|
206
|
+
order and drop reverse duplicates, so A–B and B–A collapse to one row.
|
|
207
|
+
"""
|
|
208
|
+
rows = c.execute(
|
|
209
|
+
"SELECT from_guid, to_guid, created_at FROM associations WHERE app=? AND assoc_name=?",
|
|
210
|
+
(app, name),
|
|
211
|
+
).fetchall()
|
|
212
|
+
canon = {}
|
|
213
|
+
for r in rows:
|
|
214
|
+
a, b = sorted((r["from_guid"], r["to_guid"]))
|
|
215
|
+
prev = canon.get((a, b))
|
|
216
|
+
if prev is None or r["created_at"] < prev:
|
|
217
|
+
canon[(a, b)] = r["created_at"]
|
|
218
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?", (app, name))
|
|
219
|
+
for (a, b), created in canon.items():
|
|
220
|
+
c.execute(
|
|
221
|
+
"INSERT INTO associations (app, assoc_name, from_guid, to_guid, created_at) "
|
|
222
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
223
|
+
(app, name, a, b, created),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- relation "views" (one per relation a type exposes) -----------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _all_assoc_schemas(app, c=None):
|
|
231
|
+
ex = (c or db.conn()).execute
|
|
232
|
+
return ex("SELECT * FROM association_schemas WHERE app=?", (app,)).fetchall()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def relation_views(app, type_name, c=None):
|
|
236
|
+
"""All relations visible on ``type_name`` in ``app``, each as a view dict:
|
|
237
|
+
|
|
238
|
+
{key, assoc_name, side, mult, neighbor_type, cardinality, symmetric}
|
|
239
|
+
|
|
240
|
+
``side`` is 'from', 'to', or 'sym' — how this type sits on the edge.
|
|
241
|
+
``mult`` is 'one'/'many' — whether the value is a scalar guid or a list.
|
|
242
|
+
A self-relation (non-symmetric) yields two views: forward and inverse.
|
|
243
|
+
"""
|
|
244
|
+
views = []
|
|
245
|
+
for s in _all_assoc_schemas(app, c):
|
|
246
|
+
sym = bool(s["symmetric"])
|
|
247
|
+
if sym:
|
|
248
|
+
if type_name in (s["from_type"], s["to_type"]):
|
|
249
|
+
views.append({
|
|
250
|
+
"key": s["forward_name"], "assoc_name": s["name"], "side": "sym",
|
|
251
|
+
"mult": _mult(s["cardinality"], "from"),
|
|
252
|
+
"neighbor_type": s["from_type"], # == to_type for symmetric
|
|
253
|
+
"cardinality": s["cardinality"], "symmetric": True,
|
|
254
|
+
})
|
|
255
|
+
continue
|
|
256
|
+
if s["from_type"] == type_name:
|
|
257
|
+
views.append({
|
|
258
|
+
"key": s["forward_name"], "assoc_name": s["name"], "side": "from",
|
|
259
|
+
"mult": _mult(s["cardinality"], "from"), "neighbor_type": s["to_type"],
|
|
260
|
+
"cardinality": s["cardinality"], "symmetric": False,
|
|
261
|
+
})
|
|
262
|
+
if s["to_type"] == type_name:
|
|
263
|
+
views.append({
|
|
264
|
+
"key": s["inverse_name"], "assoc_name": s["name"], "side": "to",
|
|
265
|
+
"mult": _mult(s["cardinality"], "to"), "neighbor_type": s["from_type"],
|
|
266
|
+
"cardinality": s["cardinality"], "symmetric": False,
|
|
267
|
+
})
|
|
268
|
+
return views
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def relation_keys(app, type_name, c=None):
|
|
272
|
+
return {v["key"] for v in relation_views(app, type_name, c)}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def schema_relations(app, type_name, c=None):
|
|
276
|
+
"""Return (relations, inverse_relations) dicts for a type's schema document.
|
|
277
|
+
|
|
278
|
+
``relations`` are authored on this type (editable); ``inverse_relations``
|
|
279
|
+
are the read-only mirror of relations authored on another type.
|
|
280
|
+
"""
|
|
281
|
+
relations, inverse = {}, {}
|
|
282
|
+
for s in _all_assoc_schemas(app, c):
|
|
283
|
+
sym = bool(s["symmetric"])
|
|
284
|
+
if sym and type_name in (s["from_type"], s["to_type"]):
|
|
285
|
+
relations[s["forward_name"]] = {
|
|
286
|
+
"to": s["to_type"], "cardinality": s["cardinality"],
|
|
287
|
+
"symmetric": True, "description": s["forward_description"],
|
|
288
|
+
}
|
|
289
|
+
continue
|
|
290
|
+
if not sym and s["from_type"] == type_name:
|
|
291
|
+
relations[s["forward_name"]] = {
|
|
292
|
+
"to": s["to_type"], "cardinality": s["cardinality"],
|
|
293
|
+
"inverse": s["inverse_name"], "symmetric": False,
|
|
294
|
+
"description": s["forward_description"],
|
|
295
|
+
"inverse_description": s["inverse_description"],
|
|
296
|
+
}
|
|
297
|
+
if not sym and s["to_type"] == type_name:
|
|
298
|
+
inverse[s["inverse_name"]] = {
|
|
299
|
+
"to": s["from_type"], "cardinality": _flip(s["cardinality"]),
|
|
300
|
+
"inverse": s["forward_name"], "symmetric": False,
|
|
301
|
+
"via_type": s["from_type"], "via_relation": s["forward_name"],
|
|
302
|
+
"readonly": True, "description": s["inverse_description"],
|
|
303
|
+
}
|
|
304
|
+
return relations, inverse
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _flip(cardinality):
|
|
308
|
+
frm, to = cardinality.split("_to_")
|
|
309
|
+
return f"{to}_to_{frm}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --- projecting relations onto object bodies (read) ---------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def project_relations(app, guids, type_name):
|
|
316
|
+
"""Map each guid to its relation values: {guid: {relation_key: guid|[guid]}}.
|
|
317
|
+
|
|
318
|
+
Batched: one query per relationship type touching the page of objects, so a
|
|
319
|
+
list read does not fan out into a query per object. Scoped to ``app``.
|
|
320
|
+
"""
|
|
321
|
+
views = relation_views(app, type_name)
|
|
322
|
+
result = {g: {} for g in guids}
|
|
323
|
+
if not guids:
|
|
324
|
+
return result
|
|
325
|
+
# Seed every relation with its empty value so the shape is stable.
|
|
326
|
+
for g in guids:
|
|
327
|
+
for v in views:
|
|
328
|
+
result[g][v["key"]] = [] if v["mult"] == "many" else None
|
|
329
|
+
if not views:
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
qmarks = ",".join("?" * len(guids))
|
|
333
|
+
gset = set(guids)
|
|
334
|
+
by_assoc = {}
|
|
335
|
+
for v in views:
|
|
336
|
+
by_assoc.setdefault(v["assoc_name"], []).append(v)
|
|
337
|
+
|
|
338
|
+
for assoc_name, vs in by_assoc.items():
|
|
339
|
+
rows = db.conn().execute(
|
|
340
|
+
f"SELECT from_guid, to_guid FROM associations WHERE app=? AND assoc_name=? "
|
|
341
|
+
f"AND (from_guid IN ({qmarks}) OR to_guid IN ({qmarks})) "
|
|
342
|
+
f"ORDER BY created_at, id",
|
|
343
|
+
[app, assoc_name, *guids, *guids],
|
|
344
|
+
).fetchall()
|
|
345
|
+
for v in vs:
|
|
346
|
+
side, key, mult = v["side"], v["key"], v["mult"]
|
|
347
|
+
for r in rows:
|
|
348
|
+
fg, tg = r["from_guid"], r["to_guid"]
|
|
349
|
+
if side == "from":
|
|
350
|
+
if fg in gset:
|
|
351
|
+
_assign(result[fg], key, mult, tg)
|
|
352
|
+
elif side == "to":
|
|
353
|
+
if tg in gset:
|
|
354
|
+
_assign(result[tg], key, mult, fg)
|
|
355
|
+
else: # symmetric: object may be on either end
|
|
356
|
+
if fg in gset:
|
|
357
|
+
_assign(result[fg], key, mult, tg)
|
|
358
|
+
if tg in gset:
|
|
359
|
+
_assign(result[tg], key, mult, fg)
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _assign(bucket, key, mult, neighbor):
|
|
364
|
+
if mult == "many":
|
|
365
|
+
bucket[key].append(neighbor)
|
|
366
|
+
elif bucket.get(key) is None:
|
|
367
|
+
bucket[key] = neighbor
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --- writing relations as fields (set-as-field, last-write-wins) --------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def apply_relation_writes(c, app, obj_guid, type_name, rel_part):
|
|
374
|
+
"""Apply the relation keys present in an object write.
|
|
375
|
+
|
|
376
|
+
``rel_part`` maps relation keys to their desired value (a guid / list of
|
|
377
|
+
guids, or null/[] to clear). Only listed relations are touched; others are
|
|
378
|
+
left as-is. Set semantics: the listed value becomes the relation's full set.
|
|
379
|
+
"""
|
|
380
|
+
views = {v["key"]: v for v in relation_views(app, type_name, c)}
|
|
381
|
+
for key, value in rel_part.items():
|
|
382
|
+
view = views[key]
|
|
383
|
+
desired = _desired_guids(key, view, value)
|
|
384
|
+
_set_relation(c, app, obj_guid, view, desired)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _desired_guids(key, view, value):
|
|
388
|
+
if view["mult"] == "one":
|
|
389
|
+
if value in (None, ""):
|
|
390
|
+
return []
|
|
391
|
+
if not isinstance(value, str):
|
|
392
|
+
raise bad_request(
|
|
393
|
+
f"Relation '{key}' is to-one; expected a single guid string or null, "
|
|
394
|
+
f"got {type(value).__name__}."
|
|
395
|
+
)
|
|
396
|
+
return [value]
|
|
397
|
+
# to-many
|
|
398
|
+
if value is None:
|
|
399
|
+
return []
|
|
400
|
+
if not isinstance(value, (list, tuple)):
|
|
401
|
+
raise bad_request(
|
|
402
|
+
f"Relation '{key}' is to-many; expected a list of guids (or [] to clear), "
|
|
403
|
+
f"got {type(value).__name__}."
|
|
404
|
+
)
|
|
405
|
+
seen, out = set(), []
|
|
406
|
+
for g in value:
|
|
407
|
+
if not isinstance(g, str):
|
|
408
|
+
raise bad_request(f"Relation '{key}' values must be guid strings.")
|
|
409
|
+
if g not in seen:
|
|
410
|
+
seen.add(g)
|
|
411
|
+
out.append(g)
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _set_relation(c, app, obj_guid, view, desired):
|
|
416
|
+
name = view["assoc_name"]
|
|
417
|
+
side = view["side"]
|
|
418
|
+
|
|
419
|
+
# Current neighbors for this relation, from obj's point of view.
|
|
420
|
+
if side == "from":
|
|
421
|
+
rows = c.execute(
|
|
422
|
+
"SELECT id, to_guid AS nb FROM associations WHERE app=? AND assoc_name=? AND from_guid=?",
|
|
423
|
+
(app, name, obj_guid)).fetchall()
|
|
424
|
+
elif side == "to":
|
|
425
|
+
rows = c.execute(
|
|
426
|
+
"SELECT id, from_guid AS nb FROM associations WHERE app=? AND assoc_name=? AND to_guid=?",
|
|
427
|
+
(app, name, obj_guid)).fetchall()
|
|
428
|
+
else: # sym
|
|
429
|
+
rows = c.execute(
|
|
430
|
+
"SELECT id, from_guid, to_guid FROM associations WHERE app=? AND assoc_name=? "
|
|
431
|
+
"AND (from_guid=? OR to_guid=?)", (app, name, obj_guid, obj_guid)).fetchall()
|
|
432
|
+
current = {}
|
|
433
|
+
for r in rows:
|
|
434
|
+
nb = r["nb"] if side != "sym" else (
|
|
435
|
+
r["to_guid"] if r["from_guid"] == obj_guid else r["from_guid"])
|
|
436
|
+
current[nb] = r["id"]
|
|
437
|
+
|
|
438
|
+
desired_set = set(desired)
|
|
439
|
+
# Remove edges no longer wanted.
|
|
440
|
+
for nb, eid in current.items():
|
|
441
|
+
if nb not in desired_set:
|
|
442
|
+
c.execute("DELETE FROM associations WHERE id=?", (eid,))
|
|
443
|
+
|
|
444
|
+
other_mult = _mult(view["cardinality"], "to" if side == "from" else "from")
|
|
445
|
+
ts = now_iso()
|
|
446
|
+
for nb in desired:
|
|
447
|
+
if nb in current:
|
|
448
|
+
continue
|
|
449
|
+
_validate_target(c, app, view, obj_guid, nb)
|
|
450
|
+
# Last-write-wins: if the neighbor's slot on the other side is single and
|
|
451
|
+
# already taken, steal it (delete the conflicting edge).
|
|
452
|
+
if other_mult == "one":
|
|
453
|
+
_free_target_slot(c, app, name, side, nb)
|
|
454
|
+
fg, tg = _edge_endpoints(side, obj_guid, nb)
|
|
455
|
+
c.execute(
|
|
456
|
+
"INSERT OR IGNORE INTO associations (app, assoc_name, from_guid, to_guid, created_at) "
|
|
457
|
+
"VALUES (?, ?, ?, ?, ?)", (app, name, fg, tg, ts))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _edge_endpoints(side, obj_guid, neighbor):
|
|
461
|
+
if side == "from":
|
|
462
|
+
return obj_guid, neighbor
|
|
463
|
+
if side == "to":
|
|
464
|
+
return neighbor, obj_guid
|
|
465
|
+
return tuple(sorted((obj_guid, neighbor))) # symmetric: canonical order
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _free_target_slot(c, app, name, side, neighbor):
|
|
469
|
+
if side == "from":
|
|
470
|
+
# neighbor sits on the to-side; its single inbound slot must be freed.
|
|
471
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND to_guid=?",
|
|
472
|
+
(app, name, neighbor))
|
|
473
|
+
elif side == "to":
|
|
474
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND from_guid=?",
|
|
475
|
+
(app, name, neighbor))
|
|
476
|
+
else: # symmetric one_to_one
|
|
477
|
+
c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND (from_guid=? OR to_guid=?)",
|
|
478
|
+
(app, name, neighbor, neighbor))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _validate_target(c, app, view, obj_guid, neighbor):
|
|
482
|
+
if neighbor == obj_guid:
|
|
483
|
+
raise bad_request("Self-referential edges (an object related to itself) are not allowed.")
|
|
484
|
+
# The target must be an object in the SAME app — relations never cross apps.
|
|
485
|
+
row = c.execute(
|
|
486
|
+
"SELECT object_type FROM objects WHERE app=? AND guid=?",
|
|
487
|
+
(app, neighbor)).fetchone()
|
|
488
|
+
if row is None:
|
|
489
|
+
raise bad_request(
|
|
490
|
+
f"Relation '{view['key']}' target '{neighbor}' does not exist.")
|
|
491
|
+
if row["object_type"] != view["neighbor_type"]:
|
|
492
|
+
raise bad_request(
|
|
493
|
+
f"Relation '{view['key']}' expects a '{view['neighbor_type']}', but "
|
|
494
|
+
f"'{neighbor}' is a '{row['object_type']}'.")
|