From 844ac19dd2dba3884737155eed294c417ebf700d Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 9 Sep 2026 17:57:27 +0200 Subject: [PATCH 1/2] fix: JSON-LD collection syntax can silently duplicate shared RDF lists Converter.to_collection walked an rdf:List chain checking only that each cell carries one rdf:first/rdf:rest pair and that the chain is acyclic. It did not check how many statements point into the chain. The @list form inlines the whole chain at a single reference and writes its cells nowhere else, so when a cell is referenced from more than one place each referring list rendered the shared cells again. The round trip then gained triples: _:tail rdf:first "b" ; rdf:rest rdf:nil . _:head rdf:first "a" ; rdf:rest _:tail . ex:s1 ex:p _:head . ex:s2 ex:p _:tail . # second reference into the chain serialized as two independent @list values, so the tail was emitted twice: 6 triples in, 8 out, not isomorphic. A head referenced twice was duplicated the same way: 4 in, 6 out. The Serialize RDF as JSON-LD Algorithm (JSON-LD 1.1 Processing Algorithms and API, section 8.4) only turns a chain into @list when every cell, the head included, is a blank node that is "referenced once", counted over the whole dataset. The converter now applies that condition. It counts blank-node references once per conversion, over every graph it writes, and a chain with a cell referenced more than once is written with explicit rdf:first/rdf:rest, which can express any arrangement of sharing. Counting across graphs also covers a cell that another named graph points at, whose reference was otherwise left dangling. add_to_node offered a term declared with "@container": "@list" for any object with an rdf:first, then iterated to_collection without checking for None. That already raised TypeError for a chain the converter refuses, such as one whose cell carries an extra property, and would have for every shared chain. The term is now chosen from to_collection's result instead. This is the defect #3504 fixed in the Turtle and longturtle serializers; the JSON-LD converter has its own chain walk. Fixes #3542. Signed-off-by: jdsika --- rdflib/plugins/serializers/jsonld.py | 40 ++++- test/jsonld/test_shared_list_cells.py | 228 ++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 test/jsonld/test_shared_list_cells.py diff --git a/rdflib/plugins/serializers/jsonld.py b/rdflib/plugins/serializers/jsonld.py index be87a9832..c15d60a4b 100644 --- a/rdflib/plugins/serializers/jsonld.py +++ b/rdflib/plugins/serializers/jsonld.py @@ -37,6 +37,7 @@ from __future__ import annotations import warnings +from collections import Counter from typing import IO, Any, Optional from rdflib.graph import DATASET_DEFAULT_GRAPH_ID, Graph, _ObjectType @@ -155,6 +156,8 @@ def __init__(self, context: Context, use_native_types: bool, use_rdf_type: bool) self.context = context self.use_native_types = context.active or use_native_types self.use_rdf_type = use_rdf_type + # Statements per blank-node object, across every graph being converted. + self._bnode_usages: Optional[Counter[Identifier]] = None def convert(self, graph: Graph): # TODO: bug in rdflib dataset parsing (nquads et al): @@ -187,6 +190,10 @@ def convert(self, graph: Graph): graphs = [graph] default_graph_id = graph.identifier + self._bnode_usages = Counter( + o for g in graphs for o in g.objects() if isinstance(o, BNode) + ) + context = self.context objs: list[Any] = [] @@ -270,12 +277,16 @@ def add_to_node( ): context = self.context + collection = None if isinstance(o, Literal): datatype = str(o.datatype) if o.datatype else None language = o.language term = context.find_term(str(p), datatype, language=language) else: - containers = [LIST, None] if graph.value(o, RDF.first) else [None] + # Only offer an @list term for a chain that can be written as @list. + if graph.value(o, RDF.first): + collection = self.to_collection(graph, o) + containers = [LIST, None] if collection is not None else [None] for container in containers: for coercion in (ID, VOCAB, UNDEF): # type error: Argument 2 to "find_term" of "Context" has incompatible type "object"; expected "Union[str, Defined, None]" @@ -305,11 +316,11 @@ def add_to_node( ): node = str(o) - if LIST in term.container: + if LIST in term.container and collection is not None: node = [ self.type_coerce(v, term.type) or self.to_raw_value(graph, s, v, nodemap) - for v in self.to_collection(graph, o) + for v in collection ] elif LANG in term.container and language: value = s_node.setdefault(p_key, {}) @@ -416,7 +427,28 @@ def to_raw_value( else: return v + def _usages(self, graph: Graph, node: Identifier) -> int: + """Count the statements whose object is `node`. + + Inside `convert` this covers every graph being converted; called on + its own, `graph` is taken to be the whole input. + """ + if self._bnode_usages is None: + return len(list(graph.subject_predicates(node))) + return self._bnode_usages[node] + def to_collection(self, graph: Graph, l_: Identifier): + """Return the members of the `rdf:List` headed by `l_`, or None. + + None means the chain must not be written as `@list`. Besides a + malformed or cyclic chain, that includes a chain any of whose cells, + the head included, is the object of more than one statement in any of + the graphs being converted. `@list` inlines the cells at a single + reference and writes them nowhere else, so another reference would be + left pointing at a node the output never defines. This is the + "referenced once" condition of the Serialize RDF as JSON-LD Algorithm + (JSON-LD 1.1 Processing Algorithms and API, section 8.4). + """ if l_ != RDF.nil and not graph.value(l_, RDF.first): return None list_nodes: list[Optional[_ObjectType]] = [] @@ -426,6 +458,8 @@ def to_collection(self, graph: Graph, l_: Identifier): return list_nodes if isinstance(l_, URIRef): return None + if self._usages(graph, l_) != 1: + return None first, rest = None, None for p, o in graph.predicate_objects(l_): if not first and p == RDF.first: diff --git a/test/jsonld/test_shared_list_cells.py b/test/jsonld/test_shared_list_cells.py new file mode 100644 index 000000000..0da839dfb --- /dev/null +++ b/test/jsonld/test_shared_list_cells.py @@ -0,0 +1,228 @@ +import json +from typing import Any + +import pytest + +from rdflib import RDF, RDFS, BNode, Dataset, Graph, Literal, Namespace +from rdflib.compare import isomorphic +from rdflib.plugins.serializers.jsonld import Converter +from rdflib.plugins.shared.jsonld.context import Context + +NS = Namespace("http://example.org/ns/") +# A context whose term for ns:p writes every value of ns:p as a list. +LIST_CONTEXT = {"p": {"@id": str(NS.p), "@container": "@list"}} + + +def _shared_tail_graph(): + """Two lists whose final cell is the same node, not a copy of it.""" + g = Graph() + tail = BNode() + head = BNode() + g.add((tail, RDF.first, Literal("b"))) + g.add((tail, RDF.rest, RDF.nil)) + g.add((head, RDF.first, Literal("a"))) + g.add((head, RDF.rest, tail)) + g.add((NS.s1, NS.p, head)) + g.add((NS.s2, NS.p, tail)) + return g, head, tail + + +def _shared_head_graph(): + """One list referenced from two subjects.""" + g = Graph() + head = BNode() + g.add((head, RDF.first, Literal("a"))) + g.add((head, RDF.rest, RDF.nil)) + g.add((NS.s1, NS.p, head)) + g.add((NS.s2, NS.p, head)) + return g, head, head + + +def _annotated_cell_graph(): + """A list whose cell has a property besides rdf:first and rdf:rest.""" + g = Graph() + head = BNode() + g.add((head, RDF.first, Literal("a"))) + g.add((head, RDF.rest, RDF.nil)) + g.add((head, RDFS.label, Literal("annotated cell"))) + g.add((NS.s, NS.p, head)) + return g, head, head + + +def _non_empty_lists(data: str) -> list[Any]: + """Every non-empty ``@list`` value in a JSON-LD document. + + ``"@list": []`` is left out: it is ``rdf:nil``, a single IRI, not a list + structure. + """ + found: list[Any] = [] + + def walk(value: Any) -> None: + if isinstance(value, dict): + if value.get("@list"): + found.append(value["@list"]) + for item in value.values(): + walk(item) + elif isinstance(value, list): + for item in value: + walk(item) + + walk(json.loads(data)) + return found + + +def test_jsonld_shared_list_tail_round_trips(): + """ + A list cell pointed to from more than one place cannot be written as + ``@list``: that form inlines the whole chain at a single reference and + writes its cells nowhere else, so any other statement referring to one of + those cells is left pointing at a node the output never defines. + + Rendering each list independently instead emitted the shared cell once per + referring list, so the round-tripped graph gained triples: 6 in, 8 out, + non-isomorphic. + + Same defect class as ``test_turtle_shared_list_tail_round_trips`` and + ``test_longturtle_shared_list_tail_round_trips``; the JSON-LD converter has + its own chain walk and so needed the same reference check. + """ + g, _head, _tail = _shared_tail_graph() + + data = g.serialize(format="json-ld") + g2 = Graph() + g2.parse(data=data, format="json-ld") + + assert len(g2) == len(g) + assert isomorphic(g, g2) + # The chain is stated explicitly rather than inlined. + assert _non_empty_lists(data) == [] + assert len(list(g2.triples((None, RDF.first, None)))) == 2 + assert len(list(g2.triples((None, RDF.rest, None)))) == 2 + + +def test_a_shared_list_head_round_trips(): + """Sharing the head, not an interior cell, is the same defect: 4 in, 6 out.""" + g, _head, _tail = _shared_head_graph() + + data = g.serialize(format="json-ld") + g2 = Graph() + g2.parse(data=data, format="json-ld") + + assert len(g2) == len(g) + assert isomorphic(g, g2) + assert _non_empty_lists(data) == [] + + +def test_to_collection_rejects_a_multiply_referenced_cell(): + """The decision itself, so the reason survives a refactor of the caller.""" + g, head, tail = _shared_tail_graph() + converter = Converter(Context(), False, None) + + assert converter.to_collection(g, head) is None + assert converter.to_collection(g, tail) is None + + +@pytest.mark.parametrize("auto_compact", [False, True]) +@pytest.mark.parametrize( + "build", + [_shared_tail_graph, _shared_head_graph, _annotated_cell_graph], + ids=["shared-tail", "shared-head", "annotated-cell"], +) +def test_a_list_container_term_is_not_used_for_a_chain_that_is_not_a_list( + build, auto_compact +): + """A term declared with ``"@container": "@list"`` is the ``@list`` form too. + + It must not be chosen for a chain that cannot be written as a list. It was + chosen for any object with an ``rdf:first``, and the refused chain then + raised ``TypeError: 'NoneType' object is not iterable``. + """ + g, _head, _tail = build() + + data = g.serialize( + format="json-ld", context=LIST_CONTEXT, auto_compact=auto_compact + ) + g2 = Graph() + g2.parse(data=data, format="json-ld") + + assert isomorphic(g, g2) + + +def test_a_privately_owned_list_is_still_written_as_a_list(): + """The fix must not stop ordinary lists from using ``@list``.""" + g = Graph() + g.add((NS.s, NS.p, RDF.nil)) + collection = BNode() + second = BNode() + g.add((collection, RDF.first, Literal("a"))) + g.add((collection, RDF.rest, second)) + g.add((second, RDF.first, Literal("b"))) + g.add((second, RDF.rest, RDF.nil)) + g.add((NS.owner, NS.items, collection)) + + data = g.serialize(format="json-ld") + g2 = Graph() + g2.parse(data=data, format="json-ld") + + assert len(_non_empty_lists(data)) == 1 + assert isomorphic(g, g2) + + +def test_a_privately_owned_list_still_uses_a_list_container_term(): + g = Graph() + head = BNode() + second = BNode() + g.add((head, RDF.first, Literal("a"))) + g.add((head, RDF.rest, second)) + g.add((second, RDF.first, Literal("b"))) + g.add((second, RDF.rest, RDF.nil)) + g.add((NS.s, NS.p, head)) + + data = g.serialize(format="json-ld", context=LIST_CONTEXT) + g2 = Graph() + g2.parse(data=data, format="json-ld") + + assert json.loads(data)["p"] == ["a", "b"] + assert isomorphic(g, g2) + + +def test_a_list_cell_shared_across_named_graphs_round_trips(): + """References are counted across the whole dataset, not per graph. + + The Serialize RDF as JSON-LD Algorithm (JSON-LD 1.1 Processing Algorithms + and API, section 8.4) keeps one "referenced once" map for all graphs, so a + cell that another named graph points at cannot be inlined either: the + other graph's reference would be left dangling. + """ + ds = Dataset() + g1, g2 = ds.graph(NS.g1), ds.graph(NS.g2) + cell = BNode() + g1.add((cell, RDF.first, Literal("a"))) + g1.add((cell, RDF.rest, RDF.nil)) + g1.add((NS.s1, NS.p, cell)) + g2.add((NS.s2, NS.p, cell)) + + data = ds.serialize(format="json-ld") + ds2 = Dataset() + ds2.parse(data=data, format="json-ld") + + (in_g1,) = ds2.graph(NS.g1).objects(NS.s1, NS.p) + (in_g2,) = ds2.graph(NS.g2).objects(NS.s2, NS.p) + assert in_g1 == in_g2 + assert (in_g1, RDF.first, Literal("a")) in ds2.graph(NS.g1) + assert _non_empty_lists(data) == [] + + +def test_lists_in_separate_named_graphs_are_still_written_as_lists(): + """Counting across graphs must not reject a list each graph owns alone.""" + ds = Dataset() + for name in ("g1", "g2"): + graph = ds.graph(NS[name]) + head = BNode() + graph.add((head, RDF.first, Literal(name))) + graph.add((head, RDF.rest, RDF.nil)) + graph.add((NS.s, NS.p, head)) + + data = ds.serialize(format="json-ld") + + assert len(_non_empty_lists(data)) == 2 From 7dd4a556da2a2df2e6b6bf6e01a994b572a74729 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 26 Oct 2026 22:17:51 +1000 Subject: [PATCH 2/2] fix: preserve explicit JSON-LD list descriptions efficiently Queue descriptions behind blank-node references, including coercing contexts, so rejected collections retain their triples without recursive expansion. Cache rejected prefixes for each graph conversion to avoid repeatedly walking shared-tail suffixes. Add round-trip, traversal-count, and converter-reuse regressions. Signed-off-by: jdsika --- rdflib/plugins/serializers/jsonld.py | 79 ++++++++++++------ test/jsonld/test_shared_list_cells.py | 112 +++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 28 deletions(-) diff --git a/rdflib/plugins/serializers/jsonld.py b/rdflib/plugins/serializers/jsonld.py index c15d60a4b..f1914366e 100644 --- a/rdflib/plugins/serializers/jsonld.py +++ b/rdflib/plugins/serializers/jsonld.py @@ -158,6 +158,9 @@ def __init__(self, context: Context, use_native_types: bool, use_rdf_type: bool) self.use_rdf_type = use_rdf_type # Statements per blank-node object, across every graph being converted. self._bnode_usages: Optional[Counter[Identifier]] = None + self._pending_nodes: list[BNode] = [] + # Rejected prefixes are reusable only during a single graph conversion. + self._rejected_lists: Optional[set[Identifier]] = None def convert(self, graph: Graph): # TODO: bug in rdflib dataset parsing (nquads et al): @@ -232,15 +235,25 @@ def convert(self, graph: Graph): def from_graph(self, graph: Graph): nodemap: dict[Any, Any] = {} - - for s in set(graph.subjects()): - ## only iri:s and unreferenced (rest will be promoted to top if needed) - if isinstance(s, URIRef) or ( - isinstance(s, BNode) and not any(graph.subjects(None, s)) - ): - self.process_subject(graph, s, nodemap) - - return list(nodemap.values()) + self._pending_nodes = [] + self._rejected_lists = set() + + try: + for s in set(graph.subjects()): + ## only iri:s and unreferenced (rest will be promoted to top if needed) + if isinstance(s, URIRef) or ( + isinstance(s, BNode) and not any(graph.subjects(None, s)) + ): + self.process_subject(graph, s, nodemap) + + # Explicit blank-node chains can be arbitrarily long. Process their + # descriptions iteratively instead of recursing through each reference. + while self._pending_nodes: + self.process_subject(graph, self._pending_nodes.pop(), nodemap) + + return list(nodemap.values()) + finally: + self._rejected_lists = None def process_subject(self, graph: Graph, s: IdentifiedNode, nodemap): if isinstance(s, URIRef): @@ -322,6 +335,12 @@ def add_to_node( or self.to_raw_value(graph, s, v, nodemap) for v in collection ] + if term.type == ID: + self._pending_nodes.extend( + v + for v in collection + if isinstance(v, BNode) and any(graph.predicate_objects(v)) + ) elif LANG in term.container and language: value = s_node.setdefault(p_key, {}) values = value.get(language) @@ -353,6 +372,10 @@ def add_to_node( if node is None: node = self.to_raw_value(graph, s, o, nodemap) + elif isinstance(o, BNode) and term and LIST not in term.container: + # Coercion emits only the reference; preserve its description too. + if any(graph.predicate_objects(o)): + self._pending_nodes.append(o) value = s_node.get(p_key) if value: @@ -392,15 +415,7 @@ def to_raw_value( ] return {context.list_key: coll} elif isinstance(o, BNode): - embed = ( - False # TODO: self.context.active or using startnode and only one ref - ) - onode = self.process_subject(graph, o, nodemap) - if onode: - if embed and not any(s2 for s2 in graph.subjects(None, o) if s2 != s): - return onode - else: - nodemap[onode[context.id_key]] = onode + self._pending_nodes.append(o) return {context.id_key: o.n3()} elif isinstance(o, URIRef): # TODO: embed if o != startnode (else reverse) @@ -449,17 +464,26 @@ def to_collection(self, graph: Graph, l_: Identifier): "referenced once" condition of the Serialize RDF as JSON-LD Algorithm (JSON-LD 1.1 Processing Algorithms and API, section 8.4). """ + rejected = self._rejected_lists + if rejected is None: + rejected = set() + if l_ in rejected: + return None if l_ != RDF.nil and not graph.value(l_, RDF.first): return None list_nodes: list[Optional[_ObjectType]] = [] - chain = set([l_]) + chain: set[Identifier] = set() while l_: if l_ == RDF.nil: return list_nodes - if isinstance(l_, URIRef): - return None - if self._usages(graph, l_) != 1: - return None + if ( + l_ in chain + or l_ in rejected + or isinstance(l_, URIRef) + or self._usages(graph, l_) != 1 + ): + break + chain.add(l_) first, rest = None, None for p, o in graph.predicate_objects(l_): if not first and p == RDF.first: @@ -467,10 +491,13 @@ def to_collection(self, graph: Graph, l_: Identifier): elif not rest and p == RDF.rest: rest = o elif p != RDF.type or o != RDF.List: + rejected.update(chain) return None list_nodes.append(first) # type error: Incompatible types in assignment (expression has type "Optional[Node]", variable has type "Identifier") l_ = rest # type: ignore[assignment] - if l_ in chain: - return None - chain.add(l_) + + # Every visited prefix reaches the same invalid suffix. Remember only + # these rejections, so explicit cells do not repeatedly scan that suffix. + rejected.update(chain) + return None diff --git a/test/jsonld/test_shared_list_cells.py b/test/jsonld/test_shared_list_cells.py index 0da839dfb..6ff6af9e1 100644 --- a/test/jsonld/test_shared_list_cells.py +++ b/test/jsonld/test_shared_list_cells.py @@ -1,5 +1,6 @@ import json from typing import Any +from unittest.mock import patch import pytest @@ -11,6 +12,10 @@ NS = Namespace("http://example.org/ns/") # A context whose term for ns:p writes every value of ns:p as a list. LIST_CONTEXT = {"p": {"@id": str(NS.p), "@container": "@list"}} +COERCING_LIST_CONTEXT = { + "p": {"@id": str(NS.p), "@container": "@list", "@type": "@id"}, + "ref": {"@id": str(NS.p), "@type": "@id"}, +} def _shared_tail_graph(): @@ -27,6 +32,17 @@ def _shared_tail_graph(): return g, head, tail +def _long_shared_tail_graph(length: int) -> Graph: + g = Graph() + cells = [BNode() for _ in range(length)] + for index, cell in enumerate(cells): + g.add((cell, RDF.first, Literal(index))) + g.add((cell, RDF.rest, cells[index + 1] if index + 1 < length else RDF.nil)) + g.add((NS.s1, NS.p, cells[0])) + g.add((NS.s2, NS.p, cells[-1])) + return g + + def _shared_head_graph(): """One list referenced from two subjects.""" g = Graph() @@ -186,7 +202,8 @@ def test_a_privately_owned_list_still_uses_a_list_container_term(): assert isomorphic(g, g2) -def test_a_list_cell_shared_across_named_graphs_round_trips(): +@pytest.mark.parametrize("context", [None, LIST_CONTEXT, COERCING_LIST_CONTEXT]) +def test_a_list_cell_shared_across_named_graphs_round_trips(context): """References are counted across the whole dataset, not per graph. The Serialize RDF as JSON-LD Algorithm (JSON-LD 1.1 Processing Algorithms @@ -202,7 +219,7 @@ def test_a_list_cell_shared_across_named_graphs_round_trips(): g1.add((NS.s1, NS.p, cell)) g2.add((NS.s2, NS.p, cell)) - data = ds.serialize(format="json-ld") + data = ds.serialize(format="json-ld", context=context) ds2 = Dataset() ds2.parse(data=data, format="json-ld") @@ -226,3 +243,94 @@ def test_lists_in_separate_named_graphs_are_still_written_as_lists(): data = ds.serialize(format="json-ld") assert len(_non_empty_lists(data)) == 2 + + +@pytest.mark.parametrize("auto_compact", [False, True]) +@pytest.mark.parametrize( + "build", + [_shared_tail_graph, _shared_head_graph, _annotated_cell_graph], + ids=["shared-tail", "shared-head", "annotated-cell"], +) +def test_rejected_list_with_a_coercing_fallback_round_trips(build, auto_compact): + """An @id fallback must emit the cells as well as references to them.""" + g, _head, _tail = build() + + data = g.serialize( + format="json-ld", context=COERCING_LIST_CONTEXT, auto_compact=auto_compact + ) + g2 = Graph().parse(data=data, format="json-ld") + + assert len(g2) == len(g) + assert isomorphic(g, g2) + + +@pytest.mark.parametrize("context", [None, LIST_CONTEXT, COERCING_LIST_CONTEXT]) +def test_long_list_with_a_shared_tail_round_trips(context): + """Explicit list cells must not consume stack proportional to length.""" + g = _long_shared_tail_graph(500) + + data = g.serialize(format="json-ld", context=context) + g2 = Graph().parse(data=data, format="json-ld") + + assert len(g2) == len(g) + assert isomorphic(g, g2) + + +@pytest.mark.parametrize("length", [50, 100, 200]) +def test_shared_tail_list_serialization_has_linear_traversal_work(length): + """Rejecting @list must not rescan the remaining chain for every cell.""" + g = _long_shared_tail_graph(length) + + with patch.object(g, "predicate_objects", wraps=g.predicate_objects) as traversal: + data = g.serialize(format="json-ld") + + # Allow several passes, but reject the repeated suffix walks that made + # even a 2,000-cell list take seconds. Operation counts avoid timing flakes. + assert traversal.call_count <= 10 * length + g2 = Graph().parse(data=data, format="json-ld") + assert len(g2) == len(g) + assert isomorphic(g, g2) + + +def test_a_rejected_prefix_does_not_reject_a_private_suffix(): + g, head, tail = _shared_tail_graph() + g.remove((NS.s2, NS.p, tail)) + g.add((head, RDFS.label, Literal("annotated cell"))) + + data = g.serialize(format="json-ld") + g2 = Graph().parse(data=data, format="json-ld") + + assert len(_non_empty_lists(data)) == 1 + assert isomorphic(g, g2) + + +def test_rejected_lists_are_reconsidered_on_the_next_conversion(): + g, _head, tail = _shared_tail_graph() + converter = Converter(Context(), False, False) + first = json.dumps(converter.convert(g)) + assert _non_empty_lists(first) == [] + + # Reuse the converter after the list becomes private. Graph-local rejection + # decisions must not survive into another conversion of the same nodes. + g.remove((NS.s2, NS.p, tail)) + second = json.dumps(converter.convert(g)) + g2 = Graph().parse(data=second, format="json-ld") + + assert len(_non_empty_lists(second)) == 1 + assert isomorphic(g, g2) + + +def test_coerced_list_member_keeps_its_description(): + """Coercing list members to references must preserve their properties.""" + g = Graph() + head, member = BNode(), BNode() + g.add((NS.s, NS.p, head)) + g.add((head, RDF.first, member)) + g.add((head, RDF.rest, RDF.nil)) + g.add((member, RDFS.label, Literal("list member"))) + + data = g.serialize(format="json-ld", context=COERCING_LIST_CONTEXT) + g2 = Graph().parse(data=data, format="json-ld") + + assert len(g2) == len(g) + assert isomorphic(g, g2)