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/objects.py ADDED
@@ -0,0 +1,395 @@
1
+ """Object instances — the data the website reads and writes.
2
+
3
+ Every object has a globally unique ``_guid`` and belongs to one object type
4
+ inside one **app**. Raw field values are stored as a JSON blob and projected
5
+ through the current schema on every read (lazy invalidation), so schema edits
6
+ never touch rows. Relations are *not* in the blob — they live as edges (see
7
+ :mod:`associations`) and are folded into the object body on read / exploded into
8
+ edges on write, so the frontend treats a link like any other field.
9
+
10
+ Every read and write is scoped to the caller's app: a guid that belongs to a
11
+ different app is treated as not found, so apps are fully isolated even though
12
+ guids are globally unique.
13
+
14
+ Output shape is flat with underscore-prefixed system fields:
15
+
16
+ {"_guid", "_type", "_created_at", "_updated_at", <field>: <value>,
17
+ <relation>: <neighbor-guid | [neighbor-guids]>, ...}
18
+
19
+ Schema field/relation names may not start with ``_`` (enforced at schema-define
20
+ time), so there is never a collision with system fields.
21
+ """
22
+
23
+ import json
24
+
25
+ from . import associations as assoc
26
+ from . import db
27
+ from .errors import bad_request, not_found
28
+ from .fieldtypes import project_data, validate_against_schema
29
+ from .schema import get_object_schema
30
+ from .util import new_guid, now_iso
31
+
32
+ RESERVED_QUERY_KEYS = {"limit", "offset", "sort", "order"}
33
+ DEFAULT_LIMIT = 100
34
+ MAX_LIMIT = 1000
35
+
36
+ _OPS = {"eq", "ne", "gt", "gte", "lt", "lte", "contains", "in", "exists"}
37
+
38
+
39
+ def _project_fields(row, fields):
40
+ data = project_data(json.loads(row["data"]), fields)
41
+ out = {
42
+ "_guid": row["guid"],
43
+ "_type": row["object_type"],
44
+ "_created_at": row["created_at"],
45
+ "_updated_at": row["updated_at"],
46
+ }
47
+ out.update(data)
48
+ return out
49
+
50
+
51
+ def _project_full(app, row, fields, object_type):
52
+ """A single object's full body: system fields + fields + relation values."""
53
+ out = _project_fields(row, fields)
54
+ rels = assoc.project_relations(app, [row["guid"]], object_type)[row["guid"]]
55
+ out.update(rels)
56
+ return out
57
+
58
+
59
+ def _split_body(app, object_type, body, fields, c=None):
60
+ """Partition an incoming write into (field values, relation values).
61
+
62
+ Keys that are neither a declared field nor a relation (and not a system
63
+ ``_`` key echoed back from a read) are rejected, so typos surface early.
64
+ """
65
+ rel_keys = assoc.relation_keys(app, object_type, c)
66
+ field_part, rel_part = {}, {}
67
+ for k, v in body.items():
68
+ if k in fields:
69
+ field_part[k] = v
70
+ elif k in rel_keys:
71
+ rel_part[k] = v
72
+ elif k.startswith("_"):
73
+ continue # tolerate _guid/_type echoed back from a prior read
74
+ else:
75
+ raise bad_request(
76
+ f"Unknown field/relation '{k}' on type '{object_type}'. "
77
+ f"Fields: {sorted(fields)}; relations: {sorted(rel_keys)}. "
78
+ "Update the schema first, or remove the stray key."
79
+ )
80
+ return field_part, rel_part
81
+
82
+
83
+ # --- writes -------------------------------------------------------------------
84
+
85
+
86
+ def create_object(app, object_type, data):
87
+ schema = get_object_schema(app, object_type, required=True)
88
+ fields = schema["fields"]
89
+ guid = new_guid(object_type)
90
+ ts = now_iso()
91
+ with db.transaction() as c:
92
+ field_part, rel_part = _split_body(app, object_type, data or {}, fields, c)
93
+ clean = validate_against_schema(field_part, fields, partial=False)
94
+ c.execute(
95
+ "INSERT INTO objects (guid, app, object_type, data, created_at, updated_at) "
96
+ "VALUES (?, ?, ?, ?, ?, ?)",
97
+ (guid, app, object_type, json.dumps(clean), ts, ts),
98
+ )
99
+ if rel_part:
100
+ assoc.apply_relation_writes(c, app, guid, object_type, rel_part)
101
+ row = c.execute("SELECT * FROM objects WHERE guid = ?", (guid,)).fetchone()
102
+ return _project_full(app, row, fields, object_type)
103
+
104
+
105
+ def upsert_object(app, object_type, guid, data, partial=True):
106
+ """Create-or-update an object at a caller-supplied guid, within ``app``.
107
+
108
+ Fields follow ``partial``: PATCH (partial=True) merges the given fields, PUT
109
+ (partial=False) replaces the field set (and re-checks required fields).
110
+ Relations are always patch-style: only relation keys present in the body are
111
+ touched; each present relation's value becomes its full set (set-as-field).
112
+ """
113
+ schema = get_object_schema(app, object_type, required=True)
114
+ fields = schema["fields"]
115
+ ts = now_iso()
116
+ with db.transaction() as c:
117
+ existing = c.execute(
118
+ "SELECT * FROM objects WHERE guid = ?", (guid,)
119
+ ).fetchone()
120
+ if existing is not None and existing["app"] != app:
121
+ # The guid is owned by another app; from here it simply doesn't exist.
122
+ raise not_found(f"No object with guid '{guid}'.")
123
+ if existing is not None and existing["object_type"] != object_type:
124
+ raise bad_request(
125
+ f"Object '{guid}' already exists with type "
126
+ f"'{existing['object_type']}', not '{object_type}'."
127
+ )
128
+
129
+ field_part, rel_part = _split_body(app, object_type, data or {}, fields, c)
130
+ clean = validate_against_schema(field_part, fields, partial=partial)
131
+
132
+ if existing is None:
133
+ c.execute(
134
+ "INSERT INTO objects (guid, app, object_type, data, created_at, updated_at) "
135
+ "VALUES (?, ?, ?, ?, ?, ?)",
136
+ (guid, app, object_type, json.dumps(clean), ts, ts),
137
+ )
138
+ else:
139
+ blob = json.loads(existing["data"]) if partial else {}
140
+ blob.update(clean)
141
+ c.execute(
142
+ "UPDATE objects SET data = ?, updated_at = ? WHERE guid = ?",
143
+ (json.dumps(blob), ts, guid),
144
+ )
145
+ if rel_part:
146
+ assoc.apply_relation_writes(c, app, guid, object_type, rel_part)
147
+ row = c.execute("SELECT * FROM objects WHERE guid = ?", (guid,)).fetchone()
148
+ return _project_full(app, row, fields, object_type)
149
+
150
+
151
+ def delete_object(app, guid):
152
+ with db.transaction() as c:
153
+ row = c.execute(
154
+ "SELECT * FROM objects WHERE app = ? AND guid = ?", (app, guid)
155
+ ).fetchone()
156
+ if row is None:
157
+ raise not_found(f"No object with guid '{guid}'.")
158
+ c.execute("DELETE FROM objects WHERE app = ? AND guid = ?", (app, guid))
159
+ # Remove this object's edges only; neighbor objects are left intact.
160
+ c.execute(
161
+ "DELETE FROM associations WHERE app = ? AND (from_guid = ? OR to_guid = ?)",
162
+ (app, guid, guid),
163
+ )
164
+ return {"deleted": guid}
165
+
166
+
167
+ # --- reads --------------------------------------------------------------------
168
+
169
+
170
+ def get_object(app, guid, object_type=None):
171
+ row = db.conn().execute(
172
+ "SELECT * FROM objects WHERE app = ? AND guid = ?", (app, guid)
173
+ ).fetchone()
174
+ if row is None:
175
+ raise not_found(f"No object with guid '{guid}'.")
176
+ if object_type is not None and row["object_type"] != object_type:
177
+ raise not_found(
178
+ f"Object '{guid}' is of type '{row['object_type']}', not '{object_type}'."
179
+ )
180
+ schema = get_object_schema(app, row["object_type"], required=True)
181
+ return _project_full(app, row, schema["fields"], row["object_type"])
182
+
183
+
184
+ def _parse_filter_key(key, fields):
185
+ if "__" in key:
186
+ field, op = key.rsplit("__", 1)
187
+ if op not in _OPS:
188
+ # treat the whole thing as a field name with default eq
189
+ field, op = key, "eq"
190
+ else:
191
+ field, op = key, "eq"
192
+ if field not in fields:
193
+ raise bad_request(
194
+ f"Cannot filter on unknown field '{field}'. "
195
+ f"Declared fields: {sorted(fields)}. (Filtering is on fields, not relations.)"
196
+ )
197
+ return field, op
198
+
199
+
200
+ def _coerce_filter_value(field, op, raw, ftype):
201
+ from .fieldtypes import coerce_value
202
+
203
+ if op == "exists":
204
+ # raw is "true"/"false"
205
+ return coerce_value(field, raw, "boolean")
206
+ if ftype == "json":
207
+ raise bad_request(f"Cannot filter on json field '{field}'.")
208
+ if op == "in":
209
+ parts = raw.split(",") if isinstance(raw, str) else list(raw)
210
+ return [coerce_value(field, p, ftype) for p in parts]
211
+ if op == "contains":
212
+ return str(raw)
213
+ return coerce_value(field, raw, ftype)
214
+
215
+
216
+ _INT64_MAX = 2 ** 63 - 1
217
+ _INT64_MIN = -(2 ** 63)
218
+
219
+
220
+ def _safe_bind(v):
221
+ """Make a value safe to bind as a SQLite parameter.
222
+
223
+ Python ints outside the signed-64-bit range cannot be bound (sqlite3 raises
224
+ OverflowError). Such magnitudes are beyond SQLite's integer precision
225
+ anyway, so we bind them as floats rather than 500 on a client value.
226
+ """
227
+ if isinstance(v, bool):
228
+ return v
229
+ if isinstance(v, int) and (v > _INT64_MAX or v < _INT64_MIN):
230
+ return float(v)
231
+ return v
232
+
233
+
234
+ # JSON storage types (per SQLite json_type) that satisfy each field type.
235
+ _JSON_TYPES_FOR = {
236
+ "number": ("'integer'", "'real'"),
237
+ "boolean": ("'true'", "'false'"),
238
+ "string": ("'text'",),
239
+ "datetime": ("'text'",),
240
+ }
241
+
242
+
243
+ def _field_expr(field, fdef, params):
244
+ """SQL expression for a field that mirrors read-time projection exactly.
245
+
246
+ A stored value counts only if its JSON type matches the field's current type
247
+ (the same rule fieldtypes.project_data applies on read). Anything else — an
248
+ absent key, a stored null, or a value left over at the wrong type after a
249
+ retype — falls back to the field's default (or NULL). Reads and queries are
250
+ therefore always in lockstep, with no row rewrites.
251
+
252
+ Any parameter the expression needs (the default) is appended to ``params``
253
+ in evaluation order, so the caller must add comparison params *after*.
254
+ """
255
+ raw = f"json_extract(data, '$.{field}')"
256
+ jt = f"json_type(data, '$.{field}')"
257
+ ftype = fdef["type"]
258
+ if ftype == "json":
259
+ valid = f"{jt} IS NOT NULL"
260
+ else:
261
+ valid = f"{jt} IN ({','.join(_JSON_TYPES_FOR[ftype])})"
262
+ default = fdef.get("default")
263
+ if default is not None:
264
+ params.append(_safe_bind(default))
265
+ return f"(CASE WHEN {valid} THEN {raw} ELSE ? END)"
266
+ return f"(CASE WHEN {valid} THEN {raw} ELSE NULL END)"
267
+
268
+
269
+ def _build_where(filters, fields):
270
+ """Translate a ``{key: value}`` filter mapping into a SQL WHERE fragment.
271
+
272
+ Supports operators via ``field__op`` keys: eq, ne, gt, gte, lt, lte,
273
+ contains, in, exists.
274
+ """
275
+ clauses = []
276
+ params = []
277
+ for key, raw in filters.items():
278
+ field, op = _parse_filter_key(key, fields)
279
+ fdef = fields[field]
280
+ ftype = fdef["type"]
281
+ val = _coerce_filter_value(field, op, raw, ftype)
282
+
283
+ # Empty IN matches nothing; handle before building expr so we don't emit
284
+ # an orphan default parameter.
285
+ if op == "in" and not val:
286
+ clauses.append("0")
287
+ continue
288
+
289
+ # expr is used exactly once per clause (so its COALESCE default, if any,
290
+ # maps to exactly one bound parameter).
291
+ expr = _field_expr(field, fdef, params)
292
+ if op == "eq":
293
+ clauses.append(f"{expr} = ?")
294
+ params.append(_safe_bind(val))
295
+ elif op == "ne":
296
+ # Null-safe inequality: also matches rows where the value is null.
297
+ clauses.append(f"{expr} IS NOT ?")
298
+ params.append(_safe_bind(val))
299
+ elif op == "gt":
300
+ clauses.append(f"{expr} > ?")
301
+ params.append(_safe_bind(val))
302
+ elif op == "gte":
303
+ clauses.append(f"{expr} >= ?")
304
+ params.append(_safe_bind(val))
305
+ elif op == "lt":
306
+ clauses.append(f"{expr} < ?")
307
+ params.append(_safe_bind(val))
308
+ elif op == "lte":
309
+ clauses.append(f"{expr} <= ?")
310
+ params.append(_safe_bind(val))
311
+ elif op == "contains":
312
+ # Escape LIKE metacharacters so the match is a literal substring,
313
+ # not a wildcard pattern.
314
+ esc = (str(val).replace("\\", "\\\\")
315
+ .replace("%", "\\%").replace("_", "\\_"))
316
+ clauses.append(f"{expr} LIKE ? ESCAPE '\\'")
317
+ params.append(f"%{esc}%")
318
+ elif op == "in":
319
+ qmarks = ",".join("?" * len(val))
320
+ clauses.append(f"{expr} IN ({qmarks})")
321
+ params.extend(_safe_bind(v) for v in val)
322
+ elif op == "exists":
323
+ clauses.append(f"{expr} IS NOT NULL" if val else f"{expr} IS NULL")
324
+ return clauses, params
325
+
326
+
327
+ def list_objects(app, object_type, filters=None, limit=DEFAULT_LIMIT, offset=0,
328
+ sort=None, order="asc"):
329
+ schema = get_object_schema(app, object_type, required=True)
330
+ fields = schema["fields"]
331
+ filters = filters or {}
332
+
333
+ clauses = ["app = ?", "object_type = ?"]
334
+ params = [app, object_type]
335
+ fc, fp = _build_where(filters, fields)
336
+ clauses.extend(fc)
337
+ params.extend(fp)
338
+ where = " AND ".join(clauses)
339
+
340
+ order_l = str(order).lower()
341
+ if order_l not in ("asc", "desc"):
342
+ raise bad_request(f"Invalid order '{order}'. Use 'asc' or 'desc'.")
343
+ order_sql = "DESC" if order_l == "desc" else "ASC"
344
+ # Always append `guid ASC` as a deterministic tie-break so pagination is
345
+ # stable even when the primary sort key has duplicate values. The sort key
346
+ # uses the same projection-aware expression as filters/reads so it orders by
347
+ # the values the client actually sees (defaults included).
348
+ sort_params = []
349
+ if sort:
350
+ if sort in ("_created_at", "_updated_at", "_guid"):
351
+ col = {"_created_at": "created_at", "_updated_at": "updated_at",
352
+ "_guid": "guid"}[sort]
353
+ order_clause = f"{col} {order_sql}, guid ASC"
354
+ elif sort in fields:
355
+ if fields[sort]["type"] == "json":
356
+ raise bad_request(f"Cannot sort on json field '{sort}'.")
357
+ sort_expr = _field_expr(sort, fields[sort], sort_params)
358
+ order_clause = f"{sort_expr} {order_sql}, guid ASC"
359
+ else:
360
+ raise bad_request(f"Cannot sort on unknown field '{sort}'.")
361
+ else:
362
+ order_clause = f"created_at {order_sql}, guid ASC"
363
+
364
+ try:
365
+ limit = int(limit)
366
+ offset = int(offset)
367
+ except (TypeError, ValueError):
368
+ raise bad_request("limit and offset must be integers.")
369
+ if limit < 0 or offset < 0:
370
+ raise bad_request("limit and offset must be non-negative.")
371
+ if offset > _INT64_MAX:
372
+ raise bad_request("offset is too large.")
373
+ limit = min(limit, MAX_LIMIT)
374
+
375
+ c = db.conn()
376
+ total = c.execute(
377
+ f"SELECT COUNT(*) AS n FROM objects WHERE {where}", params
378
+ ).fetchone()["n"]
379
+ rows = c.execute(
380
+ f"SELECT * FROM objects WHERE {where} ORDER BY {order_clause} "
381
+ f"LIMIT ? OFFSET ?",
382
+ params + sort_params + [limit, offset],
383
+ ).fetchall()
384
+
385
+ projected = [_project_fields(r, fields) for r in rows]
386
+ relmap = assoc.project_relations(app, [r["guid"] for r in rows], object_type)
387
+ for p in projected:
388
+ p.update(relmap[p["_guid"]])
389
+
390
+ return {
391
+ "objects": projected,
392
+ "total": total,
393
+ "limit": limit,
394
+ "offset": offset,
395
+ }
morphdb/router.py ADDED
@@ -0,0 +1,58 @@
1
+ """A tiny path-template router with no dependencies.
2
+
3
+ Templates use ``{name}`` segments, e.g. ``/objects/{type}/{guid}``. A trailing
4
+ slash is optional. Handlers receive a :class:`Request` and return either a
5
+ payload (rendered as 200) or a ``(status, payload)`` tuple.
6
+ """
7
+
8
+ import re
9
+
10
+
11
+ class Request:
12
+ def __init__(self, method, path, params, query, body, headers):
13
+ self.method = method
14
+ self.path = path
15
+ self.params = params # dict from path template
16
+ self.query = query # dict, single (last) value per key
17
+ self.body = body # parsed JSON (dict/list) or {}
18
+ self.headers = headers
19
+
20
+ def query_bool(self, key, default=False):
21
+ val = self.query.get(key)
22
+ if val is None:
23
+ return default
24
+ return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
25
+
26
+
27
+ class Router:
28
+ def __init__(self):
29
+ self._routes = [] # (method, compiled_regex, handler)
30
+
31
+ def add(self, method, template, handler):
32
+ param_names = re.findall(r"{(\w+)}", template)
33
+ pattern = re.sub(r"{(\w+)}", r"(?P<\1>[^/]+)", template)
34
+ regex = re.compile("^" + pattern + "/?$")
35
+ self._routes.append((method.upper(), regex, handler, param_names))
36
+
37
+ def route(self, method, template):
38
+ def deco(fn):
39
+ self.add(method, template, fn)
40
+ return fn
41
+ return deco
42
+
43
+ def match(self, method, path):
44
+ """Return ``(handler, params, path_matched)``.
45
+
46
+ ``path_matched`` distinguishes a 404 (no path matched) from a 405
47
+ (path matched but not for this method).
48
+ """
49
+ # HEAD is served by the GET handler (the server omits the body).
50
+ match_method = "GET" if method == "HEAD" else method
51
+ path_matched = False
52
+ for m, regex, handler, _names in self._routes:
53
+ mo = regex.match(path)
54
+ if mo:
55
+ path_matched = True
56
+ if m == match_method:
57
+ return handler, mo.groupdict(), True
58
+ return None, None, path_matched