Skip to content

SPARQL builder

app.sparql_builder

SPARQL generation from EntityShape descriptors plus the active filters.

Subject(var, type_var, suffix, declare_type) dataclass

Which variable a set of filter clauses constrains.

Filters read the same for a pin the map draws and for the pin that puts a region on the map, but they cannot share variable names: the region branch nests its copy inside FILTER EXISTS, where the outer ?type is already bound to compass:CountryArea.

Attributes:

Name Type Description
var str

Subject variable (e.g. ?s or ?pin).

type_var str

Variable holding the entity class IRI.

suffix str

Keeps helper variables distinct across the two copies.

declare_type bool

Bind type_var here rather than relying on an outer BIND.

to_prefixed(iri)

Convert a full IRI to a SPARQL prefixed name (e.g. compass:country).

Parameters:

Name Type Description Default
iri str

Absolute property IRI.

required

Returns:

Type Description
str

Prefixed name when the namespace is known, otherwise an <IRIREF>.

Source code in src/backend/app/sparql_builder.py
17
18
19
20
21
22
23
24
25
26
27
28
29
def to_prefixed(iri: str) -> str:
    """Convert a full IRI to a SPARQL prefixed name (e.g. ``compass:country``).

    Args:
        iri: Absolute property IRI.

    Returns:
        Prefixed name when the namespace is known, otherwise an ``<IRIREF>``.
    """
    for ns, prefix in PREFIX_MAP.items():
        if iri.startswith(ns):
            return prefix + iri[len(ns) :]
    return iri_term(iri)

_is_iri_value(value)

True when a filter value names a concept rather than a literal tag.

A tag dimension can be filtered either by concept IRI or by literal text, so the two are told apart by shape. The IRI check is the grammar's, not a guess: a value that cannot be written as an IRIREF is treated as a literal rather than interpolated between brackets.

Parameters:

Name Type Description Default
value str

Raw query-parameter value.

required

Returns:

Type Description
bool

Whether value is a safe absolute HTTP(S) IRI.

Source code in src/backend/app/sparql_builder.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def _is_iri_value(value: str) -> bool:
    """True when a filter value names a concept rather than a literal tag.

    A tag dimension can be filtered either by concept IRI or by literal text,
    so the two are told apart by shape. The IRI check is the grammar's, not a
    guess: a value that cannot be written as an IRIREF is treated as a literal
    rather than interpolated between brackets.

    Args:
        value: Raw query-parameter value.

    Returns:
        Whether *value* is a safe absolute HTTP(S) IRI.
    """
    return value.startswith(("http://", "https://")) and is_iri(value)

build_optional(spec, lang)

Build the OPTIONAL clause that binds one EntityShape property.

Parameters:

Name Type Description Default
spec EntityShape

Property descriptor from SHACL.

required
lang str

Preferred language for labels / langString filters.

required

Returns:

Type Description
str

SPARQL OPTIONAL fragment, or empty string for unknown categories.

Source code in src/backend/app/sparql_builder.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def build_optional(spec: EntityShape, lang: str) -> str:
    """Build the OPTIONAL clause that binds one EntityShape property.

    Args:
        spec: Property descriptor from SHACL.
        lang: Preferred language for labels / langString filters.

    Returns:
        SPARQL OPTIONAL fragment, or empty string for unknown categories.
    """
    sid = spec.id
    path = to_prefixed(spec.path_iri)
    cat = spec.category

    if cat == "lang_literal":
        return f'OPTIONAL {{ ?s {path} ?{sid} . FILTER(lang(?{sid}) = "{lang}") }}'
    if cat in ("simple_literal", "uri_literal", "boolean"):
        return f"OPTIONAL {{ ?s {path} ?{sid} . }}"
    if cat == "iri_with_label":
        return (
            f"OPTIONAL {{\n"
            f"            ?s {path} ?{sid}Node .\n"
            f"            OPTIONAL {{ ?{sid}Node skos:prefLabel ?{sid}Skos . "
            f'FILTER(lang(?{sid}Skos) = "{lang}") }}\n'
            f"            OPTIONAL {{ ?{sid}Node rdfs:label ?{sid}Rdfs . "
            f'FILTER(lang(?{sid}Rdfs) = "{lang}") }}\n'
            f"            BIND(COALESCE(?{sid}Skos, ?{sid}Rdfs) AS ?{sid}Lab)\n"
            f"        }}"
        )
    return ""

