Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Syntax Overview

Dolfin uses indentation-sensitive syntax, like Python. Blocks are opened with a colon (:) and delimited by consistent indentation, tabs or spaces, but never mixed. All lines inside a block must be at the same indentation level.

Comments begin with # and run to the end of the line.


# This is a comment
concept Foo:
  has bar: string  # inline comment

File types

A Dolfin project uses two kinds of files.

package.dlf: one per project, declares the package identity:


package <http://example.com/my-ontology>:
  dolfin_version "1"
  version "0.3.0"
  author "Alice"
  description "A short description"
  import <http://acme.com/hr> "1.2.0"
FieldRequiredDescription
dolfin_versionyesLanguage version to use (currently "1")
versionyesOntology version (semver string)
authornoAuthor name
descriptionnoHuman-readable description of the ontology
importnoAn ontology this package reuses, one line each. See Imports
reasoningnoReasoning profiles: rdfs, owl, or none. See Reasoning

*.dlf ontology files: one or more files containing declarations (concepts, enums, properties, facts, mappings, rules, queries).


Prefixes

Prefixes bind short aliases to IRI namespaces, enabling interoperability with external vocabularies.


prefix <http://schema.org/> as schema
prefix <http://purl.org/dc/elements/1.1/> as dc

Once declared, a prefix can be used in qualified names:


has address: optional schema.PostalAddress

Prefixes can also be declared in a hierarchical block to share a common path:


prefix com.example:
  Person
  Organization as Org

This is equivalent to:


prefix com.example.Person
prefix com.example.Organization as Org

Imports

A package reuses another ontology by listing it in package.dlf, one import line each:


package <http://acme.com/app>:
  dolfin_version "1"
  version "0.1.0"
  import <http://acme.com/hr> "1.2.0"
  import <http://xmlns.com/foaf/0.1/> from <https://xmlns.com/foaf/spec/index.rdf>
  • The IRI is the imported ontology’s IRI. It is emitted as owl:imports <IRI>.
  • The quoted version is optional and pins the importee’s version.
  • from <IRI> is optional and says where to fetch the ontology. It defaults to the IRI itself.

Locking imports on disk is covered in the Agrafe docs: Import locks on disk.

Using imported names

The import is declared once, for the whole package. Each .dlf file that uses an imported name still declares a prefix over one of the import’s namespaces, then writes prefixed or dotted refs:


prefix com.acme.hr.person as hr
prefix <http://xmlns.com/foaf/0.1/> as foaf

concept Team:
  has lead: one hr:Employee
  has member: some foaf:Person

