Up to now every column Datamog has handled has been a single primitive type: a string, a whole number, a floating-point number, a boolean. That’s enough for the flat-tuple modelling philosophy of Chapter 10 — and for almost every example we’ve seen — but the data we find in the world is rarely already flat. Web logs arrive as nested JSON. API responses pack arrays of objects inside arrays of objects. Configuration files are deeply nested. Pre-flattening that data in some other language before feeding it to Datalog is real friction.
So Datamog adds a fifth type, value — a union that covers
null, booleans, integers, floats, strings, arrays, and
objects — plus a small toolkit for picking it apart from inside
a rule body. The name “JSON” is reserved here for the syntax
(JSONL files, parsing strings) and the on-the-wire / on-disk
representation; the language-level type is just value.
The design is primarily destructure: most data enters a
program through the EDB as a value and gets read, projected,
and coerced down to primitives. Construction is also supported:
primitives auto-lift wherever a value is expected (so
r(J) :- t(J), J == 5 “just works”), parse_json parses a
string into a value (or NULL, on malformed input), and array
/ object literals ([1, 2, X], {"k": V}) build composites
directly. The finiteness checker (Chapter 5’s lens, formalised
in spec §5.8) flags the recursive-loop patterns that would
otherwise manufacture an unbounded family of compounds.
Two loaders cover the two common shapes.
JSONL with a single-value column. When the extensional
declaration has exactly one column typed value, every non-blank
line of <predicate>.jsonl is consumed as the column’s contents
directly — any JSON shape goes. From
code/ch14/events.dl:
extensional event(payload: value).
with event.jsonl:
{"id": 1, "method": "GET", "path": "/v1/users", "status": 200, "headers": {"x-trace": "abc"}}
{"id": 2, "method": "POST", "path": "/v1/users", "status": 201, "headers": {"x-trace": "def", "content-type": "application/json"}}
— each line becomes one row whose payload column holds the
whole parsed object.
A whole .json file as one row. When you want to ingest a
single JSON document (a config blob, a manifest), drop a file
named <predicate>.json next to the .dl and declare a
single-value column extensional. The file is parsed and inserted
as exactly one row. From
code/ch14/config.dl:
extensional config(blob: value).
with config.json:
{
"name": "datamog-demo",
"features": {"tracing": true, "auth": false, "cache": true},
"endpoints": [{"path": "/health"}, {"path": "/users"}, {"path": "/admin"}]
}
In both cases the loader works the same way: parse JSON, hand the parsed value straight to the engine, no schema flattening.
valuesvalues destructure with the same [ ] syntax as strings, but
with one extra superpower: object keys are first-class. From
events.dl:
request(Id, Method, Path, Status) :-
event(E),
Id = as_integer(E["id"]),
Method = as_string(E["method"]),
Path = as_string(E["path"]),
Status = as_integer(E["status"]).
E has type value. The expressions E["id"], E["method"],
… return values of whatever shape sits at that key — always
value, regardless of whether the underlying contents are a
number, a string, an object, or null. Missing keys produce SQL
NULL (which then propagates through the equality and drops
the row, exactly as NULL does elsewhere).
Indexing rules:
J[I] with I : integer looks up an array element. Out-of-
range → NULL.J["key"] (string index) looks up an object entry. Missing key
→ NULL.J[I:J] slices an array value (string-style slice doesn’t
apply here). Empty / reversed range → []. Slicing a non-array
→ NULL.A wrong-shape access (object indexed with an integer, primitive
leaf indexed at all) returns SQL NULL. There is no error to
catch and no exception to recover from — bad accesses just drop
their row.
The values you get out of subscripts are always value — even
when the underlying leaf is a string or a number. To work with
primitives you need an explicit coercion:
| Function | Returns | NULL when |
|---|---|---|
as_string(V) |
string |
V is not a string leaf |
as_integer(V) |
integer |
not an integer-valued numeric leaf, or out of int range |
as_float(V) |
float |
V is not a numeric leaf |
as_boolean(V) |
boolean |
V is not a boolean leaf |
length(V) |
integer |
V is not an array, object, or string |
type_of(V) |
string |
(always returns one of "object", "array", "string", "number", "boolean", "null") |
as_integer is strict about integer-ness: as_integer(1.5) → NULL,
not 1. If you want truncation, do it explicitly with
as_float and floor. (Datamog won’t smuggle silent precision
loss past you.)
length is overloaded across strings and the three “container-shaped”
value forms. For values, wrong-shaped leaves return SQL NULL:
length of the null leaf returns SQL NULL.
Three more single-argument builtins cover the operations that relational destructuring doesn’t already give you for free:
| Function | Returns | Behaviour |
|---|---|---|
keys(V) |
value |
sorted array of the object’s keys; NULL on non-object |
values(V) |
value |
array of the object’s values, ordered by key; NULL on non-object |
to_json(V) |
string |
canonical JSON text for canonical values — keys sorted, no whitespace |
keys and values give you a one-step “what’s inside this
object?” projection without having to write the
object_entry + list round-trip. They return NULL on
non-object inputs (arrays, primitives, the null leaf) so
downstream rules can pattern-match on shape.
to_json returns a value’s canonical JSON text, which makes it
useful as a hash key, a dedup key, or a stable identifier for
canonical values. One v1 backend variance remains: SQLite / sql.js
parse_json minifies objects without sorting their keys, so
to_json(parse_json(...)) can preserve source key order on those
backends.
Subscript pulls one value out at a known position. Iteration walks every entry. Two built-in body atoms cover the cases:
object_entry(O, K, V) # K : string, V : value — one row per entry of object O
array_element(A, I, V) # I : integer, V : value — one row per element of array A
Both are body atoms — they sit alongside p(X, Y) and X = Y
in a rule body, not inside expressions. Their first argument
must be safe (its variables already bound somewhere else in the
rule body — same rule as range atoms X in [lo .. hi]); the
remaining positions become bindings when they’re bare variables.
From events.dl:
event_string_header(Id, Key, Value) :-
event(E),
Id = as_integer(E["id"]),
object_entry(E["headers"], Key, V),
Value = as_string(V).
Each event contributes one row per header, with the key as
string and the value coerced from value to string. Events
whose headers are missing or non-object simply don’t appear —
object_entry on a wrong-shape source yields zero rows.
array_element is the symmetric primitive for lists:
endpoint(I, Path) :-
config(C),
array_element(C["endpoints"], I, E),
Path = as_string(E["path"]).
Logic lens.
object_entryandarray_elementare not new predicates — they’re parameterised, EDB-like families of facts conjured from a singlevalue. Think ofobject_entry(O, K, V)as the (extensional) ternary relation{(O, K, V) | (K, V) ∈ entries(O)}. Each O picks out a finite set of (K, V) pairs; the iteration primitive lets a rule range over them.Because the source has to be safe (bound elsewhere), the reachable set of
(K, V)pairs is finite — exactly the finiteness property that makes Datalog Datalog. The construction primitives — auto-lift,parse_json, and the array / object literals (next section) — sit just outside that closure: they can manufacture new values, but always from inputs the rules already had. The finiteness checker (spec §5.8) flags the dangerous shape — a recursion that loops avalueback through any of them — so the property holds as a warning rather than as a hard syntactic prohibition.
valuesYou can compare two values with =, <>, ==, !=:
same_payload(A, B) :- event(A), event(B), A = B.
Equality is structural: {"a":1, "b":2} equals
{"b":2, "a":1} because they describe the same value, even
though the textual representations differ. Datamog canonicalises
values on insert (object keys sorted recursively, numbers
normalised) so that this works uniformly across every backend.
Ordering operators (<, <=, >, >=) on value are
rejected at type-check. There is no portable order on
structured values — SQL backends disagree, and adding our own
order-rule on top would just be a lie.
Three routes move primitives into the value type:
value slot is
lifted automatically — atom args matched against a
value-typed column, equality variants between primitive
and value, and IDB column unification across rules
contributing different types. This means a primitive-to-
value conversion function isn’t part of the surface
language: in any context where you’d want one, the lift
fires by itself. See the spec for the few sites where it
intentionally doesn’t (function arguments expecting
value, the source of object_entry /
array_element, ordering comparisons).parse_json(s) parses a string as JSON syntax and returns
the parsed value. Malformed input becomes NULL rather than
raising, matching the rest of the parsing family
(to_integer / to_float / to_boolean).[e1, e2, ...] produces an
array; {"k1": v1, "k2": v2, ...} produces an object.
Element / value expressions are auto-lifted, so primitives,
already-value expressions, and null mix freely. Object
keys are written as string literals (mirroring JSON syntax).extensional sample(name: string, raw: string).
# Primitive auto-lift: comparing the primitive `length(N)` against
# a `value`-typed column is fine — the lift fires implicitly.
counted(N, V) :- sample(N, _), V = [length(N)][0].
# A string parsed into a structured value, with a clean NULL
# for any row whose payload isn't valid JSON syntax.
parsed(N, V) :- sample(N, R), V = parse_json(R).
# Object and array literals — assemble structured values
# directly, without round-tripping through string form.
record(N, V) :- sample(N, _), V = {"name": N, "tags": ["sample", N]}.
Termination lens. Auto-lift and array / object literals over already-bound variables are harmless: they don’t produce values outside the active domain.
parse_jsoncan —parse_json(as_string(J)) = Jonly when the string round-trips, butparse_json("[" + S + "]")wraps each value in an extra layer, and a recursive rule that applies that pattern grows without bound. The finiteness checker treats every value constructor (literals and lifts included) like string concat and arithmetic for that reason: a cycle through any of them warns. The warning isn’t a refusal to compile — sometimes the looping terminates for reasons the checker can’t see — but it does mean “I cannot prove this terminates; you’re on the hook.”
Backend lens.
parse_jsonis one place the backends visibly differ. Postgresjsonband the native evaluator both canonicalise parsed objects (sort keys, normalise numbers), soparse_json('{"a":1,"b":2}')andparse_json('{"b":2, "a":1}')join structurally. SQLite / sql.js use textual JSON and only minify — they don’t sort keys — so on those backends the two parse results are textually distinct and won’t unify in a join. If your join key is a freshly-parsed object, prefer Postgres or the native backend; otherwise round-trip throughas_*so the join key is a primitive.
The full events.dl from
code/ch14/events.dl reads logs out of
event.jsonl, projects a flat request(Id, Method, Path,
Status) view, filters to /v1/-prefixed 2xx responses, and
fans out per-event headers — all without leaving Datalog:
extensional event(payload: value).
request(Id, Method, Path, Status) :-
event(E),
Id = as_integer(E["id"]),
Method = as_string(E["method"]),
Path = as_string(E["path"]),
Status = as_integer(E["status"]).
ok_v1_request(Id, Path) :-
request(Id, _, Path, Status),
Status >= 200,
Status < 300,
Path[0:4] = "/v1/".
event_string_header(Id, Key, Value) :-
event(E),
Id = as_integer(E["id"]),
object_entry(E["headers"], Key, V),
Value = as_string(V).
?- ok_v1_request(Id, P).
?- event_string_header(Id, K, V).
Run it:
bun run datamog doc/walkthrough/code/ch14/events.dl
SQL lens. Run
--dry-runto see what each backend emits. Postgres usesJSONBcolumns and the->operator for subscript; iteration becomesLATERAL jsonb_each(...);as_integercompiles to aCASE WHEN jsonb_typeof = 'number' AND ... THEN ... ELSE NULL END. SQLite/sql.js useTEXTcolumns withjson_extractandjson_each, plus a canonicalising pass at insert time so textual equality lines up with structural equality. Same Datalog source, very different SQL, identical answers.
Imperative lens. The Python-shaped equivalent of the request projection is roughly:
rows = [json.loads(line) for line in open("event.jsonl")] requests = [ (r["id"], r["method"], r["path"], r["status"]) for r in rows if isinstance(r.get("id"), int) and isinstance(r.get("method"), str) # ...etc ]The
as_integer/as_stringchecks in Datalog play exactly the role theisinstanceguards play here — without them, avalueof the wrong shape would silently get the wrong type. The difference is that the Datalog version composes: oncerequestis defined,ok_v1_requestandevent_string_headerconsume it directly, and they too get the safety/typing guarantees for free.
value-typed column carries the union of every shape:
null, booleans, integers, floats, strings, arrays, and
objects. Datamog reads them with subscript / iteration /
coercion, and constructs new ones via auto-lift (anywhere a
value slot meets a primitive), parse_json(s) (parse a
string, NULL on malformed input), and array / object
literals ([e1, e2, ...] and {"k1": v1, "k2": v2, ...}).value
column declaration, and a standalone <predicate>.json file.J["key"] / J[i]; iterate with
object_entry / array_element; coerce leaves with as_string,
as_integer, as_float, as_boolean; introspect with length and
type_of. Wrong-shape access is always NULL, never an error.json is rejected.JSON support is the escape hatch for “the data arrived as a nested blob and I don’t want to flatten it before I look at it” — once you’ve pulled out the fields that matter, the rest of the language treats them as ordinary primitives.
Starter: code/ch14/events.dl
Define status_count(S, count(Id)) — the number of events with
each HTTP status code, grouped by status. You’ll need the
request predicate from the chapter; the aggregate is from
Chapter 9. Hint: the only new piece is as_integer(E["status"]).
Define complex_event(Id) — events whose payload object has
five or more top-level keys. Compose length (over the
top-level object) with as_integer and a comparison.
Define first_endpoint_path(P) — the path of the first endpoint
in config.json. Use array_element and filter by I = 0,
or the equivalent literal-key form C["endpoints"][0]["path"]
with subscript chaining. Try both; compare the SQL each emits.
Some events have "x-internal": "true" (a JSON string that
looks like a boolean); some others might have
"x-internal": true (an actual JSON boolean). Define
internal_event(Id) that matches both shapes — using as_boolean
or as_string and a string comparison. Then think about which
form you’d prefer in production code, and why.