Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,48 @@ shacl2code generate \
--output schema.json
```

#### JSON Schema draft version

The default schema targets [Draft 2020-12][json-schema-2020-12] rather than
[Draft 2019-09][json-schema-2019-09] because the annotation-collection semantics
of `unevaluatedProperties` across `if/then/else` and `$ref` were underspecified
in 2019-09 and clarified in 2020-12.

With `--use-additional-properties`, `unevaluatedProperties` is not used,
so the schema targets [Draft 2019-09][json-schema-2019-09], the minimum for
the remaining keywords.

#### `--use-additional-properties`

The `--use-additional-properties` flag switches the property-restriction
keyword from `unevaluatedProperties` to `additionalProperties` by inlining
all inherited properties directly into each class definition instead of
composing them via `$ref` chains.

**When to use it:**

- **Performance:** some JSON Schema validators (e.g. [json-schema-validator]
2.0.x) have [performance regressions][perf-issue] with `unevaluatedProperties`
because it requires tracking annotation state across the full evaluation tree.
`additionalProperties` is a simpler local lookup and avoids that cost.
- **Draft 2020-12 not supported:** use this flag to get a schema that targets
[Draft 2019-09][json-schema-2019-09].

**Trade-off:**

- Larger schema: every class inlines its full inherited property list
(SPDX 3.0: 253 KB --> 336 KB, +33%; SPDX 3.1-dev: 452 KB --> 712 KB, +58%).
- More permissive than default mode: `@context` is accepted on embedded
(non-root) objects, not just the root, because it's added to every class's
inlined property list so root documents can pass `additionalProperties: false`.
Default mode rejects `@context` on embedded objects.
(see [`test_context_on_embedded_object_*`](tests/test_jsonschema.py)).

[json-schema-2019-09]: https://json-schema.org/draft/2019-09
[json-schema-2020-12]: https://json-schema.org/draft/2020-12
[json-schema-validator]: https://github.com/networknt/json-schema-validator
[perf-issue]: https://github.com/networknt/json-schema-validator/issues/1216

## Developing

Developing on `shacl2code` is best done using a virtual environment. You can
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ addopts = [
"auto",
"--dist=loadfile",
]
markers = [
"network: marks tests that require network access (deselect with '-m \"not network\"')",
]
# Suppress 3rd-party warnings to reduce log noise. Remove after libraries get updated.
filterwarnings = [
"ignore:.*ConjunctiveGraph is deprecated.*:DeprecationWarning", # From rdflib
Expand Down
4 changes: 4 additions & 0 deletions src/shacl2code/lang/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def get_additional_render_args(self, model):
def get_extra_env(self):
return {}

def get_extra_model_env(self, classes):
return {}

def render(self, template, output, *, extra_env=None, render_args=None):
if extra_env is None:
extra_env = {} # pragma: no cover
Expand Down Expand Up @@ -139,6 +142,7 @@ def get_all_named_individuals(cls):
"get_all_named_individuals": get_all_named_individuals,
"include_file": include_file,
**self.get_extra_env(),
**self.get_extra_model_env(classes),
}

for output, template, args in self.get_outputs():
Expand Down
37 changes: 34 additions & 3 deletions src/shacl2code/lang/jsonschema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#
# Copyright (c) 2024 Joshua Watt
#
# SPDX-FileContributor: Joshua Watt
# SPDX-FileContributor: Arthit Suriyawongkul
# SPDX-FileCopyrightText: 2024-present Joshua Watt
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: MIT
"""JSON Schema renderer"""

Expand Down Expand Up @@ -29,6 +30,7 @@ def __init__(self, args):
"schema_title": args.title,
"schema_id": args.id,
"allow_elided_lists": args.allow_elided_lists,
"use_additional_properties": args.use_additional_properties,
}

@classmethod
Expand All @@ -42,11 +44,40 @@ def get_arguments(cls, parser):
action="store_true",
help="Allow lists to be elided if they only contain a single element",
)
parser.add_argument(
"--use-additional-properties",
action="store_true",
help=(
"Use additionalProperties instead of unevaluatedProperties. "
"Flattens inherited property refs into each class definition for "
"better compatibility with validators that have poor "
"unevaluatedProperties performance."
),
)

def get_extra_env(self):
return {
"varname": varname,
}

def get_extra_model_env(self, classes):
def get_all_properties(cls):
"""Return [(defining_class, prop), ...] for cls and all ancestors, parent-first, no duplicates."""
seen = set()
result = []

def collect(c):
for parent_id in c.parent_ids:
collect(classes.get(parent_id))
for prop in c.properties:
if prop.path not in seen:
seen.add(prop.path)
result.append((c, prop))

collect(cls)
return result

return {"get_all_properties": get_all_properties}

def get_additional_render_args(self, model):
return self.__render_args
94 changes: 84 additions & 10 deletions src/shacl2code/lang/templates/jsonschema.j2
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "{% if use_additional_properties %}https://json-schema.org/draft/2019-09/schema{% else %}https://json-schema.org/draft/2020-12/schema{% endif %}",
{%- if schema_id %}
"$id": "{{ schema_id }}",
{%- endif %}
Expand Down Expand Up @@ -42,35 +42,44 @@
"then": {
"type": "object",
"properties": {
{%- if use_additional_properties %}
"@context": {},
{%- endif %}
"@graph": {
"description": "Top level container for JSON-LD objects",
"type": "array",
"items": {
"type": "object",
"$ref": "#/$defs/AnyClass",
"$ref": "#/$defs/AnyClass"
{%- if not use_additional_properties %},
"unevaluatedProperties": false
{%- endif %}
}
}
},
"required": ["@graph"]
{%- if use_additional_properties %},
"additionalProperties": false
{%- endif %}
},
"else": {
"$ref": "#/$defs/AnyClass"
},
{%- if not use_additional_properties %}
"unevaluatedProperties": false,
{%- endif %}

"$defs": {
{%- for class in classes %}
{#- Classes are divided into 2 parts. The properties are separated into a separate object #}
{#- so that a object can references the properties of its parent without needing the const #}
{#- @type tag #}
{#- Default: _props splits inherited properties for parent reuse without repeating @type. #}
{#- --use-additional-properties: all inherited properties inlined per class. #}
{%- if not class.is_abstract or class.is_extensible %}
"{{ varname(*class.clsname) }}": {
"if": {
"type": "object",
"properties": {
"{{ context.compact_iri("@type") }}": {
{#- Abstract Extensible classes are weird; any type _except_ the specific class type is allowed #}
{#- Abstract extensible: any IRI @type except the class's own is valid #}
{%- if class.is_abstract and class.is_extensible %}
"allOf": [
{ "$ref": "#/$defs/IRI" },
Expand All @@ -88,6 +97,68 @@
},
"required": ["{{ context.compact_iri("@type") }}"]
},
{%- if use_additional_properties %}
{%- set all_props = get_all_properties(class) %}
{%- set required = [] %}
{%- if class.node_kind == SH.IRI %}
{{- required.append(class.id_property or "@id") or "" }}
{%- endif %}
{%- for _, prop in all_props %}
{%- if not prop.min_count is none and prop.min_count > 0 %}
{{- required.append(context.compact_vocab(prop.path)) or "" }}
{%- endif %}
{%- endfor %}
"then": {
"type": "object",
{%- if class.deprecated %}
"deprecated": true,
{%- endif %}
"properties": {
"{{ class.id_property or "@id" }}": { "$ref": "#/$defs/{{ class.node_kind.split("#")[-1] }}" },
"{{ context.compact_iri("@type") }}": { "type": "string" },
"@context": {}{% if all_props %},{% endif %}
{%- for def_class, prop in all_props %}
{%- set is_list = prop.max_count is none or prop.max_count != 1 %}
{%- set prop_ref = "#/$defs/prop_" + varname(*def_class.clsname) + "_" + varname(prop.varname) %}
"{{ context.compact_vocab(prop.path) }}": {
{%- if is_list %}
"anyOf": [
{
"type": "array",
{%- if prop.max_count is not none %}
"maxItems": {{ prop.max_count }},
{%- endif %}
{%- if prop.min_count is not none %}
"minItems": {{ prop.min_count }},
{%- endif %}
"items": {
"$ref": "{{ prop_ref }}"
}
}{% if allow_elided_lists %},
{
"$ref": "{{ prop_ref }}"
}
{%- endif %}
]
{%- else %}
"$ref": "{{ prop_ref }}"
{%- endif %}
}{% if not loop.last %},{% endif %}
{%- endfor %}
}{% if required %},
"required": [
{%- for r in required %}
"{{ r }}"{% if not loop.last %},{% endif %}
{%- endfor %}
]
{%- endif %},
{%- if class.is_extensible %}
"additionalProperties": true
{%- else %}
"additionalProperties": false
{%- endif %}
},
{%- else %}
"then": {
"allOf": [
{
Expand All @@ -108,6 +179,7 @@
{ "$ref": "#/$defs/{{ varname(*class.clsname) }}_props" }
]
},
{%- endif %}
"else": {
"const": "Not a {{ varname(*class.clsname) }}"
}
Expand All @@ -116,10 +188,8 @@
"{{ varname(*class.clsname) }}_derived": {
{%- set ns = namespace(json_refs=[], named_individuals=[]) %}
{%- for d in get_all_derived(class) + [class._id] %}
{%- if not classes.get(d).is_abstract %}
{%- if not classes.get(d).is_abstract or classes.get(d).is_extensible %}
{%- set ns.json_refs = ns.json_refs + ["#/$defs/" + varname(*classes.get(d).clsname)] %}
{%- elif classes.get(d).is_extensible %}
{%- set ns.json_refs = ns.json_refs + ["#/$defs/" + varname(*classes.get(d).clsname) + "_props"] %}
{%- endif %}
{%- for n in classes.get(d).named_individuals %}
{%- if context.compact_iri(n._id) != n._id %}
Expand All @@ -131,7 +201,7 @@
{%- if ns.json_refs %}
{
"type": "object",
{%- if not class.is_extensible %}
{%- if not class.is_extensible and not use_additional_properties %}
"unevaluatedProperties": false,
{%- endif %}
"anyOf": [
Expand All @@ -147,6 +217,7 @@
{ "$ref": "#/$defs/BlankNodeOrIRI" }
]
},
{%- if not use_additional_properties %}
"{{ varname(*class.clsname) }}_props": {
"allOf": [
{%- if class.parent_ids %}
Expand Down Expand Up @@ -201,6 +272,7 @@
}
]
},
{%- endif %}
{%- for prop in class.properties %}
"prop_{{ varname(*class.clsname) }}_{{ varname(prop.varname) }}": {
{%- if prop.deprecated %}
Expand Down Expand Up @@ -291,6 +363,7 @@
"anyURI": {
"type": "string"
},
{%- if not use_additional_properties %}
"SHACLClass": {
"type": "object",
"properties": {
Expand All @@ -309,6 +382,7 @@
},
"required": ["{{ context.compact_iri("@type") }}"]
},
{%- endif %}
"AnyClass": {
"anyOf": [
{%- for class in concrete_classes %}
Expand Down
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,36 @@
import json
import os
import shutil
import socket
import subprocess
import time
from pathlib import Path
from typing import List

import pytest

from testfixtures.httpserver import HTTPTestServer


def _network_available() -> bool:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect(("8.8.8.8", 53))
return True
except OSError:
return False


def pytest_collection_modifyitems(items: List[pytest.Item]) -> None:
if _network_available():
return
skip = pytest.mark.skip(reason="no network connection")
for item in items:
if item.get_closest_marker("network"):
item.add_marker(skip)


THIS_FILE = Path(__file__)
THIS_DIR = THIS_FILE.parent

Expand Down Expand Up @@ -86,6 +108,29 @@ def test_timezone():
time.tzset()


@pytest.fixture(scope="session")
def test_jsonschema_additional_props(model_server, test_context_url):
p = subprocess.run(
[
"shacl2code",
"generate",
"--input",
MODEL_DIR / "test.ttl",
"--context",
test_context_url,
"jsonschema",
"--use-additional-properties",
"--output",
"-",
],
check=True,
stdout=subprocess.PIPE,
encoding="utf-8",
)

yield json.loads(p.stdout)


@pytest.fixture(scope="session")
def roundtrip(tmp_path_factory, model_server):
outfile = tmp_path_factory.mktemp("roundtrip") / "roundtrip.json"
Expand Down
Loading
Loading