diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5194f8..2a0e3010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Options from the test manifest now override the options configured in `create_test_options()`, instead of the other way around. This fixes tests not able to override default options in the test-setup such as `extractAllScripts`. Fixes [html#tf004](https://w3c.github.io/json-ld-api/tests/html-manifest#tf004). - When `@type` is `@json` in a frame, it no longer raises an "Invalid JSON-LD syntax" error. Fixes [frame#t0069](https://w3c.github.io/json-ld-framing/tests/frame-manifest.html#t0069). - Use safeguard for non-dict values of `options['link']` +- Local and type-scoped contexts are now properly resolved for nested node objects, so a scoped context on a `@nest` term is being applied to nested properties. Fixes [toRdf#tc037](https://w3c.github.io/json-ld-api/tests/toRdf-manifest.html#tc037) and [toRdf#tc038](https://w3c.github.io/json-ld-api/tests/toRdf-manifest.html#tc038). ### Changed - `requests_document_loader()` and `aiohttp_document_loader()` now return diff --git a/lib/pyld/jsonld.py b/lib/pyld/jsonld.py index 94899b40..0ef16bf6 100644 --- a/lib/pyld/jsonld.py +++ b/lib/pyld/jsonld.py @@ -2139,35 +2139,10 @@ def _expand( active_ctx, property_scoped_ctx, options, override_protected=True ) - # recursively expand object - # if element has a context, process it - if '@context' in element: - active_ctx = self._process_context(active_ctx, element['@context'], options) - - # set the type-scoped context to the context on input, for use later - type_scoped_ctx = active_ctx - - # Remember the first key found expanding to @type - type_key = None - - # look for scoped context on @type - for key, _value in sorted(element.items()): - expanded_property = self._expand_iri(active_ctx, key, vocab=True) - if expanded_property == '@type': - if not type_key: - type_key = key - # set scoped contexts from @type - types = [ - t for t in JsonLdProcessor.arrayify(element[key]) if _is_string(t) - ] - for type_ in sorted(types): - ctx = JsonLdProcessor.get_context_value( - type_scoped_ctx, type_, '@context' - ) - if ctx is not None and ctx is not False: - active_ctx = self._process_context( - active_ctx, ctx, options, propagate=False - ) + # prepare type-scoped contexts when nested + active_ctx, type_key, type_scoped_ctx = ( + self._prepare_nested_context(active_ctx, element, options) + ) # process each key and value in element, ignoring @nest content rval = {} @@ -2620,11 +2595,6 @@ def _expand_object( continue - # nested keys - if expanded_property == '@nest': - nests.append(key) - continue - # use potential scoped context for key term_ctx = active_ctx ctx = JsonLdProcessor.get_context_value(active_ctx, key, '@context') @@ -2633,6 +2603,11 @@ def _expand_object( active_ctx, ctx, options, propagate=True, override_protected=True ) + # for nested keys, add scoped context with key + if expanded_property == '@nest': + nests.append((key, term_ctx)) + continue + container = JsonLdProcessor.arrayify( JsonLdProcessor.get_context_value(active_ctx, key, '@container') ) @@ -2775,10 +2750,24 @@ def _expand_object( code='invalid value object value', ) - # expand each nested key - for key in nests: + # Nested values merge into the current node, but their term and type + # scoped contexts must still be prepared as if expanding a node object. + for key, term_ctx in nests: for nv in JsonLdProcessor.arrayify(element[key]): - if not _is_object(nv) or [ + if not _is_object(nv): + raise JsonLdError( + 'Invalid JSON-LD syntax; nested value must be a node object.', + 'jsonld.SyntaxError', + {'value': nv}, + code='invalid @nest value', + ) + + # prepare type-scoped contexts when nested + active_ctx, type_key, type_scoped_ctx = ( + self._prepare_nested_context(term_ctx, nv, options) + ) + + if [ k for k, v in nv.items() if self._expand_iri(active_ctx, k, vocab=True) == '@value' @@ -2789,6 +2778,7 @@ def _expand_object( {'value': nv}, code='invalid @nest value', ) + self._expand_object( active_ctx, active_property, @@ -2801,6 +2791,51 @@ def _expand_object( type_scoped_ctx=type_scoped_ctx, ) + def _prepare_nested_context(self, active_ctx, element, options): + """ + Prepare local and type-scoped contexts for a nested node object. + + `@nest` is semantically transparent in JSON-LD 1.1, so nested properties + are merged into the parent node. Context processing is not transparent: + a scoped context on the nest term must be active while discovering any + @type entries and applying the type-scoped contexts they reference. + """ + # Recursively expand object: process context if element has one + # Local contexts on the nested node apply before looking for @type, + # just like they do for an ordinary node object. + if '@context' in element: + active_ctx = self._process_context(active_ctx, element['@context'], options) + + # Set the type-scoped context to the context on input, for use later + type_scoped_ctx = active_ctx + + # Remember the first key found expanding to @type + type_key = None + + # Type terms are looked up against the context that was active before + # applying any type-scoped contexts discovered below. + for key, value in sorted(element.items()): + # The @type entry itself may be aliased by the nest term's scoped + # context, so use the nested active context to identify it. + if self._expand_iri(active_ctx, key, vocab=True) != '@type': + continue + + type_key = type_key or key + # set scoped contexts from @type + types = [t for t in JsonLdProcessor.arrayify(value) if _is_string(t)] + for type_ in sorted(types): + ctx = JsonLdProcessor.get_context_value( + type_scoped_ctx, type_, '@context' + ) + if ctx is not None and ctx is not False: + # Type-scoped contexts affect expansion of this nested node, + # but must not leak into sibling properties or parent nodes. + active_ctx = self._process_context( + active_ctx, ctx, options, propagate=False + ) + + return active_ctx, type_key, type_scoped_ctx + def _flatten(self, input): """ Performs JSON-LD flattening. diff --git a/tests/runtests.py b/tests/runtests.py index dd82b314..b9f7f97d 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -1019,12 +1019,10 @@ def write(self, filename): # well formed '.*toRdf-manifest#twf05$', # uncategorized - '.*toRdf-manifest#tc038$', '.*toRdf-manifest#ter54$', '.*toRdf-manifest#ter56$', '.*toRdf-manifest#tli12$', '.*toRdf-manifest#tli14$', - '.*toRdf-manifest#tc037$', ] }, 'skip': { diff --git a/tests/test_jsonld.py b/tests/test_jsonld.py index f7b00217..85458e4c 100644 --- a/tests/test_jsonld.py +++ b/tests/test_jsonld.py @@ -316,6 +316,85 @@ def test_structured_value_still_works_with_scoped_context(self): # meaning -> @id assert "@id" in prop_val + # Issue 204 + def test_scoped_context_on_nest_term_expands_nested_properties(self): + """A scoped context on a @nest term should apply to nested properties.""" + input = { + "@context": { + "@vocab": "http://example.org/vocab#", + "p1": { + "@id": "@nest", + "@context": {"p2": "http://example.org/ns#P2"}, + }, + }, + "p1": {"p2": "foo"}, + } + + expected = [ + { + "http://example.org/ns#P2": [ + { + "@value": "foo", + } + ], + } + ] + + result = jsonld.expand(input) + + assert result == expected + + # Issue 204 + def test_scoped_context_on_nest_term_expands_nested_type_scoped_context(self): + """ + A scoped context on a @nest term should be in effect when expanding the + nested node, including when processing any type-scoped contexts found on + that node. + """ + input = { + "@context": { + "@vocab": "http://example.org/outer#", + # p1 is an @nest term with a property-scoped context. That context defines + # Type and gives Type its own type-scoped context. + "p1": { + "@id": "@nest", + "@context": { + # The nested node uses Type and then uses p2 from Type's scoped context. + "Type": { + "@id": "http://example.org/ns#Type", + "@context": { + "p2": "http://example.org/ns#P2", + }, + }, + }, + }, + }, + "p1": { + "@type": "Type", + "p2": "foo", + }, + } + + # The @nest term context is active before @type is expanded and before Type's scoped + # context is applied. + expected = [ + { + # If nested values are expanded by directly walking their keys instead of + # running the normal expansion setup for the nested node, Type and p2 fall + # back to the outer @vocab. + "@type": ["http://example.org/ns#Type"], + "http://example.org/ns#P2": [ + { + "@value": "foo", + } + ], + } + ] + + result = jsonld.expand(input) + + assert result == expected + def test_mixed_plain_and_vocab_terms(self): """Contexts with both plain and @type:@vocab terms should work correctly.""" @@ -752,6 +831,59 @@ def test_double_and_float_values(self): result = jsonld.to_rdf(input) assert result == expected + # Issue 204 + def test_conflicting_property_names(self): + """ + Conversion to RDF should allow a node in the root @context with + a conflicting property name in its own @context + """ + input = { + "@context": { + "dublinCore": { + "@id": "http://foo.bar/dc", + "@context": {"title": "http://purl.org/dc/terms/title"}, + }, + "title": "http://foo.bar/title", + }, + "@id": "http://foo.bar/obj/test", + "title": "test", + "dublinCore": {"title": "Chapter 1: Jonathan Harker's Journal"}, + } + + expected = """ _:b0 . + "test" . +_:b0 "Chapter 1: Jonathan Harker's Journal" . +""" + + nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) + assert nquads == expected + + + def test_conflicting_property_names_in_nested_node(self): + """ + Conversion to RDF should not ignore a @nest'ed node in the root @context + a conflicting property name in its own @context + """ + input = { + "@context": { + "dublinCore": { + "@id": "@nest", + "@context": {"title": "http://purl.org/dc/terms/title"}, + }, + "title": "http://foo.bar/title", + }, + "@id": "http://foo.bar/obj/test", + "title": "test", + "dublinCore": {"title": "Chapter 1: Jonathan Harker's Journal"}, + } + + expected = """ "test" . + "Chapter 1: Jonathan Harker's Journal" . +""" + + nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) + assert nquads == expected + class TestCompact: # Issue 59 - PR: https://github.com/digitalbazaar/pyld/pull/60