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"
| Field | Required | Description |
|---|---|---|
dolfin_version | yes | Language version to use (currently "1") |
version | yes | Ontology version (semver string) |
author | no | Author name |
description | no | Human-readable description of the ontology |
import | no | An ontology this package reuses, one line each. See Imports |
reasoning | no | Reasoning 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:
| Importee | Namespace IRI | Name |
|---|---|---|
Dolfin package com.acme.hr, file person.dlf | …/com/acme/hr/person# | com.acme.hr.person |
| Web vocabulary | http://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 avin 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
appimportshrandhrimportsorg,appcannot reachorg’s names throughhr: it importsorgitself. A deployment still loads the whole import closure for reasoning. - Ambiguous names. When two imports map to the same dotted name, a
prefixnaming it fails withAmbiguous 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 schemawithout 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: thesymmetricandtransitiveproperty axioms.- Write any subset of
rdfsandowl, comma-separated, in any order, each at most once. The formatter writesrdfsfirst. reasoning noneturns reasoning off. It cannot be combined withrdfsorowl.- An unknown profile, a repeated profile, or
nonenext 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,
WorkingPersonis 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.
| Keyword | Meaning |
|---|---|
| (none) | Any number (zero or more) |
any | Any number (zero or more, explicit) |
one | Exactly one (required) |
optional | Zero or one (like at most 1, but never owl:FunctionalProperty) |
some | One or more |
N | Exactly N (integer literal) |
at least N | N or more |
at most N | N or fewer |
N..M | Between N and M (inclusive) |
N..* | At least N (same as at least N) |
As ranges:
| Keyword | Range |
|---|---|
one | 1..1 |
any | 0..* |
some | 1..* |
optional | 0..1 |
at least n | n..* |
at most n | 0..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 ownp valueline (a value inside a nested[ … ]block belongs to that block, not toF);is p of Fin another fact (F’s ownis p of xsetsponx, not onF);q Fin any fact, oris q of xinF, whenqisinverse ofp(orpofq): inverse links are always inferred;p Fin another fact whenpissymmetricandowlreasoning is on (see Reasoning). Withreasoning rdfsit 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
| Type | Description | Examples |
|---|---|---|
string | Text | "hello", "" |
int | Integer | 0, 42, -7 |
float | Floating-point | 3.14, -0.5 |
boolean | Boolean | true, 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).
| Type | Description | XSD type | Value example |
|---|---|---|---|
date | Calendar date | xsd:date | date(June 1st 2026) |
time | Wall-clock time | xsd:time | time(2:30 PM UTC) |
date_time | Date + time | xsd:dateTime | date_time(June 1st 2026, 14:30) |
duration | Length of time | xsd:duration | duration(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:
| Unit | y | mo | w | d | h | min | s |
|---|---|---|---|---|---|---|---|
| Means | years | months | weeks | days | hours | minutes | seconds |
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 occupiesTemporalRegionhas neither): nordfs:domain/rdfs:rangeis emitted for it. - Each side is given once. Giving it both in the one-line
D -> Rand in adomain/rangeline, or in two lines, is error S014. - Cardinalities belong to the one-line form:
domain/rangelines take none. domainandrangeare not reserved: a field or fact property nameddomainkeeps 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 valueorkey=value— space, not colon: writelabel "First name", notlabel: "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
hasline it decorates. A property-level annotation can only ever apply to the onehasline 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
labelortitleis 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:):
| Key | Meaning | Default when absent |
|---|---|---|
title | Form heading | #@glossary label, else the concept name |
display | Which property’s value labels this concept’s instances in a reference picker | consumer-defined fallback |
Property-level keys (above one has line):
| Key | Meaning | Default when absent |
|---|---|---|
label | Field label | #@glossary label, else the property name humanized (first_name → “First name”) |
placeholder | Input placeholder text | none |
help | Field-level help text | #@glossary definition, else none |
widget | Overrides the type-inferred widget (below) | inferred from the property’s declared type |
row | Grid row (fields sharing a row render side by side) | one field per row, in has-declaration order |
col | Grid column within a row | left to right, in has-declaration order |
group | Subheading text grouping several fields | none |
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 type | Default widget | Notes |
|---|---|---|
string | text | widget textarea opts into a multi-line field (never inferred) |
int / float | number | |
boolean | checkbox | |
date | date | native date picker |
time | time | native time picker |
date_time | date_time | native datetime picker |
duration | duration | free-text, no native widget |
a one of: closed-concept type | select | widget radio opts into radio buttons |
| a reference to another concept | reference | renders 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 ahasdeclaration (or inherited one) on the concept.
Value forms
| Form | Example | Meaning |
|---|---|---|
| String literal | "Alice" | Text value |
| Integer literal | 42 | Integer value |
| Float literal | 3.14 | Floating-point value |
| Boolean literal | true | Boolean value |
| Temporal literal | date(June 1st 2026) | Date/time/datetime/duration (smart literal) |
| Enum value | Active | A one of member, unquoted |
| Reference | alice | Another named fact of the package |
| Anonymous block | [ ... ] | Inline instance (blank node) |
| List | v1, v2 | Multiple 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:
| Form | Example | Yields |
|---|---|---|
| Prefixed | ap:{?id} | <http://airport.example.com/6523> |
| Prefixed with text | ap:stop{?id} | <http://airport.example.com/stop6523> |
| Full IRI | <http://ex.com/{?id}/{?stop}> | <http://ex.com/6523/25> |
| Constant | ap:allStops | the same subject for every row |
Values. Every value form a fact accepts still works, plus:
| Form | Example | Meaning |
|---|---|---|
| Braced variable | {?lat} | The cell’s raw text |
| Bare variable | ?lat | Shorthand for {?lat} |
| Cast | {?stop} as int | Typed 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.
| Situation | Result |
|---|---|
Empty cell, property is optional or any | That one assertion is dropped; the rest of the row is emitted |
Empty cell, property is one, some or at least N | The whole row is skipped, with a diagnostic |
| Empty cell bound into the subject template | The 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.
| Condition | Holds 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:
| Cell | ev:op_{slug(?op)} |
|---|---|
ALDI SARL | ev:op_aldi_sarl |
Électricité de Lyon | ev: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, sofor each "$"is the same as leaving the line out. - A
for eachthat 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 ?tagbinds each tag in turn. for eachunderas CSVis a parse error:for each is not applicable to CSV sources. A secondfor eachline 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:
| Match | Result |
|---|---|
None, or null | An empty value: the empty-cell rules apply unchanged |
| One scalar | Binds the scalar’s text |
| One object or array | The row is skipped: row <n>: binding ?<var> matched a non-scalar node |
| Several nodes | The 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:@idis 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/@idand/transport/bus/@idselect the same nodes.for each "."is the same as leaving the line out (/alone is not a valid path). - A
for eachthat matches attributes ("//stop/@id") gives rows that are skipped withrow <n>: for each matched an attribute; iterate over its element instead: iterate over the element and bind@id. Afor eachon text nodes ("//stop/text()") is fine;.binds each one’s text. - A
for eachthat matches nothing warnsiteration path '<path>' matched nothingand gives no rows. for eachand its rules are otherwise those of JSON mappings; a secondfor eachline 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:
| Match | Result |
|---|---|
| None | An empty value: the empty-cell rules apply unchanged |
| One scalar node (see below) | Binds that node’s text |
| One element with element children | The row is skipped: row <n>: binding ?<var> matched a non-scalar node |
| Several nodes | The 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:
@idon<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@idand 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.
| Form | Meaning |
|---|---|
/a/b | Absolute path: child steps from the document root |
a/b | Relative path: child steps from the current row’s node |
. | The context node itself (./@id is @id) |
* | Any element, in any namespace |
//b, a//b | Descendant-or-self, then a child step: b at any depth below |
@attr | An 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 whilestop[1][@id='651']selects nothing. - An attribute or
text()step must be the last step. - Results come back in document order, without duplicates (
//a//breturns eachbonce, however manyaancestors it has). //stop[1]is the firststopof each parent, as in XPath, not the firststopof 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 predicate | the 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>:
| Example | Reason |
|---|---|
"" | empty path |
/a/, /, a// | trailing '/' |
a[0] | positions are 1-based, '[0]' matches nothing |
a[1 | unbalanced '[' |
a] | unbalanced ']' |
a[@id='x | unterminated string literal |
a b | expected '/' between steps |
@id[1] | predicates apply only to element steps |
@id/x | an 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 theprefix <…> as tdeclarations of the same.dlffile. 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 asprefix hr.human as hhas no namespace IRI, soh:in a path counts as undeclared. The IRI is compared exactly as written (a trailing#or/matters, as inxmlns). - An unprefixed element name matches that local name in any namespace, including none.
- An unprefixed attribute matches only an attribute in no namespace:
@idmatchesid="1"but notx: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):
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Stop register | ||||
| 2 | |||||
| 3 | id | opened | ok | n | f |
| 4 | 6523 | 2024-01-01 (date) | TRUE | 25 | =A4*2 (shows 13046) |
| 5 | |||||
| 6 | 6524 | 2024-01-01 12:00 (date and time) | FALSE | 0.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 holdsA1). 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 0is a parse error.for eachis a parse error underas XLSX/as ODS, as under CSV: the row is the iterator.sheetandheader roware 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.
| Cell | Binds |
|---|---|
| Text | Its text, verbatim; an empty string is an empty value |
| Number | Shortest 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, time | ISO-8601 text, see Dates |
| Formula | Its 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 |
| Empty | An 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 value | Binds |
|---|---|
| A date, no time part | YYYY-MM-DD: 2024-01-01 |
| A date with a time | YYYY-MM-DDTHH:MM:SS: 2024-01-01T12:00:00 |
| A time of day only | HH: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.
| Operator | Meaning | Precedence |
|---|---|---|
( ) | Grouping | 1 (tightest) |
p+ | One or more steps of p | 2 |
p* | Zero or more steps of p | 2 |
p? | Zero or one step of p | 2 |
^p | Inverse: follow p backwards | 3 |
!(p|q) | One step through any property except those listed (also !p, !^p) | 3 |
p1/p2 | Sequence: p1, then p2 | 4 |
p1|p2 | Alternative: p1 or p2 | 5 (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:
| Operator | Meaning |
|---|---|
= | 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:
| Quantifier | Meaning |
|---|---|
all | Every binding must satisfy the sub-patterns |
none | No binding may satisfy the sub-patterns |
at least N | At least N bindings must satisfy the sub-patterns |
at most N | At most N bindings may satisfy the sub-patterns |
exactly N | Exactly N bindings must satisfy the sub-patterns |
between N, M | Between 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
| Aggregate | Value |
|---|---|
count | Number of bindings of the variable |
count distinct | Number of distinct values of the variable |
sum | Sum of a number, duration or quantity variable |
average | Average of a number, duration or quantity variable |
min, max | Smallest / 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 andthen: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(orcount distinct) over an empty group is0: theunder_vaccinated_dogrule above fires for a dog with no vaccination.sum,average,minandmaxhave no value over an empty group, so the rule does not fire for that row. - Quantities of the same unit keep their unit (
sumofkgvalues is inkg). Quantities of mixed units of the same dimension are converted to the SI unit first (gandkgsum tokg). 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:
| Form | Meaning |
|---|---|
name ?name | The subject has name; bind its value to ?name |
urgency Emergency | The 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 ?email | Bind ?email when the property exists, keep the row otherwise (OPTIONAL) |
is treatedBy of ?animal | Inverse: ?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.
| Line | Meaning |
|---|---|
?name | Return ?name as column name |
ownerName ?n | Return ?n as column ownerName |
limit 10 | At most 10 rows (LIMIT) |
A column may carry an indented modifier block:
| Modifier | Meaning |
|---|---|
order asc / order desc | Sort on this column. Several sorted columns sort in column order |
distinct | Drop 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/Brusselsare resolved from a bundled timezone database, available when the parser is built with theiana-tzfeature (on by default). Fixed offsets (+02:00) and abbreviations (CET,UTC) work regardless.