An import has one namespace per IRI stem (the IRI up to its last # or /). Each namespace is named from its IRIs:

ImporteeNamespace IRIName
Dolfin package com.acme.hr, file person.dlf…/com/acme/hr/person#com.acme.hr.person
Web vocabularyhttp://xmlns.com/foaf/0.1/com.xmlns.foaf.v0_1
  • A Dolfin importee keeps its dotted package path, followed by the file name: one namespace per file of the importee.
  • Other IRIs give their host reversed (without a leading www), then their path. A part that does not start with a letter gets a v in front (0.1 → v0_1), and any character other than a letter, digit or _ becomes _.
  • An IRI prefix (prefix <…> as x) works for any import, whatever its namespace name.

Rules

  • Checked refs. A ref into an imported namespace must name something that import defines. Otherwise it is an error: `hr:Ghost` is not defined by import `http://acme.com/hr`.
  • Not transitive. Only direct imports are nameable. If app imports hr and hr imports org, app cannot reach org’s names through hr: it imports org itself. A deployment still loads the whole import closure for reasoning.
  • Ambiguous names. When two imports map to the same dotted name, a prefix naming it fails with Ambiguous imported namespace. Declare it as an IRI prefix instead.
  • Outside every import. A prefix over a namespace no import covers, such as prefix <http://schema.org/> as schema without a schema.org import, stays unchecked and is emitted as written.

Reasoning

The optional reasoning line of package.dlf picks the inference rules applied to the package’s data:


package <http://acme.com/app>:
  dolfin_version "1"
  version "0.1.0"
  reasoning rdfs, owl
  • rdfs: subclass, subproperty, domain and range entailment.
  • owl: the symmetric and transitive property axioms.
  • Write any subset of rdfs and owl, comma-separated, in any order, each at most once. The formatter writes rdfs first.
  • reasoning none turns reasoning off. It cannot be combined with rdfs or owl.
  • An unknown profile, a repeated profile, or none next to another profile is an error on that word.

Without the line, the profile is chosen automatically: rdfs is always on, and owl is on as soon as a property of the package is symmetric or transitive, whether it is a property declaration or a has field:


concept Truc:
  has link: one Truc
    symmetric

Here link turns owl on. inverse of does not need owl: inverse links are always inferred. Only the package’s own declarations count; to get owl for an imported symmetric property, write reasoning rdfs, owl.

With an explicit reasoning line that leaves owl out (reasoning rdfs, reasoning none), each symmetric or transitive axiom gets warning S030 (`link` is symmetric but OWL reasoning is off: inverse links won't be inferred), with a quick fix that adds owl to the line. Without the line there is no warning: owl turns on by itself.

A rule that only restates an axiom gets warning S031, with a quick fix that deletes the rule:


rule linksym:
  match:
    ?a link ?b
  then:
    ?b link ?a

Here rule `linksym` is redundant: `link` is declared symmetric. The same holds for ?a p ?b and ?b p ?c giving ?a p ?c with p transitive, and for ?a p ?b giving ?b q ?a with q inverse of p (or p of q). Variable names do not matter; a rule with any other match line, a constant, or a second then line is not reported. With owl off, symmetric and transitive links are not inferred, so the rule is not redundant: S031 is then a hint to enable OWL reasoning instead, without a fix. Inverse links are always inferred, so that case stays a warning.

The profile applies wherever the package is reasoned over: queries, validation and radeau export. Validation never infers a type from a domain or range (it would hide a missing sh:class value), but it does apply owl: a symmetric link written on one side satisfies the cardinality of the other. A deployment keeps the profile it was deployed (or last updated) with; editing the reasoning line or a property’s axioms afterwards does not change it until the next deploy or update.


Alias

alias gives one name, imported or local, a short name in one file:


prefix com.acme.hr.person as hr
alias com.acme.hr.person.Employee as WorkingPerson

concept Team:
  has lead: one WorkingPerson

alias hr:Manager as Boss
  • An alias is file-scoped: other files of the package do not see it.
  • It is a declaration, so it can appear anywhere a declaration can, before or after its uses.
  • It introduces no new term. In the Turtle output, WorkingPerson is the target’s IRI.
  • The target must be defined by a direct import or declared in the package. Otherwise: Alias '…' targets '…', which no import defines and the package does not declare.
  • The alias name must not match another declaration or alias of the same file. Otherwise: Alias '…' clashes with a declaration of the same name.

Concepts

A concept defines a category of things and the attributes they can carry.


concept Person:
  has firstName: one string
  has lastName: one string
  has email: optional string
  has age: optional int

An empty concept (no body) is also valid, useful as a flag or tag:


concept FlaggedForReview

Inheritance

A concept can inherit from one or more parents using sub:


concept Employee:
  sub Person
  has employeeId: one string
  has department: one string

concept Manager:
  sub Employee
  has reports: Employee

Multiple parents are comma-separated:


concept PartTimeEmployee:
  sub Employee, Contractor

Attributes

Each attribute is declared with has:

has <name>: [cardinality] <type>

The type can be a primitive or another concept name:


has count: one int
has owner: optional Person
has tags: string          # zero or more (default cardinality)

Cardinality

Cardinality constrains how many values an attribute can hold.

KeywordMeaning
(none)Any number (zero or more)
anyAny number (zero or more, explicit)
oneExactly one (required)
optionalZero or one (like at most 1, but never owl:FunctionalProperty)
someOne or more
NExactly N (integer literal)
at least NN or more
at most NN or fewer
N..MBetween N and M (inclusive)
N..*At least N (same as at least N)

As ranges:

KeywordRange
one1..1
any0..*
some1..*
optional0..1
at least nn..*
at most n0..n

A cardinality whose maximum is 1 (one, at most 1, 1, n..1) makes the property owl:FunctionalProperty; see Turtle.


has name: one string          # required, single value
has nickname: optional string # may be absent
has tags: some string         # at least one
has aliases: string           # any number (default)
has luckyNumbers: 3 int      # exactly three
has phoneNumbers: at least 1 string  # one or more
has referees: at most 2 string        # no more than two
has scores: 1..5 float        # between one and five
has comments: 0..* string     # zero or more (explicit range)

A fact must set every field whose minimum is above zero (one, some, N, at least N, N..M with N > 0), including fields inherited from parent concepts and standalone properties with a range cardinality (property mother: Human -> one Human). A missing one is error S010 (missing required field). A field p of fact F is set by:

  • F’s own p value line (a value inside a nested [ … ] block belongs to that block, not to F);
  • is p of F in another fact (F’s own is p of x sets p on x, not on F);
  • q F in any fact, or is q of x in F, when q is inverse of p (or p of q): inverse links are always inferred;
  • p F in another fact when p is symmetric and owl reasoning is on (see Reasoning). With reasoning rdfs it is not inferred, so S010 stays.

These lines may sit in any file of the package. A field that a rule asserts in its then: (directly or through its inverse) is never reported: whether the rule fires is only known on the reasoned data, where validation checks it. A subconcept cannot relax an inherited minimum: restrictions add up.


Primitive types

TypeDescriptionExamples
stringText"hello", ""
intInteger0, 42, -7
floatFloating-point3.14, -0.5
booleanBooleantrue, false

Temporal types

Four temporal types cover dates, times, timestamps and durations. As range types they name the expected value; as values they use smart literals (see Temporal values).

TypeDescriptionXSD typeValue example
dateCalendar datexsd:datedate(June 1st 2026)
timeWall-clock timexsd:timetime(2:30 PM UTC)
date_timeDate + timexsd:dateTimedate_time(June 1st 2026, 14:30)
durationLength of timexsd:durationduration(1y 6mo)

concept Appointment:
  has scheduledFor: one date_time
  has reminderLead: optional duration

Temporal values

Temporal values are written as smart literals: a type keyword followed by human-friendly notation in parentheses. The keyword fixes the type; the text inside is parsed into the corresponding XSD value.


fact checkup a Appointment
  scheduledFor date_time(June 1st 2026, 2:30 PM)
  reminderLead duration(1d)

Dates: date(...)

Natural-language dates, in either field order (month names are case-insensitive, ordinal suffixes optional):


date(June 1st 2026)      # → 2026-06-01
date(1st June 2026)      # → 2026-06-01
date(Jun 1 2026)

Numeric dates are ambiguous, so the field order must be given, either inline with an as mask, or file-wide with @locale:


date(01/06/2026 as d/m/y)   # → 2026-06-01
date(2001-09-11 as y-m-d)   # → 2001-09-11

The separator (/, -, or .) must match between the value and the mask.

Times: time(...)

24-hour by default; AM/PM (case-insensitive) switches to 12-hour. An optional timezone follows as an offset or an abbreviation:


time(14:30)              # → 14:30:00
time(2:30 PM)            # → 14:30:00
time(08:46:00 UTC)       # → 08:46:00+00:00
time(14:30 +02:00)       # → 14:30:00+02:00

Datetimes: date_time(...)

A date part and a time part separated by a comma or a space:


date_time(June 1st 2026, 2:30 PM)
date_time(01/06/2026 14:30 as d/m/y)

Durations: duration(...)

One or more <number><unit> terms, in any order. Distinct unit tokens avoid clashing with quantity units:

Unitymowdhmins
Meansyearsmonthsweeksdayshoursminutesseconds

duration(7d)             # → P7D
duration(1h 40min)       # → PT1H40M
duration(1y 6mo 3d)      # → P1Y6M3D

Closed concepts

An closed concept defines a closed set of named values. Only the declared variants are valid:


concept Status:
  one of:
    Pending
    Active
    Archived

Enumurated values are referenced by name in attributes and rules:


has status: one Status

Properties

A property is a named relationship declared independently of any concept, rather than inside one. It connects a domain type to a range type:


property worksFor: Employee -> Organization

Cardinality can be specified on either side:


property manages: one Manager -> Employee

The range cardinality (after ->) bounds the values per subject; the domain cardinality bounds the subjects per value. A maximum of 1 on the range side makes the property owl:FunctionalProperty, on the domain side owl:InverseFunctionalProperty (manages above: each employee has at most one manager). No cardinality means any: neither.

A side too long for one line goes in the property’s body as a domain X or range X line, which takes the with block form (see Restrictions). The header is then just property p; body lines and axioms (sub, inverse of, transitive, …) come in any order:


concept Process
concept TemporalRegion
property occupiesTemporalRegion

property happensDuring
  sub occupiesTemporalRegion
  domain Process
    with occupiesTemporalRegion: TemporalRegion
  range TemporalRegion
  • A side may be left out (property occupiesTemporalRegion has neither): no rdfs:domain / rdfs:range is emitted for it.
  • Each side is given once. Giving it both in the one-line D -> R and in a domain / range line, or in two lines, is error S014.
  • Cardinalities belong to the one-line form: domain / range lines take none.
  • domain and range are not reserved: a field or fact property named domain keeps working.

Properties are used in rule patterns and assertions like any attribute.


Annotations

A #@ annotation attaches structured, plugin-style metadata to a concept or a has declaration without touching the grammar — it’s a regular comment whose first non-space character is @. A tool that doesn’t recognise the name simply ignores it; the concept or property it decorates parses and compiles exactly the same either way.


#@ glossary:
#@   label "Cat"
#@   definition "A small domesticated feline."
concept Cat:
  #@ form:
  #@   label "Name"
  has name: string

General shape, at concept level (directly above concept Name:) or property level (directly above one has line):

#@ <name>:
#@   <key> <value>
#@   <key> <value>
  ...
  • The header line is #@ <name>: — the trailing colon marks “a block with indented args follows”. A bare #@ <name> with no colon and no indented lines is also valid and carries no arguments (equivalent to omitting the annotation).
  • Argument lines are key value or key=value — space, not colon: write label "First name", not label: "First name". A value containing spaces needs double quotes; a single-word value doesn’t.
  • Attachment is positional: the annotation sits directly above the concept or has line it decorates. A property-level annotation can only ever apply to the one has line right below it — there’s no “unknown property name” failure mode the way a block referencing property names by string would have.
  • Annotation names and keys are not reserved words in any sense the parser knows about — a concept or property named label or title is unaffected and needs no backtick-escaping.

Two annotations exist today: #@glossary (SKOS metadata — skos:prefLabel and rdfs:label from pref_label / label, skos:definition, skos:altLabel — read by the Turtle generator, see Comments and labels) and #@form, below.


Forms

#@form describes how a concept renders as a data-entry form — labels, placeholder text, help text, input widgets, and grid placement — the same way #@glossary describes SKOS metadata. It’s read by form-rendering consumers (such as Formol); it has no effect on the compiled Turtle/RDF output.


#@ form:
#@   title "Register a person"
#@   display first_name
concept Person:
  #@ form:
  #@   label "First name"
  #@   placeholder "Jane"
  #@   row 1
  #@   col 1
  has first_name: one string

  #@ form:
  #@   label "Last name"
  #@   row 1
  #@   col 2
  has last_name: one string

  #@ form:
  #@   label "Email address"
  #@   widget email
  #@   row 2
  has email: optional string

  #@ form:
  #@   label "Employer"
  #@   row 3
  has employer: optional Organization

Concept-level keys (above concept Name:):

KeyMeaningDefault when absent
titleForm heading#@glossary label, else the concept name
displayWhich property’s value labels this concept’s instances in a reference pickerconsumer-defined fallback

Property-level keys (above one has line):

KeyMeaningDefault when absent
labelField label#@glossary label, else the property name humanized (first_name → “First name”)
placeholderInput placeholder textnone
helpField-level help text#@glossary definition, else none
widgetOverrides the type-inferred widget (below)inferred from the property’s declared type
rowGrid row (fields sharing a row render side by side)one field per row, in has-declaration order
colGrid column within a rowleft to right, in has-declaration order
groupSubheading text grouping several fieldsnone

A property not carrying its own #@ form: block still appears in the rendered form, using these defaults — the annotation overrides layout, it doesn’t opt properties in or out. A concept with no #@form anywhere is fully renderable from its #@glossary annotations and, failing those, its bare has declarations alone.

Widget catalogue — widget’s value is a plain string, matched against this table when present; otherwise the default comes from the property’s type:

has typeDefault widgetNotes
stringtextwidget textarea opts into a multi-line field (never inferred)
int / floatnumber
booleancheckbox
datedatenative date picker
timetimenative time picker
date_timedate_timenative datetime picker
durationdurationfree-text, no native widget
a one of: closed-concept typeselectwidget radio opts into radio buttons
a reference to another conceptreferencerenders an instance picker

widget email is the one widget not inferable from the type system (the property is still plain string underneath) — it always requires an explicit override; it only changes the input type and adds client-side format validation.


Facts

A fact declares a named instance of a concept. Facts use the same indentation-based style as every other Dolfin construct.


fact alice a Person
  firstName "Alice"
  lastName "Chen"
  age 34

The general form is:

fact <id> a <ConceptName>
  <property-name> <value>
  ...
  • <id> is the instance identifier. It can be referenced elsewhere as <id>.
  • a <ConceptName> asserts the type. Multiple types are comma-separated: fact x a Dog, Neutered.
  • Each property line is <name> <value>, where the name matches a has declaration (or inherited one) on the concept.

Value forms

FormExampleMeaning
String literal"Alice"Text value
Integer literal42Integer value
Float literal3.14Floating-point value
Boolean literaltrueBoolean value
Temporal literaldate(June 1st 2026)Date/time/datetime/duration (smart literal)
Enum valueActiveA one of member, unquoted
ReferencealiceAnother named fact of the package
Anonymous block[ ... ]Inline instance (blank node)
Listv1, v2Multiple values for multi-valued properties

References

A reference points to another fact declaration by its identifier:


fact acme a Organization
  name "Acme Corp"

fact alice a Person
  firstName "Alice"
  employer acme

Forward references are allowed; all identifiers are resolved after the full file is parsed. A bare identifier is looked up in the current file first, then in the one other file of the package that declares it.

A leading colon, :acme, is the package’s default prefix, as in Turtle: the package namespace itself, not the acme fact of the current file. Since package.dlf declares no facts, the analyzer warns about every :acme.

Anonymous blocks

When a property’s value is a structured object with no need for a global identifier, use an inline block:


fact alice a Person
  address [
    street "12 Main St"
    city "Paris"
    country "France"
  ]

The block’s type is inferred from the property’s declared range. To specify a subtype explicitly, add a SubType as the first line of the block:


fact rex a Animal
  owner [
    a LegalGuardian
    firstName "Bob"
  ]

Multi-valued properties

For properties with cardinality any, some, or at least N, repeat the property name on multiple lines:


fact alice a Person
  phoneNumbers "555-0001"
  phoneNumbers "555-0002"

Or use a comma-separated list:


fact alice a Person
  phoneNumbers "555-0001", "555-0002"

Both forms are equivalent.

Multiple anonymous blocks

Repeating a property with block values works the same way:


fact rex a Dog
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Distemper"
    dateAdministered "2024-03-15"
  ]

Scoping and identity

All fact declarations in a module share a flat namespace. An id must be unique within the module. Facts from imported modules are referenced with module.id syntax.


Mappings

A mapping reads a CSV file (or a JSON or XML document, or a spreadsheet, see JSON mappings, XML mappings and Spreadsheet mappings) and expands a fact-shaped template into one set of triples per data row. It is the bulk-loading counterpart to fact: where a fact names one instance by hand, a mapping declares the shape of an instance once and lets the file supply the values.


prefix <http://airport.example.com/> as ap

mapping from <package://Airport.csv> as CSV
  map:
    id as ?id
    stop as ?stop
    latitude as ?lat
    longitude as ?long
  to:
    fact ap:{?id} a transit:Stop
      transit:route {?stop} as int
      wgs84_pos:lat {?lat}
      wgs84_pos:long {?long}

Given Airport.csv:

id,stop,latitude,longitude
6523,25,50.901389,4.484444

the row expands to exactly the triples the equivalent hand-written fact would produce:

<http://airport.example.com/6523> rdf:type transit:Stop .
<http://airport.example.com/6523> transit:route "25"^^xsd:int .
<http://airport.example.com/6523> wgs84_pos:lat "50.901389" .
<http://airport.example.com/6523> wgs84_pos:long "4.484444" .

The general form is:

mapping from <package://<path>> as CSV
  map:
    <column> as ?<var>
    ...
  to:
    fact <subject-template> a <ConceptName>
      <property-name> <value>
      ...

Expansion happens at translation time, before Turtle is generated, so mapped rows are indistinguishable from hand-written facts everywhere downstream.

mapping is a reserved word. from, for, each, sheet, header, row, map, where, starts, case, else, slug and to are not — they are matched only inside a mapping block, so existing schemas that use them as concept or property names keep working.

The source

<package://<path>> resolves relative to the package root — the directory holding package.dlf — not to the file containing the mapping. A path that escapes the package (.., an absolute path, a symlink pointing outside) is rejected.

package:// is the only scheme in v0.1: http(s):// and file:// sources are errors. Five readers exist: CSV, JSONPath (see JSON mappings), XPath (see XML mappings), and XLSX and ODS (see Spreadsheet mappings). A CSV file is read as comma-delimited, header-row-present UTF-8.

The map: block

Under as CSV, each line binds one column to one variable (under as JSONPath the left-hand side is a path instead, see Path bindings):


  map:
    id as ?id
    "stop id" as ?stop

Column names match the header exactly, case included. A column name may be bare or quoted; quote one that contains spaces or reserved characters. A column the file does not have fails the whole mapping, and a variable used in to: but never bound in map: is a parse error.

The to: block

The body is fact syntax (see Facts) with variables in place of literals. More than one fact template is allowed, and all of them expand for every row — the usual way to emit a second, linked instance per row.

Subject templates. A {?var} hole may appear in the subject:

FormExampleYields
Prefixedap:{?id}<http://airport.example.com/6523>
Prefixed with textap:stop{?id}<http://airport.example.com/stop6523>
Full IRI<http://ex.com/{?id}/{?stop}><http://ex.com/6523/25>
Constantap:allStopsthe same subject for every row

Values. Every value form a fact accepts still works, plus:

FormExampleMeaning
Braced variable{?lat}The cell’s raw text
Bare variable?latShorthand for {?lat}
Cast{?stop} as intTyped literal (casts)
IRI template<http://ex.com/{?lat},{?long}>An IRI built from cells
Literal"metres"A constant, same on every row

Checking. Unprefixed types and properties in a template must be declared in the package, exactly as in a hand-written fact. Names under an IRI prefix (transit:Stop, wgs84_pos:lat) belong to an external vocabulary and are not checked, the same as prefixed names anywhere else in Dolfin.

Casts

Without as, a cell becomes a plain string literal. With it, the text is parsed and typed exactly as the same literal would be in a hand-written fact:

as string, as int, as float, as boolean, as date (ISO-8601), and as quantity(<unit>).

as quantity(kW) reads the cell as a number (_ separators allowed, as for int and float) and gives the quantity a human would write: the cell 22 becomes quantity(22 kW). The unit is any unit a quantity(...) literal accepts, conversion included (as quantity(kW as W)), and is checked the same way. A cell that is not a number, such as 22 kW, is a failed cast.

The cast may also sit inside the braces: {?stop as int} means the same as {?stop} as int, and the formatter writes the outside form.

A cast belongs on a value, never on a subject: ap:{?id} as int is a parse error, since a subject template is always string-substituted into an IRI.

Empty cells and bad values

Per-row problems skip the row rather than failing the build; a count of skipped rows is reported at the end of translation.

SituationResult
Empty cell, property is optional or anyThat one assertion is dropped; the rest of the row is emitted
Empty cell, property is one, some or at least NThe whole row is skipped, with a diagnostic
Empty cell bound into the subject templateThe whole row is skipped
Cast fails (as int on "twenty-six")The whole row is skipped, with a diagnostic

Problems with the mapping itself — an unknown scheme, a missing file, a column the header does not have — fail the whole mapping and stop the build.

Filtering rows: where:

A where: block, between map: and to:, keeps only the rows that pass it:


mapping from <package://irve.csv> as CSV
  map:
    code_insee_commune as ?insee
    id_station_itinerance as ?station
    puissance_nominale as ?kw
  where:
    ?insee [ starts with "69" ]
    ?station [ != "", != "Non concerné" ]
    ?kw [ >= 50 ]
  to:
    ...

Each line names a variable from map: and lists conditions in [ ... ], like a constraint block in a rule. Every condition of every line must hold. A row that fails one is dropped silently: it is not a skipped row, and gets no diagnostic.

ConditionHolds when the cell, trimmed, …
= "text", != "text"is (is not) exactly that text, case included
< "text", <=, >, >=sorts before / after that text (so ISO dates compare as dates)
= 50, < 50, … (a number)is a number and compares so; a cell that is not a number fails everything but !=
starts with "text"starts with that text

An empty cell is "": != "" keeps the rows where the cell has a value.

Value tables: case

case turns the text of a cell into a value, for instance an enumeration member:


      siteType case ?site:
        "Voirie" -> OnStreet
        "Parking public" -> PublicCarPark
      openAllHours case ?hours:
        "24/7" -> true
        else -> false

Each arm is "<text>" -> <value>, where the value is anything a fact can say (a member, a reference, a literal); an optional else -> <value> comes last. The cell and the keys are compared after trimming, lowercasing and dropping accents, so " PARKING PUBLIC" and "Parking public" match. Two keys that are equal once compared that way are an error.

A cell that matches no arm, with no else, is an empty value: an optional property loses that one assertion. That is how a value is given on some rows only:


      connectors case ?type2:
        "true" -> Type2
      connectors case ?ccs:
        "true" -> CCS

IRI-safe names: slug()

A template hole can be {slug(?var)} instead of {?var}, in a subject, a value or an <...> IRI. The cell is lowercased, its accents dropped, every run of other characters than a–z and 0–9 replaced by one _, and _ trimmed from both ends:

Cellev:op_{slug(?op)}
ALDI SARLev:op_aldi_sarl
Électricité de Lyonev:op_electricite_de_lyon

Different cells can give the same name (e-totem, E-TOTEM): they then describe one subject. A cell that gives nothing (empty, or only punctuation) counts as an empty cell.

Rows and identity

Rows are independent: no row can reference another row’s bindings, and row order does not affect the resulting triples. Two rows that build the same subject IRI simply contribute to that one subject — identical triples dedupe, and a conflicting value for a one-cardinality property is a validation warning, the same as it would be between two hand-written facts.

JSON mappings

as JSONPath reads a JSON document. Values are addressed by RFC 9535 JSONPath strings instead of column names, and a for each line says what counts as a row. Everything else — the source, the to: block, casts and the empty-value rules — is shared with CSV.


prefix <http://example.com/ns#> as ex

mapping from <package://Venue.json> as JSONPath
  for each "$.venue[*]"
  map:
    "$.latitude" as ?lat
    "$.longitude" as ?long
    "$.location.continent" as ?continent
    "$.location.country" as ?country
    "$.location.city" as ?city
  to:
    fact <http://loc.example.com/city/{?city}> a ex:City
      ex:countryCode ?country
      ex:latlong <http://loc.example.com/latlong/{?lat},{?long}>
      ex:onContinent ?continent
    fact <http://loc.example.com/latlong/{?lat},{?long}> a ex:latlong
      ex:lat {?lat}
      ex:long {?long}

Given Venue.json:

{
  "venue": [
    {
      "latitude": 50.901389,
      "longitude": 4.484444,
      "location": { "continent": "EU", "country": "BE", "city": "Brussels" }
    }
  ]
}

the one venue entry is one row, and both fact templates expand for it:

<http://loc.example.com/city/Brussels> rdf:type owl:NamedIndividual
  , ex:City
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/city/Brussels> ex:countryCode "BE" .
<http://loc.example.com/city/Brussels> ex:latlong <http://loc.example.com/latlong/50.901389,4.484444> .
<http://loc.example.com/city/Brussels> ex:onContinent "EU" .

<http://loc.example.com/latlong/50.901389,4.484444> rdf:type owl:NamedIndividual
  , ex:latlong
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/latlong/50.901389,4.484444> ex:lat "50.901389" .
<http://loc.example.com/latlong/50.901389,4.484444> ex:long "4.484444" .

The iteration root: for each

for each "<path>" sits between the header and map:. Each node the path matches is one row; for an array selection such as [*], rows follow document order.

  • Paths in map: are relative to the current match: $ means the matched node, not the document.
  • Without for each, the whole document is the single row, so for each "$" is the same as leaving the line out.
  • A for each that matches nothing yields zero rows and a warning, iteration path '<path>' matched nothing, since a typo looks the same as an empty dataset.
  • A match may be a scalar: for each "$.tags[*]" with "$" as ?tag binds each tag in turn.
  • for each under as CSV is a parse error: for each is not applicable to CSV sources. A second for each line is a parse error too.

Path bindings

On the left of as, a quoted string is a path under as JSONPath and a column name under as CSV. Under JSONPath the path must be quoted: a bare name is a parse error, a JSONPath binding must be a quoted path expression. Quote member names with spaces inside the path: "$['stop id']".

Path syntax, including the for each path, is checked at parse time: invalid JSONPath expression '<path>': <reason>. Whether a path matches anything is only known per row.

Matches and values

Within one row a path may match zero, one or several nodes:

MatchResult
None, or nullAn empty value: the empty-cell rules apply unchanged
One scalarBinds the scalar’s text
One object or arrayThe row is skipped: row <n>: binding ?<var> matched a non-scalar node
Several nodesThe first one binds, with a warning: row <n>: binding ?<var> matched <N> nodes, using the first

To get one row per node instead, move the path into for each.

A scalar binds as raw text, whatever its JSON type: 1 binds "1", true binds "true", a string binds its content. Without a cast the result is a plain string literal, exactly like a CSV cell; with one, the same casts apply. JSON types never pick the literal type.

Numbers bind as re-printed by the JSON parser, not as written: 1.50 binds "1.5". Object members are visited in key order, which only matters when a path selects several members of one object.

A document that is not valid JSON fails the whole mapping: malformed JSON in mapping source '<path>': <reason>.

A shape form of map: (a JSON literal with ?var holes) is planned, as sugar over path bindings.

XML mappings

as XPath reads an XML document. Values are addressed by XPath strings, from a fixed subset of XPath 1.0, and a for each line says what counts as a row. Everything else — the source, the to: block, casts and the empty-value rules — is shared with CSV and JSON.


prefix <http://example.com/ns#> as ex

mapping from <package://Transport.xml> as XPath
  for each "/transport/bus/route/stop"
  map:
    "@id" as ?stop
    "text()" as ?label
  to:
    fact <http://trans.example.com/stop/{?stop}> a ex:Stop
      ex:label ?label

Given Transport.xml:

<transport>
  <bus id="25">
    <route>
      <stop id="645">Central</stop>
      <stop id="651">Airport</stop>
    </route>
  </bus>
</transport>

each <stop> element is one row, so the template expands twice: stop 645 labelled "Central" and stop 651 labelled "Airport".

The XML iteration root: for each

for each "<path>" sits between the header and map:. Each node the path matches is one row, in document order.

  • Paths in map: are relative to the current match: @id is the matched element’s attribute and . is the matched node itself. An absolute path (/… or //…) still starts from the document root, whatever the row.
  • Without for each, the whole document is the single row, and relative paths start at the document node: transport/bus/@id and /transport/bus/@id select the same nodes. for each "." is the same as leaving the line out (/ alone is not a valid path).
  • A for each that matches attributes ("//stop/@id") gives rows that are skipped with row <n>: for each matched an attribute; iterate over its element instead: iterate over the element and bind @id. A for each on text nodes ("//stop/text()") is fine; . binds each one’s text.
  • A for each that matches nothing warns iteration path '<path>' matched nothing and gives no rows.
  • for each and its rules are otherwise those of JSON mappings; a second for each line is a parse error.

XPath path bindings

On the left of as, a quoted string is an XPath expression under as XPath. The path must be quoted: a bare name is a parse error, a XPath binding must be a quoted path expression. Inside a path, write string literals with single quotes ([@kind='express']), so that they do not end the quoted binding.

A path outside the subset, or one that is not XPath at all, is rejected with one of the messages listed there — never a silent empty match.

XML matches and values

Within one row a path may match zero, one or several nodes. The 0/1/N rules are those of JSON mappings:

MatchResult
NoneAn empty value: the empty-cell rules apply unchanged
One scalar node (see below)Binds that node’s text
One element with element childrenThe row is skipped: row <n>: binding ?<var> matched a non-scalar node
Several nodesThe first one in document order binds, with a warning: row <n>: binding ?<var> matched <N> nodes, using the first

What a single node binds:

  • an attribute binds its value: @id on <stop id="645"> binds "645";
  • a text node (text(), CDATA included) binds its text;
  • an element with only text children binds that text: <stop id="645">Central</stop> binds "Central", so one element can give both its @id and its label; an empty element binds an empty value;
  • an element with element children is non-scalar, and the row is skipped. Whitespace between child elements is layout, not content;
  • an element with mixed content, non-blank text beside child elements (<a>x<b>z</b>y</a>), binds all its descendant text concatenated ("xzy"), with a warning: row <n>: binding ?<var> matched mixed content, using its concatenated text.

As with JSON, a value binds as raw text: without a cast it is a plain string literal; with one, the same casts apply. Text is not trimmed.

XPath subset

as XPath supports a small, fixed subset of XPath 1.0. Anything outside it is an error, never an empty result.

FormMeaning
/a/bAbsolute path: child steps from the document root
a/bRelative path: child steps from the current row’s node
.The context node itself (./@id is @id)
*Any element, in any namespace
//b, a//bDescendant-or-self, then a child step: b at any depth below
@attrAn attribute of the context element
text()The text-node children of the context element
a[n]The n-th matching child, 1-based and counted per parent
a[@attr='value']Matching children whose attribute equals the string (either quote)
  • Names may be prefixed (t:bus, @xml:lang): see Namespaces.
  • Predicates apply to element steps (a name or *) only, and may be stacked; each one filters the result of the previous one, per parent. Over <route><stop id="645"/><stop id="651"/></route>, stop[@id='651'][1] selects the second stop while stop[1][@id='651'] selects nothing.
  • An attribute or text() step must be the last step.
  • Results come back in document order, without duplicates (//a//b returns each b once, however many a ancestors it has).
  • //stop[1] is the first stop of each parent, as in XPath, not the first stop of the document.

A construct outside the subset fails with unsupported XPath construct '<c>' — see docs/src/language/reference/syntax.md#xpath-subset, naming the construct:

Written<c> in the message
....
An axis: child::a, ancestor::bus, parent::*child::, ancestor::, parent::
Any function but text(): count(//bus), node(), last(), position()count(), node(), last(), position()
A union: //bus | //stop|
A comparison other than =: [@id!='1'], [@id<'1'], [@id>='1']!=, <, >= (likewise >, <=)
Arithmetic or grouping: [1 + 1], -1, (//bus)[1]+, -, (
A variable: [@id=$id]$id
and, or, div, mod in a predicatethe keyword
Any other predicate: [@id], [route='x'], [@id=1]the whole predicate, e.g. [@id=1]
A wildcard attribute or prefixed wildcard: @*, t:*@*, t:*

Text that is not an XPath expression fails with invalid XPath expression '<path>': <reason>:

ExampleReason
""empty path
/a/, /, a//trailing '/'
a[0]positions are 1-based, '[0]' matches nothing
a[1unbalanced '['
a]unbalanced ']'
a[@id='xunterminated string literal
a bexpected '/' between steps
@id[1]predicates apply only to element steps
@id/xan attribute or text() step must be the last step
a/#unexpected character '#'

Namespaces

XML names can live in namespaces; JSON has none. A path names them through the .dlf file’s own prefix declarations:

  • A prefix in a path (t:bus, @t:id) resolves through the prefix <…> as t declarations of the same .dlf file. Matching is by namespace IRI, so the file’s prefix need not be the one the document uses. Only IRI declarations name a namespace: a package alias such as prefix hr.human as h has no namespace IRI, so h: in a path counts as undeclared. The IRI is compared exactly as written (a trailing # or / matters, as in xmlns).
  • An unprefixed element name matches that local name in any namespace, including none.
  • An unprefixed attribute matches only an attribute in no namespace: @id matches id="1" but not x:id="1". This is deliberate — unprefixed attributes are in no namespace in XML, whatever their element’s namespace.
  • xml: is predefined (@xml:lang) and needs no declaration.
  • A prefix with no declaration fails, even if nothing would have matched: undeclared prefix '<p>' in XPath expression.

prefix <http://trans.example.com/ns#> as t

mapping from <package://Transport.xml> as XPath
  for each "/t:transport/t:bus"
  map:
    "@id" as ?bus
  to:
    fact <http://trans.example.com/bus/{?bus}> a t:Bus

reads <tr:transport xmlns:tr="http://trans.example.com/ns#"><tr:bus id="25"/></tr:transport>: t: and tr: name the same namespace, and @id is a no-namespace attribute. For this one-bus document, the single binding "/t:transport/t:bus/@id" as ?bus without for each binds the same value.

Spreadsheet mappings

as XLSX reads an Office Open XML workbook (.xlsx, and .xlsm, whose macros are never run); as ODS reads an OpenDocument spreadsheet (.ods). One mapping reads one worksheet, row by row, exactly like a CSV file: the map: block binds column names, and the source, the to: block, casts and the empty-value rules are shared with CSV.

The selector says which reader opens the file; the extension is never looked at, so as XLSX on an .ods file fails with malformed XLSX in mapping source '<path>': <reason>. .xls and .xlsb are not read. Neither are .odt and .docx: those are word-processor documents (OpenDocument Text, Word), with tables inside prose and no sheets, so an .odt is not an .ods.

Spreadsheets are read by radeau build and radeau check only. The playground and Agrafe read source files as text and do not include the spreadsheet reader, so there an as XLSX or as ODS mapping fails with unsupported mapping format 'XLSX' (resp. 'ODS').


prefix <http://vocab.org/transit/terms/> as transit

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id     as ?id
    opened as ?opened
    ok     as ?ok
    n      as ?n
  to:
    fact <http://airport.example.com/stop/{?id}> a transit:Stop
      transit:opened {?opened} as date
      transit:open {?ok} as boolean
      transit:platforms {?n} as int

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id as ?id
    f  as ?code
  to:
    fact <http://airport.example.com/code/{?id}> a transit:Code
      transit:value {?code}

Given workbook.xlsx, whose sheet Stops holds a banner, a blank row, then the header on row 3 (shown as the spreadsheet application displays it):

ABCDE
1Stop register
2
3idopenedoknf
465232024-01-01 (date)TRUE25=A4*2 (shows 13046)
5
665242024-01-01 12:00 (date and time)FALSE0.5=1/0 (shows #DIV/0!)

only sheet row 4 survives, in both mappings:

<http://airport.example.com/stop/6523> rdf:type owl:NamedIndividual
  , transit:Stop
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/stop/6523> transit:opened "2024-01-01"^^xsd:date .
<http://airport.example.com/stop/6523> transit:open "true"^^xsd:boolean .
<http://airport.example.com/stop/6523> transit:platforms "25"^^xsd:integer .

<http://airport.example.com/code/6523> rdf:type owl:NamedIndividual
  , transit:Code
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/code/6523> transit:value "13046" .

Sheet row 6 is skipped by the first mapping because its date has a time (as date fails, see Dates) and by the second because its f cell is an error.

Sheet and header row

Two optional lines sit between the header and map:, sheet first by convention:

  • sheet "<name>" picks the worksheet. The name is always quoted and must match exactly, case and spaces included. Without it, the mapping reads the first worksheet in tab order (chart sheets are skipped, hidden sheets are not). A name that matches no worksheet fails the whole mapping: mapping references unknown sheet '<name>'.
  • header row <N> says which row holds the column names, counted the way the spreadsheet application numbers its rows (row 1 holds A1). Without it, the header is the first non-empty row, so a title banner above the table would become the header. Rows above the header are ignored; data starts on the row after it. An empty header row fails the whole mapping: mapping header row <N> is empty. header row 0 is a parse error.
  • for each is a parse error under as XLSX / as ODS, as under CSV: the row is the iterator. sheet and header row are parse errors under every other format.

Blank rows are skipped and not counted: row <n> in diagnostics is the 1-based index among the data rows, not the row number the application shows. In the example, sheet row 6 is row 2.

Cell values

A cell binds the text of its stored value, then the usual casts apply. The cell’s type never picks the literal type: without a cast the result is a plain string literal, as for a CSV cell.

CellBinds
TextIts text, verbatim; an empty string is an empty value
NumberShortest text that reads back as the same number, never with an exponent: 25 binds "25" (not "25.0"), 0.1 binds "0.1", -0 binds "0"
Boolean"true" or "false"
Date, timeISO-8601 text, see Dates
FormulaIts cached result, the value the application saved; formulas are never recalculated
Error (#DIV/0!, #REF!, …)The row is skipped: row <n>: cell error <code> for binding ?<var>, even for an optional property
EmptyAn empty value: the empty-cell rules apply

Number formats other than dates are not applied: a cell showing 50% binds "0.5", €3.00 binds "3". A formula saved without a cached value (some libraries write files like that) binds empty, with one warning per mapping, formula cells without a cached value read as empty (first: row <n>, binding ?<var>); opening and saving the file in a spreadsheet application fixes it. An error cell in a column the mapping does not bind is ignored. OpenDocument has no error cells: LibreOffice saves =1/0 as an empty string, so in an .ods the same row binds an empty value instead of being skipped.

Dates in cells

A cell formatted as a date binds ISO-8601 text, so as date works on it:

Cell valueBinds
A date, no time partYYYY-MM-DD: 2024-01-01
A date with a timeYYYY-MM-DDTHH:MM:SS: 2024-01-01T12:00:00
A time of day onlyHH:MM:SS: 18:00:00
An elapsed time ([h]:mm)an ISO-8601 duration: PT36H00M00S

A date with a time is not truncated: as date fails on it (row <n>: cannot cast '2024-01-01T12:00:00' to date for property <p>), so a time part is never dropped silently. A date-formatted =NOW() therefore fails as date; use =TODAY() in the sheet.

Both XLSX date systems (1900, and the 1904 system of old Mac files) are read as the application displays them. Excel’s non-existent 1900-02-29 (serial 60) binds "1900-02-29", which as date rejects. No time zone is added.


Exports

An export is the reverse of a mapping: it runs a query and writes one row per solution to a file. It has no name; its target file (and sheet) identifies it.

The query can be a named one:


query bulletin_s1:
  ?e a Etudiant
    numero ?num
    nom ?nom
  average ?v as ?moyenne
    ?note etudiant ?e
      valeur ?v
  return
    ?num
    ?nom
    ?moyenne
      order desc

export
  from query bulletin_s1
  map:
    "Numéro étudiant" as ?num
    "Nom" as ?nom
    "Moyenne" as ?moyenne as float
  to:
    <export://bulletins.xlsx> as XLSX
    sheet "Semestre 1"

or written in place, under from::


export
  from:
    ?e a Etudiant
      numero ?num
      nom ?nom
    average ?v as ?moyenne
      ?note etudiant ?e
        valeur ?v
  map:
    "Numéro étudiant" as ?num
    "Nom" as ?nom
    "Moyenne" as ?moyenne as float
      order desc
    limit 10
  to:
    <export://bulletins.csv> as CSV

The general form is:

export
  from query <query-name>           (or  from:  + query clauses)
  map:
    <column> as ?<var> [as <cast>]
      [order asc|desc]
      [distinct]
    ...
    [limit <N>]
  to:
    <export://<path>> as <FORMAT>
    [sheet "<name>"]
    [for each "<path>"]

The source

from query <name> names a query (a qualified name, so a query of another package works too). from: holds a query body in place: query clauses and an optional group by, but no return: the map: block plays that part.

The map: block

Each line writes one variable into one column: the column header (a Name, or a String when it holds spaces or other characters), as, the variable, and an optional cast written after it (as int, float, string, boolean, date or quantity(<unit>), the casts of a mapping read in reverse). The column modifiers of return (order asc|desc, distinct) go in an indented block under the line they apply to, and limit <N> may close the block, as in return.

map: is required with from:. With from query, it may be left out: the columns are then the query’s return columns, in order.

The to: block

The first line is the target file, <export://<path>> as <FORMAT>, followed by the optional sheet "<name>" (spreadsheets) and for each "<path>" (JSON) lines of a mapping, each at most once. header row is a reading option and is refused here.

export is not a reserved word: it is recognised only at the start of a top-level declaration, so a concept or property named export keeps working. from, map, limit, to, sheet, for and each are contextual too, as in a mapping; only query is reserved.

Rules

A rule defines an if-then inference. When all patterns in the match: block hold, the assertions in the then: block are applied.


rule classify_senior:
  match:
    ?p a Person
    ?p age [ >= 65 ]
  then:
    ?p a SeniorPerson

Variables

Variables begin with ?. They bind to values during matching and can be referenced in assertions:


rule link_preferred_contact:
  match:
    ?org a Organization
    ?org primaryContact ?person
  then:
    ?person worksFor ?org

Match patterns

Each line in match: is a pattern. All patterns are combined with implicit AND.

Type pattern: checks that a variable is an instance of a concept:


?x a SomeConcept

Triple pattern: checks that a subject has a property with a given value:


?x someProperty someValue
?x someProperty ?y
?x someProperty "literal"
?x someProperty 42

Constraint block: inline conditions on a value using [...]:


?x age [ > 18 ]
?x status [ = "Active" ]
?x address [ city "Paris" ]
?x manager [ a Director ]

Multiple constraints in a block are AND-combined:


?x score [ >= 50, <= 100 ]

Constraint blocks can be nested:


?x owner [ address [ country "France" ] ]

Property paths

In a match: triple, the property can be a path: ?x <path> ?y. The pattern matches when ?y can be reached from ?x by following the path.

OperatorMeaningPrecedence
( )Grouping1 (tightest)
p+One or more steps of p2
p*Zero or more steps of p2
p?Zero or one step of p2
^pInverse: follow p backwards3
!(p|q)One step through any property except those listed (also !p, !^p)3
p1/p2Sequence: p1, then p24
p1|p2Alternative: p1 or p25 (loosest)

So x|y/z is x|(y/z), ^p+ is ^(p+), and ^p/q is (^p)/q. A path is written without spaces; a single name, even (p), is a plain triple.

Ancestors, at any depth:


rule ancestor:
  match:
    ?a parentOf+ ?d
  then:
    ?a ancestorOf ?d

Siblings, plus the person themself (zero steps of siblingOf):


rule sibling_or_self:
  match:
    ?x a Person
    ?x siblingOf? ?y
  then:
    ?x familyOf ?y

Uncles: a brother of the father or of the mother:


rule uncle:
  match:
    ?u brotherOf/(fatherOf|motherOf) ?n
  then:
    ?u uncleOf ?n

Limits. Paths only appear in match:; a then: assertion must be a plain triple, and ?a ancestorOf+ ?b in then: is a parse error. A path that can match zero steps (p*, p?, or any path built only from them) needs at least one end bound by another pattern of the rule, as ?x a Person does above. With both ends free, the reasoner rejects the rule when it loads it (“needs one end bound by another condition”). Cycles in the data are fine: the search does not loop.

Comparison operators

Used inside constraint blocks:

OperatorMeaning
=Equal
!=Not equal
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal

A comparison takes a literal or a variable, not a name. To match a named individual, write the triple directly: ?x status Active.

Quantifiers

Quantifiers express conditions over collections of matching bindings:

QuantifierMeaning
allEvery binding must satisfy the sub-patterns
noneNo binding may satisfy the sub-patterns
at least NAt least N bindings must satisfy the sub-patterns
at most NAt most N bindings may satisfy the sub-patterns
exactly NExactly N bindings must satisfy the sub-patterns
between N, MBetween N and M bindings (inclusive)

rule require_manager_approval:
  match:
    ?req a Request
    none ?approver:
      ?approver a Manager
      ?req approvedBy ?approver
  then:
    ?req a UnapprovedRequest

An optional constraint block on the quantifier variable filters the set being quantified:


at least 2 ?member [ a SeniorEmployee ]:
  ?member worksIn ?dept

Aggregates in rules

A match: line can compute a value over a group of bindings, with the same line as a query’s per-row aggregation: an aggregate, the aggregated variable, as, the result variable, then an indented block of match lines. The block is grouped by the variables it shares with the lines above it, so it computes one value per binding of those lines.


rule busy_owner:
  match:
    ?owner a Owner
    count ?pet as ?n
      ?pet owner ?owner
    ?n [ > 5 ]
  then:
    ?owner a BusyOwner

rule under_vaccinated_dog:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ < 2 ]
  then:
    ?dog a UnderVaccinatedDog
AggregateValue
countNumber of bindings of the variable
count distinctNumber of distinct values of the variable
sumSum of a number, duration or quantity variable
averageAverage of a number, duration or quantity variable
min, maxSmallest / largest number, date, duration or quantity
  • The aggregated variable must be bound inside the block.
  • The result variable is bound from the next line on: later match: lines and then: can use it, an earlier line or the block itself cannot. A variable bound only inside the block is not visible after it.
  • ?n [ > 5 ] is a condition on a bound variable: the value must satisfy the constraint block, as in ?x age [ > 5 ].
  • then: can write the computed value: ?owner pet_count ?n.
  • An aggregate cannot be nested in another aggregate’s block (a parse error). Compute the inner value in a separate rule.

Semantics.

  • A count (or count distinct) over an empty group is 0: the under_vaccinated_dog rule above fires for a dog with no vaccination. sum, average, min and max have no value over an empty group, so the rule does not fire for that row.
  • Quantities of the same unit keep their unit (sum of kg values is in kg). Quantities of mixed units of the same dimension are converted to the SI unit first (g and kg sum to kg). Mixing dimensions is an error.
  • Rules are stratified around aggregates: a rule that aggregates over a property runs after every rule that can derive that property. A cycle through an aggregate (a rule whose aggregate depends, directly or through other rules, on its own conclusion) is a compile-time error.

Status. The SHACL output (radeau build --emit shacl, radeau validate) runs aggregate rules as SHACL-AF sh:SPARQLRules, with the semantics above; mixed units are converted with the qudt: unit definitions of the Turtle output, which the data must include. The N3 output writes such a rule commented out, with the reason.

Then assertions

Each line in then: asserts a new fact:

Type assertion: classifies a variable as an instance of a concept:


?x a SomeConcept

Triple assertion: asserts a property relationship:


?x someProperty ?y
?x someProperty "value"

Nested rules

A then: block can contain a nested match:/then: block for conditional sub-inferences:


rule complex_inference:
  match:
    ?x a Foo
  then:
    match:
      ?x bar ?y
    then:
      ?y a Baz

Queries

A query asks a question of the data. Where a rule adds facts, a query only reads them: it describes a shape to look for and the columns to give back. Each query is translated to one SPARQL 1.1 SELECT.


query senior_cats:
  ?cat a Cat
    name ?name
    age [?age >= 10]
  return
    ?name
    ?age
      order desc
    limit 5
SELECT ?name ?age
WHERE {
  ?cat a Cat .
  ?cat name ?name .
  ?cat age ?age .
  FILTER (?age >= 10)
}
ORDER BY DESC(?age)
LIMIT 5

A query body is, in this order: any number of clauses, an optional group by block, and an optional return block.

Variables in queries

Variables are written ?name, as in rules. A variable starting with ?_ (?_animal) is silent: it takes part in matching but is never returned.

Subject patterns

A subject pattern names a subject and lists, one per indented line, the properties it must have. All lines are AND-combined.


?cat a Cat            # typed subject
  name ?name
  age ?age

a Cat                 # anonymous typed subject (a fresh silent variable)
  name ?name

?cat                  # untyped subject
  name ?name

A pattern with a single property fits on one line:


?animal treatedBy ?vet
?animal treatedBy [ a Intern ]
?vet is treatedBy of ?animal

Property patterns

Inside a subject block, each line is one of:

FormMeaning
name ?nameThe subject has name; bind its value to ?name
urgency EmergencyThe value is this named individual or literal
age [ > 12 ]The value satisfies a constraint block
age [?age >= 10]Bind the value to ?age and filter on it
optional email ?emailBind ?email when the property exists, keep the row otherwise (OPTIONAL)
is treatedBy of ?animalInverse: ?animal treatedBy the subject
owner [ … ]Nested anonymous subject, one property per line
either … or …At least one branch holds (UNION)

A nested subject spans several lines between [ and ]:


query owners_of_dogs:
  a Dog
    name ?dogName
    owner [
      lastName ?ownerName
      optional email ?email
    ]
SELECT ?dogName ?ownerName ?email
WHERE {
  ?_s0 a Dog .
  ?_s0 name ?dogName .
  ?_s0 owner ?_n1 .
  ?_n1 lastName ?ownerName .
  OPTIONAL { ?_n1 email ?email . }
}

either and or take a property and a constraint block. Add as many or lines as needed:


query heavy_or_old:
  ?pet a Animal
    name ?name
    either weight [ > 30.0 ]
    or age [ > 12 ]

Existence: some: and none:

some: keeps a row only if its inner clauses match at least once (FILTER EXISTS); none: keeps it only if they never match (FILTER NOT EXISTS). Variables shared with the outer query refer to the same resource; the others are local to the block.


query vets_without_patients:
  ?vet a Veterinarian
    name ?name
  none:
    ?vet is treatedBy of ?_animal
SELECT ?vet ?name
WHERE {
  ?vet a Veterinarian .
  ?vet name ?name .
  FILTER NOT EXISTS {
    ?_animal treatedBy ?vet .
  }
}

Filters

A clause of the form ?var op value filters rows, value being a literal or another variable. The operators are the comparison operators.


?patients > 3
?directorAvg > ?globalAvg

Grouping and aggregation: group by

A group by ?var block, after the clauses, groups rows by ?var. Each line inside is either an aggregation kind ?input as ?result, with kind one of count, count distinct, sum, average, min, max, or a filter on a result (HAVING). count distinct ?x counts each value of ?x once (COUNT(DISTINCT ?x)).


query animals_per_species:
  ?animal a Animal
    species ?species
  group by ?species
    count ?animal as ?total
    ?total >= 2
  return
    ?species
    ?total
      order desc
SELECT ?species (COUNT(?animal) AS ?total)
WHERE {
  ?animal a Animal .
  ?animal species ?species .
}
GROUP BY ?species
HAVING (COUNT(?animal) >= 2)
ORDER BY DESC(?total)

Without a return block, a grouped query returns the group by variable and the aggregates: other variables have no single value per group.

Aggregating per row

An aggregation can also stand in the body, followed by an indented block of clauses. It computes one value per row of the rest of the query: the block is grouped by the variables it shares with the clauses above it. With no shared variable, it computes a single value over all the data.


query vaccine_count:
  ?animal a Animal
    name ?name
  count ?shot as ?shots
    ?animal vaccinations ?shot
SELECT ?animal ?name ?shots
WHERE {
  ?animal a Animal .
  ?animal name ?name .
  {
    SELECT ?animal (COUNT(?shot) AS ?shots)
    WHERE {
      ?animal vaccinations ?shot .
    }
    GROUP BY ?animal
  }
}

Only variables written above the aggregation are shared with it.

The return block

return lists the result columns, one per line. Without a return block, every non-silent variable is returned, in order of first appearance.

LineMeaning
?nameReturn ?name as column name
ownerName ?nReturn ?n as column ownerName
limit 10At most 10 rows (LIMIT)

A column may carry an indented modifier block:

ModifierMeaning
order asc / order descSort on this column. Several sorted columns sort in column order
distinctDrop duplicate rows. Applies to the whole result

Composing queries

A query can use another query of the same file as a clause. Its rows, after its own grouping, ordering and limit, are joined with the rest of the body (a SPARQL subquery). Bind its columns to local variables by column name:


query cats_with_vet:
  ?cat a Cat
    treatedBy ?vet
  return
    ?cat
    ?vet

query cat_vet_names:
  cats_with_vet as [
    cat ?c
    vet ?v
  ]
  ?c name ?catName
  ?v name ?vetName
SELECT ?c ?v ?catName ?vetName
WHERE {
  {
    SELECT (?cat AS ?c) (?vet AS ?v)
    WHERE {
      {
        SELECT ?cat ?vet
        WHERE {
          ?cat a Cat .
          ?cat treatedBy ?vet .
        }
      }
    }
  }
  ?c name ?catName .
  ?v name ?vetName .
}

The short form heavy_dogs as ?dog binds the first column. Binding a column the called query does not return is an error, and so is a query that uses itself, directly or not.

Names in the SPARQL

The examples on this page show property and concept names as written. When a package is compiled to Turtle, each query is written out as a commented SPARQL SELECT, with every name resolved to its IRI in the package.


Names and identifiers

Simple names are alphanumeric identifiers (with underscores): Person, firstName, status.

Qualified names use dot-notation to reference names in a namespace: schema.Person, com.example.Thing.

IRIs are enclosed in angle brackets: <http://example.com/Thing>. They can appear as package names and prefix targets.

Variables begin with ?: ?person, ?count.


The @iri_name annotation

The @iri_name annotation overrides the IRI that Dolfin derives from the file’s path. It has several forms.

File-level, string. At the top of an ontology file, before any declaration, a string replaces the last segment of the file’s namespace:


@iri_name "custom-segment"

concept Foo:
  has bar: string

File-level, IRI. An absolute IRI replaces the whole namespace of the file:


@iri_name <http://animals.kingdom/Mammalian>

Concept-level. Inside a concept body, an absolute IRI is used verbatim for that concept only:


concept Foo:
  @iri_name <http://schema.org/Person>
  has bar: string

Property-level. Inside a property body, the same absolute form sets that property’s IRI. A property can have a body with only this line:


property occupiesTemporalRegion
  @iri_name <http://purl.obolibrary.org/obo/BFO_0000199>

Field-level. Indented under a has line, it sets the IRI of the field’s property. It mixes with the field’s axioms in any order. Every has of that name in the file is the same property, so they all get this IRI; two fields of one name giving different IRIs is an error (S019):


concept Truc:
  @iri_name <https://example.com/Truc>
  has dependence: Dep
    @iri_name <https://example.com/truc_dep>
    inverse of dependent_of

Entity-level @iri_name takes only the absolute <…> form; the string form is file-level only.

This is useful when the file or concept name doesn’t match the IRI expected by external systems, or contains characters that cannot appear in an IRI. See Turtle & OWL Correspondence for the resulting IRIs.


The #@ owl marker

A bare #@ owl line in a concept’s leading comment block records that the concept is an OWL class: the Turtle output types it owl:Class instead of rdfs:Class. Importing Turtle adds it to every concept the source declared owl:Class, so that type survives a round trip.


#@ glossary:
#@   pref_label@fr Chat
#@ owl
# A small feline.
concept Cat

Place it after any #@ glossary: block, on its own line. See Turtle & OWL Correspondence.


File-level directives

Two directives set defaults for temporal values across the whole file. Like @iri_name they sit at the top of the file; they may appear before or after prefix statements, in any order, but before the first declaration.

@locale

@locale sets the field order (and separator) for numeric dates that carry no inline as mask:


@locale d/m/y

fact bob a Person
  birthDate date(01/06/2026)   # → 2026-06-01, no mask needed

The order is any arrangement of d, m, y (e.g. m/d/y, y-m-d). The separator in the directive must match the one used in numeric dates. Without @locale (and without an inline mask), a numeric date is a parse error.

@timezone

@timezone sets the default timezone applied to any time / date_time value that has no inline offset. It accepts an IANA name, a fixed offset, or UTC/Z:


@timezone Europe/Brussels

fact e a Event
  startsAt time(14:30)        # → 14:30:00+01:00

An inline timezone always wins over @timezone; an inline as mask always wins over @locale. Neither directive affects date or duration values.

IANA names such as Europe/Brussels are resolved from a bundled timezone database, available when the parser is built with the iana-tz feature (on by default). Fixed offsets (+02:00) and abbreviations (CET, UTC) work regardless.