build_select_expr(spec)

GROUP_CONCAT for multi-valued properties, SAMPLE for single-valued ones.

Parameters:

Name Type Description Default
spec EntityShape

Property descriptor from SHACL.

required

Returns:

Type Description
str

SELECT projection expression(s) for this property.

Source code in src/backend/app/sparql_builder.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def build_select_expr(spec: EntityShape) -> str:
    """GROUP_CONCAT for multi-valued properties, SAMPLE for single-valued ones.

    Args:
        spec: Property descriptor from SHACL.

    Returns:
        SELECT projection expression(s) for this property.
    """
    sid = spec.id
    cat = spec.category
    is_multi = spec.is_multi

    if cat == "iri_with_label":
        if is_multi:
            return (
                f'(GROUP_CONCAT(DISTINCT CONCAT(STR(?{sid}Node), "{FIELD_SEP}", '
                f'COALESCE(?{sid}Lab, "")); separator="{ITEM_SEP}") AS ?{sid}Raw)'
            )
        return (
            f"(SAMPLE(?{sid}Node) AS ?{sid}Iri)\n"
            f"           (SAMPLE(?{sid}Lab) AS ?{sid}Label)"
        )
    if is_multi:
        return f'(GROUP_CONCAT(DISTINCT ?{sid}; separator="{ITEM_SEP}") AS ?{sid}Raw)'
    return f"(SAMPLE(?{sid}) AS ?{sid}Result)"

_pin_branch(where_clauses, indent=' ')

Build the UNION of the four entity classes that carry coordinates.

Parameters:

Name Type Description Default
where_clauses list[str]

Extra FILTER / pattern lines applied to each pin.

required
indent str

Leading whitespace for generated lines.

' '

Returns:

Type Description
str

SPARQL WHERE fragment for map pins.

Source code in src/backend/app/sparql_builder.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def _pin_branch(where_clauses: list[str], indent: str = "        ") -> str:
    """Build the UNION of the four entity classes that carry coordinates.

    Args:
        where_clauses: Extra FILTER / pattern lines applied to each pin.
        indent: Leading whitespace for generated lines.

    Returns:
        SPARQL WHERE fragment for map pins.
    """
    branches = f"\n{indent}UNION ".join(
        f"{{ ?s a compass:{name} . BIND(compass:{name} AS ?type) }}" for name in PIN_CLASSES
    )
    body = f"{indent}{branches}\n"
    if where_clauses:
        body += indent + f"\n{indent}".join(where_clauses) + "\n"
    return body

_region_branch(where_clauses, indent=' ')

Build the Country/Area branch reachable only through a matching pin.

A region is a shaded polygon rather than a result, and it carries no tags of its own: it reaches the map because some pin passing the same filters records it, so shading always means "matching pins are in here".

Parameters:

Name Type Description Default
where_clauses list[str]

Filter clauses applied to the nested ?pin.

required
indent str

Leading whitespace for generated lines.

' '

Returns:

Type Description
str

SPARQL WHERE fragment for shaded regions.

Source code in src/backend/app/sparql_builder.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def _region_branch(where_clauses: list[str], indent: str = "        ") -> str:
    """Build the Country/Area branch reachable only through a matching pin.

    A region is a shaded polygon rather than a result, and it carries no tags of
    its own: it reaches the map because some pin passing the same filters
    records it, so shading always means "matching pins are in here".

    Args:
        where_clauses: Filter clauses applied to the nested ``?pin``.
        indent: Leading whitespace for generated lines.

    Returns:
        SPARQL WHERE fragment for shaded regions.
    """
    inner = f"{indent}    ?pin compass:countryArea ?s .\n"
    if where_clauses:
        inner += indent + "    " + f"\n{indent}    ".join(where_clauses) + "\n"
    return (
        f"{indent}?s a compass:CountryArea .\n"
        f"{indent}BIND(compass:CountryArea AS ?type)\n"
        f"{indent}FILTER EXISTS {{\n{inner}{indent}}}\n"
    )

