diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst index da5e4df794..50c2fd07b2 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -47,6 +47,19 @@ declarations and Any JSON that conforms to the derived JSON Schema (see above) can be converted to RDF using this context. +JSON-LD version +^^^^^^^^^^^^^^^ + +A generated context that uses a JSON-LD 1.1 construct starts with +``"@version": 1.1``. The generator emits three: scoped contexts for enum slots +and for class-level range overrides, and ``@prefix`` (see below). A JSON-LD 1.0 +processor would silently ignore them; the number ``1.1`` makes it stop instead +(`JSON-LD 1.1 §1.7 `__), +and selects JSON-LD 1.1 processing in processors that support both. A context +without such a construct, for example one generated with ``--no-model`` or for a +schema without enums and overrides, has no version line and stays loadable by a +JSON-LD 1.0 processor. + CURIE usage ^^^^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 5462b29f8b..f07efcd36f 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -133,6 +133,8 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]: return external_classes, external_slots def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_): + # Whether a class or slot definition carries a scoped context, a JSON-LD 1.1 construct + self._uses_scoped_contexts = False # Add any explicitly declared prefixes for prefix in self.schema.prefixes.values(): self.emit_prefixes.add(prefix.prefix_prefix) @@ -185,6 +187,7 @@ def end_schema( comments.source = self.schema.source_file context.comments = comments context_content = {"xsd": "http://www.w3.org/2001/XMLSchema#"} + uses_prefix_flag = False if base: base = str(base) if "://" not in base: @@ -201,12 +204,20 @@ def end_schema( prefix_obj = JsonObj() prefix_obj["@id"] = url prefix_obj["@prefix"] = True + uses_prefix_flag = True context_content[prefix] = prefix_obj if model: for k, v in self.context_body.items(): context_content[k] = v for k, v in self.slot_class_maps.items(): context_content[k] = v + if uses_prefix_flag or (model and self._uses_scoped_contexts): + # The context uses a JSON-LD 1.1 construct (``@prefix``, a scoped context), which a + # JSON-LD 1.0 processor silently ignores. ``"@version": 1.1`` makes such a processor + # stop instead (JSON-LD 1.1 §1.7), and a JSON-LD 1.1 processor then requires 1.1 + # processing (JSON-LD 1.1 API §4.1.2, step 5.5). A context without such a + # construct stays loadable by a 1.0 processor. + context_content = {"@version": 1.1, **context_content} context["@context"] = context_content if output and not self.embed_context_in_frame: with open(output, "w", encoding="UTF-8") as outf: @@ -275,6 +286,7 @@ def visit_class(self, cls: ClassDefinition) -> bool: scoped_context = self._build_scoped_context(cls) if scoped_context: class_def["@context"] = scoped_context + self._uses_scoped_contexts = True if class_def: self.slot_class_maps[cn] = class_def @@ -406,6 +418,7 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: slot_def["@context"] = {**ENUM_CONTEXT, "@vocab": vocab_ns} else: slot_def["@context"] = ENUM_CONTEXT + self._uses_scoped_contexts = True # Add the necessary prefixes to the namespace skos = self.namespaces.prefix_for(SKOS) if not skos: diff --git a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld index b209ca8a52..265aba0dc9 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/mappings.json b/tests/linkml/test_base/__snapshots__/mappings.json index babcbe09bd..ac60d1380e 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.json +++ b/tests/linkml/test_base/__snapshots__/mappings.json @@ -474,6 +474,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/meta.context.jsonld b/tests/linkml/test_base/__snapshots__/meta.context.jsonld index 9a33f0f10b..9d3d1a2e27 100644 --- a/tests/linkml/test_base/__snapshots__/meta.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/meta.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json index acda6769d8..ad22af3cc4 100644 --- a/tests/linkml/test_base/__snapshots__/meta.json +++ b/tests/linkml/test_base/__snapshots__/meta.json @@ -8910,6 +8910,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld index 509e297b35..4e5dd2223a 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "AGRKB": "https://www.alliancegenome.org/", "APO": { diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json index 3080ce2608..77885b3f37 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json @@ -35995,6 +35995,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "AGRKB": "https://www.alliancegenome.org/", "APO": { diff --git a/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld b/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld index 299abdf340..836133a929 100644 --- a/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld +++ b/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 72089eba44..9e9a7e4e1e 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -4,7 +4,7 @@ import pytest from click.testing import CliRunner -from rdflib import Graph, Literal, URIRef +from rdflib import XSD, Graph, Literal, URIRef from linkml.generators import ContextGenerator, JSONLDGenerator from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator @@ -1790,3 +1790,129 @@ def test_identifier_context_round_trips_to_rdf(tmp_path, use_curies): ex = "https://example.org/identifier-test/" assert set(graph) == {(URIRef(ex + "thing1"), URIRef(ex + "name"), Literal("n"))} + + +VERSION_SCHEMA = """ +id: https://example.org/version +name: version +prefixes: + ex: https://example.org/version/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +default_range: string +imports: [linkml:types] +slots: + value: + slot_uri: ex:value +classes: + Quantity: + class_uri: ex:Quantity + slots: [value] + slot_usage: + value: + range: float + Token: + class_uri: ex:Token + slots: [value] + Thing: + class_uri: ex:Thing + tree_root: true + attributes: + reading: {range: Quantity, inlined: true} + label: {range: Token, inlined: true} +""" + + +PLAIN_SCHEMA = """ +id: https://example.org/plain +name: plain +prefixes: + ex: https://example.org/plain/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + Thing: + attributes: + label: {} +""" + +# A namespace that ends in neither "/" nor "#" is declared with "@prefix": true. +PREFIX_SCHEMA = PLAIN_SCHEMA.replace("https://example.org/plain/", "https://example.org/plain") + + +def test_context_with_a_json_ld_1_1_construct_declares_it(tmp_path): + """A context with a scoped context declares ``"@version": 1.1`` first. + + JSON-LD 1.1 §1.7: the value is the number 1.1, which makes a JSON-LD 1.0 + processor stop instead of ignoring the 1.1 constructs. + """ + schema = tmp_path / "version.yaml" + schema.write_text(VERSION_SCHEMA, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize())["@context"] + assert next(iter(context)) == "@version" + assert context["@version"] == 1.1 and isinstance(context["@version"], float) + + +@pytest.mark.parametrize( + "schema_text,options,declared", + [ + pytest.param(VERSION_SCHEMA, {}, True, id="scoped-context"), + pytest.param(VERSION_SCHEMA, {"model": False}, False, id="scoped-context-left-out"), + pytest.param(PLAIN_SCHEMA, {}, False, id="plain"), + pytest.param(PREFIX_SCHEMA, {}, True, id="prefix"), + pytest.param(PREFIX_SCHEMA, {"prefixes": False}, False, id="prefix-left-out"), + pytest.param( + PLAIN_SCHEMA + " Tagged:\n attributes:\n color: {range: Color}\nenums:\n Color:\n" + " permissible_values: {red: {}}\n", + {}, + True, + id="enum", + ), + ], +) +def test_version_is_declared_exactly_when_a_json_ld_1_1_construct_is_used(tmp_path, schema_text, options, declared): + """``@prefix`` and scoped contexts are JSON-LD 1.1 constructs; a context without them stays + loadable by a JSON-LD 1.0 processor, so it doesn't declare ``"@version": 1.1``.""" + schema = tmp_path / "schema.yaml" + schema.write_text(schema_text, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize(**options))["@context"] + assert ("@version" in context) is declared + if declared: + assert next(iter(context)) == "@version" + + +@pytest.mark.parametrize( + "schema_text,declared", + [pytest.param(VERSION_SCHEMA, False, id="scoped-context"), pytest.param(PREFIX_SCHEMA, True, id="prefix")], +) +def test_embedded_context_follows_the_same_rule(tmp_path, schema_text, declared): + """gen-jsonld embeds a prefix-only generated context, which declares the version only if it needs it.""" + schema = tmp_path / "embedded.yaml" + schema.write_text(schema_text, encoding="utf-8") + embedded = json.loads(JSONLDGenerator(str(schema)).serialize())["@context"] + generated = next(c for c in embedded if isinstance(c, dict)) + assert ("@version" in generated) is declared + + +@pytest.mark.parametrize("version", [None, 1.0], ids=["processor-default", "json-ld-1.0-requested"]) +def test_type_scoped_coercion_survives_json_ld_1_0_processing(tmp_path, version): + """A class-level range override reaches the data even when the caller asks rdflib for JSON-LD 1.0. + + The override is a type-scoped context, a JSON-LD 1.1 construct. rdflib in + JSON-LD 1.0 mode ignores it, so ``1.5`` would become ``xsd:double``. Asked + for 1.0, rdflib switches to 1.1 when the context declares ``@version``; a + conformant JSON-LD 1.1 processor stops with a processing mode conflict + instead (JSON-LD 1.1 API §4.1.2). Either way the coercion is not silently lost. + """ + schema = tmp_path / "version.yaml" + schema.write_text(VERSION_SCHEMA, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize()) + doc = {**context, "@type": "Thing", "reading": {"@type": "Quantity", "value": 1.5}, "label": {"value": "x"}} + + options = {} if version is None else {"version": version} + graph = Graph().parse(data=json.dumps(doc), format="json-ld", **options) + + values = {o.datatype for o in graph.objects(None, URIRef("https://example.org/version/value"))} + assert values == {XSD.float, None} diff --git a/tests/linkml/test_issues/__snapshots__/issue_344_context.json b/tests/linkml/test_issues/__snapshots__/issue_344_context.json index 67f62a0b81..e5d0f534be 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_344_context.json +++ b/tests/linkml/test_issues/__snapshots__/issue_344_context.json @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "GO": { "@id": "http://purl.obolibrary.org/obo/GO_", diff --git a/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld b/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld index 64d29f16df..ab40384ceb 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld +++ b/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bms": "https://microbiomedata/schema/", "linkml": "https://w3id.org/linkml/", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld index ce935718c5..c08c2b5c55 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld @@ -5,6 +5,7 @@ "source": "kitchen_sink.yaml" }, "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld index ba1f5c389a..d893742c6b 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld @@ -5,6 +5,7 @@ "source": "kitchen_sink.yaml" }, "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld index 1ce86f3a57..78f55ddb1f 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld index e982cc2fda..cfb713c120 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld index 1ce86f3a57..78f55ddb1f 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld index e982cc2fda..cfb713c120 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld index d7274380da..a3e2415982 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld index 97c502567c..12eafd5d03 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld @@ -492,6 +492,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json index 2c39326ff5..78bcb5eb4f 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json @@ -181,6 +181,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "m": { "@id": "http://example.org/test/uriandcurie", diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld index 47f2eb296b..7a5a6876af 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "m": { "@id": "http://example.org/test/uriandcurie",