Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e8a9fae
Add additionalProperties option
bact Jun 29, 2026
c53f96c
Add size benchmark number
bact Jun 29, 2026
6b4187b
Add @context permissive test
bact Jun 29, 2026
d29925a
Fix Python 3.8 compat issue
bact Jun 29, 2026
2238c98
Add tests for get_all_properties
bact Jun 29, 2026
ad8a2b8
Merge branch 'JPEWdev:main' into additionalProperties
bact Jul 1, 2026
8b02b93
Merge branch 'main' into additionalProperties
bact Jul 3, 2026
66f614e
Revert code dedup
bact Jul 3, 2026
7c25c32
Remove unnecessary formatting changes
bact Jul 3, 2026
46d966d
Remove unnecessary formatting changes
bact Jul 3, 2026
82399b4
Fix formatting
bact Jul 3, 2026
f9073d4
README: Revise trade-off
bact Jul 3, 2026
618c452
Merge branch 'JPEWdev:main' into additionalProperties
bact Jul 6, 2026
2004c74
Update tests with compact name
bact Jul 6, 2026
ce1a4a5
Fix formatting
bact Jul 6, 2026
80d56ae
Merge branch 'JPEWdev:main' into additionalProperties
bact Jul 21, 2026
cf2e5d7
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 4, 2026
e969ffb
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 10, 2026
e351d28
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 11, 2026
fe937ac
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 13, 2026
d25d087
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 13, 2026
a814032
Merge branch 'JPEWdev:main' into additionalProperties
bact Aug 20, 2026
5f75032
Merge remote-tracking branch 'upstream/main' into additionalProperties
bact Sep 14, 2026
cd3a5b2
Fix jss-signature support in --use-additional-properties mode
bact Sep 14, 2026
df85151
Merge branch 'JPEWdev:main' into additionalProperties
bact Sep 15, 2026
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 @@ -91,6 +91,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 @@ -58,6 +58,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 @@ -155,6 +158,7 @@ def get_all_named_individuals(cls):
"include_file": include_file,
"prop_is_list": prop_is_list,
**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
97 changes: 87 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,20 +42,28 @@
"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 %}
}
}{%- if jss_signature %},
"{{ jss_signature }}": {
"$ref": "#/$defs/JSSsignatures"
}{%- endif %}
},
"required": ["@graph"]
{%- if use_additional_properties %},
"additionalProperties": false
{%- endif %}
},
"else": {
"allOf": [
Expand All @@ -69,7 +77,9 @@
}{%- endif %}
]
},
{%- if not use_additional_properties %}
"unevaluatedProperties": false,
{%- endif %}

"$defs": {
"JSSsignatures": {
Expand Down Expand Up @@ -132,16 +142,15 @@
"pattern": "^(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)(\\.\\d+)?Z$"
},
{%- 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 @@ -159,6 +168,71 @@
},
"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 jss_signature or all_props %},{% endif %}
{%- if jss_signature %}
"{{ jss_signature }}": { "$ref": "#/$defs/JSSsignatures" }{% if all_props %},{% endif %}
{%- 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 @@ -179,6 +253,7 @@
{ "$ref": "#/$defs/{{ varname(*class.clsname) }}_props" }
]
},
{%- endif %}
"else": {
"const": "Not a {{ varname(*class.clsname) }}"
}
Expand All @@ -187,10 +262,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 @@ -202,7 +275,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 @@ -218,6 +291,7 @@
{ "$ref": "#/$defs/BlankNodeOrIRI" }
]
},
{%- if not use_additional_properties %}
"{{ varname(*class.clsname) }}_props": {
"allOf": [
{%- if class.parent_ids %}
Expand Down Expand Up @@ -272,6 +346,7 @@
}
]
},
{%- endif %}
{%- for prop in class.properties %}
"prop_{{ varname(*class.clsname) }}_{{ varname(prop.varname) }}": {
{%- if prop.deprecated %}
Expand Down Expand Up @@ -362,6 +437,7 @@
"anyURI": {
"type": "string"
},
{%- if not use_additional_properties %}
"SHACLClass": {
"type": "object",
"properties": {
Expand All @@ -380,6 +456,7 @@
},
"required": ["{{ context.compact_iri("@type") }}"]
},
{%- endif %}
"AnyClass": {
"anyOf": [
{%- for class in concrete_classes %}
Expand Down
Loading
Loading