_shared_optionals(lang)

Geometry and label binding, applied to pins and regions alike.

Regions have no coordinates and label themselves with skos:prefLabel, so geometry is OPTIONAL and the label is COALESCEd across both properties.

Parameters:

Name Type Description Default
lang str

Preferred language tag.

required

Returns:

Type Description
str

SPARQL OPTIONAL / BIND / FILTER block.

Source code in src/backend/app/sparql_builder.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def _shared_optionals(lang: str) -> str:
    """Geometry and label binding, applied to pins and regions alike.

    Regions have no coordinates and label themselves with skos:prefLabel, so
    geometry is OPTIONAL and the label is COALESCEd across both properties.

    Args:
        lang: Preferred language tag.

    Returns:
        SPARQL OPTIONAL / BIND / FILTER block.
    """
    return f"""        OPTIONAL {{ ?s geo:lat ?lat . }}
        OPTIONAL {{ ?s geo:long ?long . }}
        OPTIONAL {{ ?s compass:name ?nameLabel . FILTER(lang(?nameLabel) = "{lang}") }}
        OPTIONAL {{ ?s skos:prefLabel ?prefLabel . FILTER(lang(?prefLabel) = "{lang}") }}
        BIND(COALESCE(?nameLabel, ?prefLabel) AS ?label)
        FILTER(BOUND(?label))
        OPTIONAL {{ ?type rdfs:label ?typeLabel . FILTER(lang(?typeLabel) = "{lang}") }}
"""

_special_optionals()

Return OPTIONAL patterns for properties not declared on entity NodeShapes.

Returns:

Type Description
str

SPARQL fragment fetching compass:wpEntityTagId.

Source code in src/backend/app/sparql_builder.py
203
204
205
206
207
208
209
210
211
def _special_optionals() -> str:
    """Return OPTIONAL patterns for properties not declared on entity NodeShapes.

    Returns:
        SPARQL fragment fetching ``compass:wpEntityTagId``.
    """
    return """
        OPTIONAL { ?s compass:wpEntityTagId ?wpEntityTagId . }
"""

_special_selects()

Return SELECT projections for special (non-SHACL) properties.

Returns:

Type Description
str

SPARQL SELECT fragment for wpEntityTagId.

Source code in src/backend/app/sparql_builder.py
214
215
216
217
218
219
220
def _special_selects() -> str:
    """Return SELECT projections for special (non-SHACL) properties.

    Returns:
        SPARQL SELECT fragment for ``wpEntityTagId``.
    """
    return "           (SAMPLE(?wpEntityTagId) AS ?wpEntityTagId)\n"

_union_or_single(parts)

Join alternative graph patterns with UNION, or return the sole pattern.

Parameters:

Name Type Description Default
parts list[str]

Individual pattern strings.

required

Returns:

Type Description
str

A single pattern or a braced UNION of several.

Source code in src/backend/app/sparql_builder.py
223
224
225
226
227
228
229
230
231
232
233
234
def _union_or_single(parts: list[str]) -> str:
    """Join alternative graph patterns with UNION, or return the sole pattern.

    Args:
        parts: Individual pattern strings.

    Returns:
        A single pattern or a braced UNION of several.
    """
    if len(parts) > 1:
        return "{ " + " } UNION { ".join(parts) + " }"
    return parts[0]

_build_where_clauses(query_params, filter_map, range_filters, date_filters, subject=PIN, exclude_key=None)

Translate HTTP query params into SPARQL WHERE fragments.

exclude_key drops that dimension's own constraints, so facet counts for a dimension are not shrunk by the selection within it (drill-down faceting).

Parameters:

Name Type Description Default
query_params Any

Starlette/FastAPI query parameter multi-dict.

required
filter_map dict[str, str]

Multiselect/toggle property id → prefixed predicate.

required
range_filters RangeFilters

Slider property id → (predicate, datatype).

required
date_filters dict[str, str]

Datepicker property id → prefixed predicate.

required
subject Subject

Variable naming for pin vs region-pin copies.

PIN
exclude_key str | None

