Graph-as-Code (GaC)¶
Write Python or YAML. Get SHACL. No Turtle, no SHACL vocabulary — ever.
Graph-as-Code is Trails' answer to what IaC (Infrastructure as Code) did for cloud infrastructure. Instead of writing Turtle files and SHACL shapes by hand, you annotate your Python model fields and Trails compiles everything for you at decoration time.
Quickstart¶
from typing import Annotated
from trails import App
from trails.gac import required, optional, min_length, max_value, pattern, one_of
app = App("blog")
@app.model # bare — class name used as label
class Post:
title: Annotated[str, required(), min_length(1)]
body: Annotated[str, required()]
status: Annotated[str, one_of("draft", "published", "archived")]
views: Annotated[int, optional(), min_value(0)]
slug: Annotated[str, pattern(r"^[a-z0-9-]+$")]
That's it. Trails auto-generates:
- A
@node_typeregistration (JSON-Schema, IRI minting, ORM) - A
@shapewith all the SHACLPropertyShapeconstraints - Wire-up to SHACL validation on every
.save()
Constraint reference¶
| Python | SHACL equivalent | Description |
|---|---|---|
required() |
sh:minCount 1 |
Field must have a value (default) |
optional() |
sh:minCount 0 |
Field may be absent |
min_length(n) |
sh:minLength n |
String ≥ n characters |
max_length(n) |
sh:maxLength n |
String ≤ n characters |
min_value(v) |
sh:minInclusive v |
Number ≥ v |
max_value(v) |
sh:maxInclusive v |
Number ≤ v |
pattern(r) |
sh:pattern r |
String must match regex |
one_of(*vs) |
sh:in (vs) |
Value must be one of vs |
unique() |
ORM unique list | Value unique across all instances |
Multiple markers on a single field compose freely:
Cross-property constraints with @constraint¶
For rules that span multiple fields, use @constraint with require():
from trails.gac import constraint, require
@app.model
class Invoice:
total: Annotated[float, required(), min_value(0)]
discount: Annotated[float, optional(), min_value(0)]
@constraint(Invoice)
def discount_below_total(inv):
if inv.discount is not None:
require(inv.discount < inv.total, "discount must be less than total")
@constraint(Invoice)
def positive_after_discount(inv):
if inv.discount is not None:
require(inv.total - inv.discount > 0, "net total must be positive")
@constraint validators run as Python callables before every .save() — fast,
debuggable, and no SPARQL required.
Decorator forms¶
All three forms produce identical results:
# 1. Bare — name inferred from class
@app.model
class Person:
name: Annotated[str, required()]
# 2. Named — explicit label
@app.model("Person")
class Person:
name: Annotated[str, required()]
# 3. Legacy — original fields= API, still fully supported
@app.model("Person", fields={"name": str})
class Person:
pass
Mix GaC annotations with an explicit fields= override:
@app.model("Employee", fields={"department": str})
class Employee:
name: Annotated[str, required(), min_length(1)]
# department: str — merged in from fields=
YAML surface¶
For data-engineering workflows, generated schemas, or teams that prefer declarative configuration over Python classes, GaC also accepts a YAML file:
# models.yaml
models:
- name: Word
fields:
writtenForm: {type: str, required: true, min_length: 1}
language: {type: str, optional: true, one_of: [de, en, fr]}
quality: {type: str, one_of: [high, benchmark, detected]}
- name: DriftEvent
fields:
year: {type: int, required: true, min_value: 1000}
confidence: {type: float, optional: true, min_value: 0, max_value: 1}
driftType: {type: str, one_of: [broadening, narrowing, pejoration, amelioration]}
Load it with one call — no Python classes, no Annotated[]:
Or use the lower-level function directly:
Requires PyYAML: pip install pyyaml.
YAML field keys¶
| YAML key | Compiled marker | SHACL |
|---|---|---|
type: str\|int\|float\|bool |
base type | sh:datatype |
required: true |
required() |
sh:minCount 1 |
optional: true |
optional() |
sh:minCount 0 |
min_length: N |
min_length(N) |
sh:minLength |
max_length: N |
max_length(N) |
sh:maxLength |
min_value: N |
min_value(N) |
sh:minInclusive |
max_value: N |
max_value(N) |
sh:maxInclusive |
pattern: "r" |
pattern(r) |
sh:pattern |
one_of: [...] |
one_of(*vs) |
sh:in |
unique: true |
unique() |
ORM unique list |
Mixing YAML and Python¶
Both surfaces compile to the same ShapeMeta / PredicateInfo — mix freely:
app.load_models("models.yaml") # bulk declarative models
@app.model # one-off Python model in same app
class AuditLog:
event: Annotated[str, required()]
ts: Annotated[int, required()]
@constraint(app._models["Word"]) # cross-property rule on a YAML model
def written_form_non_empty(w):
require(w.writtenForm.strip() != "", "writtenForm must not be blank")
Limitations of the YAML surface¶
- No
@constraintcross-property validators in YAML — add them in Python afterapp.load_models()(see example above). - No compile-time type checking — errors surface at load time, not at import.
- Complex SHACL (
sh:or,sh:xone, SPARQL rules) requires thepredicate()escape hatch even for YAML-declared models.
Escape hatch: raw predicate()¶
When you need a constraint GaC doesn't cover (e.g. sh:or across multiple
properties), drop down to the existing predicate() API:
from trails.shapes import shape, predicate
@shape(iri="myapp:PersonShape", extends=[Person])
class PersonShape:
name: str = predicate("schema:name", required=True, min_length=1)
# sh:or, sh:xone, SPARQL-based rules — anything in SHACL
GaC and raw shapes coexist on the same node type — constraints accumulate.
How it works¶
flowchart LR
PY["Python surface\n@app.model class Person:\n name: Annotated[str, required()]"]
YAML["YAML surface\napp.load_models('models.yaml')"]
EX["_extract_gac_annotations()\n→ plain_fields, constraints"]
YL["_yaml_field_to_markers()\n→ Annotated[T, *markers]\n→ synthetic class"]
NT["node_type(name, fields=plain_fields)"]
SH["_register_gac_shape()\n→ PredicateInfo per field\n→ ShapeMeta in _SHAPES"]
VAL["SHACL validation\non every .save()"]
PY --> EX --> NT
YAML --> YL --> EX
EX --> SH --> VAL
Both surfaces compile to the same ShapeMeta / PredicateInfo objects consumed
by SHACL export, federation schema negotiation, and validation. No Turtle is
written or read in either path.