Skip to content

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_type registration (JSON-Schema, IRI minting, ORM)
  • A @shape with all the SHACL PropertyShape constraints
  • 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:

age: Annotated[int, optional(), min_value(0), max_value(150)]

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[]:

app.load_models("models.yaml")   # registers all models, compiles all shapes

Or use the lower-level function directly:

from trails.gac import load_yaml_models
load_yaml_models("models.yaml", app)

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 @constraint cross-property validators in YAML — add them in Python after app.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 the predicate() 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.