Dimension id to ignore (faceting).

None

Returns:

Type Description
list[str]

List of SPARQL pattern / FILTER lines.

Source code in src/backend/app/sparql_builder.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def _build_where_clauses(
    query_params: Any,
    filter_map: dict[str, str],
    range_filters: RangeFilters,
    date_filters: dict[str, str],
    subject: Subject = PIN,
    exclude_key: str | None = None,
) -> list[str]:
    """Translate HTTP query params into SPARQL WHERE fragments.

    ``exclude_key`` drops that dimension's own constraints, so facet counts for a
    dimension are not shrunk by the selection within it (drill-down faceting).

    Args:
        query_params: Starlette/FastAPI query parameter multi-dict.
        filter_map: Multiselect/toggle property id → prefixed predicate.
        range_filters: Slider property id → (predicate, datatype).
        date_filters: Datepicker property id → prefixed predicate.
        subject: Variable naming for pin vs region-pin copies.
        exclude_key: Dimension id to ignore (faceting).

    Returns:
        List of SPARQL pattern / FILTER lines.
    """
    where_clauses = []
    subj = subject.var

    for key, val in query_params.items():
        if key in ("lang", exclude_key) or not val:
            continue
        values = query_params.getlist(key)
        var = f"?{key}{subject.suffix}Val"

        if key in filter_map:
            prop = filter_map[key]
            parts = []
            for v in values:
                if _is_iri_value(v):
                    parts.append(f"{subj} {prop} {iri_term(v)} .")
                else:
                    parts.append(
                        f"{subj} {prop} {var} . FILTER(str({var}) = {string_literal(v)})"
                    )
            if parts:
                where_clauses.append(_union_or_single(parts))

        elif key in date_filters:
            try:
                date.fromisoformat(val)
            except ValueError:
                continue  # not a date, so it constrains nothing
            prop = date_filters[key]
            where_clauses.append(
                f"OPTIONAL {{ {subj} {prop} {var} . }} "
                f"FILTER(!BOUND({var}) || {var} >= {string_literal(val)}^^xsd:date)"
            )

        elif key == "entityType":
            iri_list = ", ".join(iri_term(v) for v in values if _is_iri_value(v))
            if iri_list:
                # A region has no type of its own to filter, so the legend
                # reaches it through the pins: hide every Project and a region
                # holding only projects stops being shaded.
                clause = f"FILTER({subject.type_var} IN ({iri_list}))"
                if subject.declare_type:
                    clause = f"{subj} a {subject.type_var} . {clause}"
                where_clauses.append(clause)

        elif key in range_filters:
            prop, datatype = range_filters[key]
            try:
                numeric_val = float(val)
                if datatype and "gYear" in datatype:
                    year_int = int(numeric_val)
                    where_clauses.append(
                        f"OPTIONAL {{ {subj} {prop} {var} . }} "
                        f'FILTER(!BOUND({var}) || {var} >= "{year_int}"^^xsd:gYear)'
                    )
                else:
                    where_clauses.append(
                        f"OPTIONAL {{ {subj} {prop} {var} . }} "
                        f"FILTER(!BOUND({var}) || {var} >= {numeric_val})"
                    )
            except ValueError:
                continue

    return where_clauses

_categorize_specs(specs)

Split EntityShape list into multiselect, range, and date filter maps.

Parameters:

Name Type Description Default
specs list[EntityShape]

SHACL-projected property descriptors.

required

Returns:

Type Description
tuple[dict[str, str], RangeFilters, dict[str, str]]

Tuple of (filter_map, range_filters, date_filters).

Source code in src/backend/app/sparql_builder.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def _categorize_specs(
    specs: list[EntityShape],
) -> tuple[dict[str, str], RangeFilters, dict[str, str]]:
    """Split EntityShape list into multiselect, range, and date filter maps.

    Args:
        specs: SHACL-projected property descriptors.

    Returns:
        Tuple of ``(filter_map, range_filters, date_filters)``.
    """
    filter_map: dict[str, str] = {}
    range_filters: RangeFilters = {}
    date_filters: dict[str, str] = {}
    for spec in specs:
        prefixed = to_prefixed(spec.path_iri)
        if spec.filter_type in ("multiselect", "toggle"):
            filter_map[spec.id] = prefixed
        elif spec.filter_type == "slider":
            range_filters[spec.id] = (prefixed, spec.datatype)
        elif spec.filter_type == "datepicker":
            date_filters[spec.id] = prefixed
    return filter_map, range_filters, date_filters

build_facet_query(specs, lang, query_params, target_id)

Count entities per value of one tag dimension.

Regions are background context rather than results (see the map's result badge, which counts point features only), so only the pin branch is counted.

Parameters:

Name Type Description Default
specs list[EntityShape]

EntityShape list for the ontology.

required
lang str

Preferred language for shared optionals.

required
query_params Any

Active filter query parameters.

required
target_id str

Dimension whose values are counted.

required

Returns:

Type Description
str

Complete SPARQL SELECT counting ?val.

Source code in src/backend/app/sparql_builder.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
def build_facet_query(
    specs: list[EntityShape], lang: str, query_params: Any, target_id: str
) -> str:
    """Count entities per value of one tag dimension.

    Regions are background context rather than results (see the map's result
    badge, which counts point features only), so only the pin branch is counted.

    Args:
        specs: EntityShape list for the ontology.
        lang: Preferred language for shared optionals.
        query_params: Active filter query parameters.
        target_id: Dimension whose values are counted.

    Returns:
        Complete SPARQL SELECT counting ``?val``.
    """
    filter_map, range_filters, date_filters = _categorize_specs(specs)
    target_path = filter_map[target_id]

    where_clauses = _build_where_clauses(
        query_params, filter_map, range_filters, date_filters, exclude_key=target_id
    )

    sparql_where = _pin_branch(where_clauses)
    sparql_where += f"        ?s {target_path} ?val .\n"
    sparql_where += _shared_optionals(lang)

    return (
        SPARQL_PREFIXES
        + "    SELECT ?val (COUNT(DISTINCT ?s) AS ?n)\n"
        + "    WHERE {\n"
        + sparql_where
        + "    }\n"
        + "    GROUP BY ?val\n"
    )

sparql_for_instances(specs, lang, query_params)

Compile the main entity SELECT (pins UNION regions) for the map.

Parameters:

Name Type Description Default
specs list[EntityShape]

EntityShape list for the ontology.

required
lang str

Preferred language.

required
query_params Any

Active filter query parameters.

required

Returns:

Type Description
str

Complete SPARQL SELECT returning one grouped row per entity.

Source code in src/backend/app/sparql_builder.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def sparql_for_instances(specs: list[EntityShape], lang: str, query_params: Any) -> str:
    """Compile the main entity SELECT (pins UNION regions) for the map.

    Args:
        specs: EntityShape list for the ontology.
        lang: Preferred language.
        query_params: Active filter query parameters.

    Returns:
        Complete SPARQL SELECT returning one grouped row per entity.
    """
    filter_map, range_filters, date_filters = _categorize_specs(specs)

    auto_optionals = "\n        ".join(build_optional(spec, lang) for spec in specs)
    auto_selects = "\n           ".join(build_select_expr(spec) for spec in specs)
    pin_clauses = _build_where_clauses(
        query_params, filter_map, range_filters, date_filters
    )
    region_clauses = _build_where_clauses(
        query_params, filter_map, range_filters, date_filters, subject=REGION_PIN
    )

    sparql_where = (
        "        {\n"
        + _pin_branch(pin_clauses, "            ")
        + "        } UNION {\n"
        + _region_branch(region_clauses, "            ")
        + "        }\n"
    )
    sparql_where += _shared_optionals(lang)
    sparql_where += "        " + auto_optionals + "\n" + _special_optionals()

    return (
        SPARQL_PREFIXES
        + "    SELECT ?s ?label ?lat ?long ?type\n"
        + "           (SAMPLE(?typeLabel) AS ?typeLabelResult)\n"
        + "           "
        + auto_selects
        + "\n"
        + _special_selects()
        + "    WHERE {\n"
        + sparql_where
        + "    }\n"
        + "    GROUP BY ?s ?label ?lat ?long ?type\n"
    )