@_linked/core 2.28.2 → 2.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.28.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#322](https://github.com/linked-fw/core/pull/322) [`720e284`](https://github.com/linked-fw/core/commit/720e28438842cc8aa617a818c086b6279e87ade0) Thanks [@flyon](https://github.com/flyon)! - An update's WHERE, and a bulk delete's, no longer multiply their parts — the same fix [#318](https://github.com/linked-fw/core/issues/318) made for a delete by id. `update`, `upsert` and `updateWhere` chained the old value of every property and every owned-cleanup block as OPTIONALs, so the solutions were the product of all of them: updating two multi-valued properties gave one solution per pair of old values, and replacing a `contains` property gave the old node's own triples times every triple of its owned subtree. They are now one OPTIONAL over a UNION of those blocks, which deletes and inserts the same triples. Old values a computed field's BIND reads stay joined to it, as before. `deleteAll` and `deleteWhere` likewise join the root's triples and each blank-node subtree with a UNION instead of nested OPTIONALs.
8
+
9
+ Also fixes a data-loss bug: replacing a `contains` property that had no old value deleted the owned subtree of every other node in the graph, because the cascade block's `?old` variable was left unbound. Each cascade block now binds it through the owning edge.
10
+
3
11
  ## 2.28.2
4
12
 
5
13
  ### Patch Changes
@@ -27,8 +27,11 @@ export declare function upsertToAlgebra(query: IRUpsertMutation, options?: Sparq
27
27
  * Build DELETE patterns + WHERE OPTIONAL blocks that cascade-delete the owned subtree
28
28
  * reachable from `rootTerm` via `contains` edges. `varPrefix` keeps generated variables
29
29
  * unique across multiple roots in one query. Returns empty when nothing is owned.
30
+ *
31
+ * `anchor`, when given, leads every block: the triple that binds a variable root, so
32
+ * each block stays correct on its own (as a UNION branch) when no such root exists.
30
33
  */
31
- export declare function buildOwnedCascade(rootTerm: SparqlTerm, varPrefix: string): {
34
+ export declare function buildOwnedCascade(rootTerm: SparqlTerm, varPrefix: string, anchor?: SparqlTriple): {
32
35
  deletePatterns: SparqlTriple[];
33
36
  whereOptionals: SparqlAlgebraNode[];
34
37
  };
@@ -1641,7 +1641,10 @@ function processUpdateFields(data, subjectTerm, options) {
1641
1641
  const self = buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, `uc${n}_`);
1642
1642
  deletePatterns.push(...self.deletePatterns);
1643
1643
  cascadeOptionals.push(...self.whereOptionals);
1644
- const cascade = buildOwnedCascade(oldTerm, `uc${n}_`);
1644
+ // Every cascade block binds `?old` itself through the owning edge, like the
1645
+ // self-delete block does. Without it an absent old value leaves `?old` unbound and
1646
+ // the block matches the owned subtree of EVERY node in the graph.
1647
+ const cascade = buildOwnedCascade(oldTerm, `uc${n}_`, tripleOf(subjectTerm, propertyTerm, oldTerm));
1645
1648
  deletePatterns.push(...cascade.deletePatterns);
1646
1649
  cascadeOptionals.push(...cascade.whereOptionals);
1647
1650
  });
@@ -1699,21 +1702,80 @@ export function upsertToAlgebra(query, options) {
1699
1702
  * update path has always emitted.
1700
1703
  */
1701
1704
  function buildDeleteInsertPlan(query, options, opts) {
1702
- var _a, _b, _c;
1703
1705
  const subjectTerm = iriTerm(query.id);
1704
1706
  const result = processUpdateFields(query.data, subjectTerm, options);
1707
+ const whereAlgebra = buildUpdateWhere({ type: 'bgp', triples: [] }, result, query.traversalPatterns, subjectTerm);
1708
+ // The type triple leads the INSERT, mirroring `create`'s triple order.
1709
+ const insertPatterns = (opts === null || opts === void 0 ? void 0 : opts.ensureType)
1710
+ ? [
1711
+ tripleOf(subjectTerm, iriTerm(RDF_TYPE), iriTerm(opts.ensureType)),
1712
+ ...result.insertPatterns,
1713
+ ]
1714
+ : result.insertPatterns;
1715
+ return {
1716
+ type: 'delete_insert',
1717
+ deletePatterns: result.deletePatterns,
1718
+ insertPatterns,
1719
+ whereAlgebra,
1720
+ };
1721
+ }
1722
+ /** Left-nested UNION of `blocks` (at least one). */
1723
+ function unionOf(blocks) {
1724
+ let union = blocks[0];
1725
+ for (let i = 1; i < blocks.length; i++) {
1726
+ union = { type: 'union', left: union, right: blocks[i] };
1727
+ }
1728
+ return union;
1729
+ }
1730
+ /** Every variable name an expression mentions, including inside EXISTS patterns. */
1731
+ function collectVariableNames(value, into) {
1732
+ if (Array.isArray(value)) {
1733
+ for (const item of value)
1734
+ collectVariableNames(item, into);
1735
+ }
1736
+ else if (value && typeof value === 'object') {
1737
+ const node = value;
1738
+ if ((node.kind === 'variable_expr' || node.kind === 'variable') && typeof node.name === 'string') {
1739
+ into.add(node.name);
1740
+ }
1741
+ if (node.kind === 'bound_expr' && typeof node.variable === 'string') {
1742
+ into.add(node.variable);
1743
+ }
1744
+ for (const child of Object.values(value))
1745
+ collectVariableNames(child, into);
1746
+ }
1747
+ return into;
1748
+ }
1749
+ /**
1750
+ * The WHERE shared by `update`, `upsert` and `updateWhere`, on top of `base` (empty for
1751
+ * an id-targeted update; the type guard and filter for an update-where).
1752
+ *
1753
+ * The old values and owned-cleanup blocks are independent of each other, so they are
1754
+ * joined as ONE OPTIONAL over a UNION, never as a chain of OPTIONALs. A chain is a join:
1755
+ * the old values of two multi-valued properties, or an owned node's own triples and its
1756
+ * owned subtree, multiply into one solution per combination. A UNION yields each block's
1757
+ * matches once, so the solutions add up, and the DELETE removes exactly the same triples
1758
+ * (a template triple a solution leaves unbound is skipped). The OPTIONAL keeps one
1759
+ * solution when nothing matches, so the INSERT still happens.
1760
+ *
1761
+ * Old values that a computed field's BIND reads stay in `base` as before, together with
1762
+ * the traversals they need, because the BIND needs them bound in the same solution. With
1763
+ * a single independent block the output is the one OPTIONAL it always was.
1764
+ */
1765
+ function buildUpdateWhere(base, result, traversalPatterns, subjectTerm) {
1766
+ var _a, _b;
1705
1767
  // Partition old-value/optional triples into subject-anchored ones and those
1706
1768
  // anchored on a traversal target variable (e.g. `?a1 <name> ?a1_name` from a
1707
1769
  // `p.bestFriend.name.ucase()` expression). Traversal-anchored triples MUST be
1708
1770
  // nested inside the same OPTIONAL group as their traversal edge — otherwise,
1709
1771
  // when the subject has no such edge, the leaf variable is unbound and the
1710
1772
  // property triple matches every entity in the graph (data-corruption bug).
1711
- const travTos = new Set(((_a = query.traversalPatterns) !== null && _a !== void 0 ? _a : []).map((t) => t.to));
1773
+ const travTos = new Set((traversalPatterns !== null && traversalPatterns !== void 0 ? traversalPatterns : []).map((t) => t.to));
1712
1774
  const travAnchoredByTo = new Map();
1713
1775
  const subjectAnchored = [];
1714
1776
  for (const triple of result.oldValueTriples) {
1715
1777
  if (triple.subject.kind === 'variable' && travTos.has(triple.subject.name)) {
1716
- const list = (_b = travAnchoredByTo.get(triple.subject.name)) !== null && _b !== void 0 ? _b : [];
1778
+ const list = (_a = travAnchoredByTo.get(triple.subject.name)) !== null && _a !== void 0 ? _a : [];
1717
1779
  list.push(triple);
1718
1780
  travAnchoredByTo.set(triple.subject.name, list);
1719
1781
  }
@@ -1721,36 +1783,50 @@ function buildDeleteInsertPlan(query, options, opts) {
1721
1783
  subjectAnchored.push(triple);
1722
1784
  }
1723
1785
  }
1724
- let whereAlgebra = wrapOldValueOptionals({ type: 'bgp', triples: [] }, subjectAnchored);
1786
+ // Old values a BIND reads must share its solution; the rest are independent blocks.
1787
+ const readByBinds = new Set();
1788
+ for (const ext of result.extends)
1789
+ collectVariableNames(ext.expression, readByBinds);
1790
+ const correlated = [];
1791
+ const independent = [];
1792
+ for (const triple of subjectAnchored) {
1793
+ const vars = collectVariableNames([triple.subject, triple.predicate, triple.object], new Set());
1794
+ if ([...vars].some((v) => readByBinds.has(v))) {
1795
+ correlated.push(triple);
1796
+ }
1797
+ else {
1798
+ independent.push({ type: 'bgp', triples: [triple] });
1799
+ }
1800
+ }
1801
+ let whereAlgebra = wrapOldValueOptionals(base, correlated);
1725
1802
  // Add traversal OPTIONAL patterns (for multi-segment expression refs). The
1726
1803
  // traversal edge and its dependent leaf property triples share one OPTIONAL
1727
1804
  // group so the leaf variable is scoped to the traversal target. These come
1728
1805
  // BEFORE expression BINDs since the BINDs reference the traversal variables.
1729
- if (query.traversalPatterns) {
1730
- for (const trav of query.traversalPatterns) {
1731
- const fromTerm = trav.from === '__mutation_subject__' ? subjectTerm : varTerm(trav.from);
1732
- const traversalTriple = tripleOf(fromTerm,
1733
- // The declared `sh:path`, like every other predicate — not the property
1734
- // shape's own IRI, which identifies the description of the property.
1735
- resolvePropertyPredicateTerm(trav.property), varTerm(trav.to));
1736
- // The traversal edge binds the target var; each dependent leaf property is
1737
- // a nested OPTIONAL *within* that scope, so a missing optional property
1738
- // (e.g. a bestFriend with no hobby) doesn't drop the whole group, while an
1739
- // absent edge still leaves every leaf var unbound (no cross-entity match).
1740
- let travNode = { type: 'bgp', triples: [traversalTriple] };
1741
- for (const leaf of (_c = travAnchoredByTo.get(trav.to)) !== null && _c !== void 0 ? _c : []) {
1742
- travNode = {
1743
- type: 'left_join',
1744
- left: travNode,
1745
- right: { type: 'bgp', triples: [leaf] },
1746
- };
1747
- }
1748
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: travNode };
1806
+ for (const trav of traversalPatterns !== null && traversalPatterns !== void 0 ? traversalPatterns : []) {
1807
+ const fromTerm = trav.from === '__mutation_subject__' ? subjectTerm : varTerm(trav.from);
1808
+ const traversalTriple = tripleOf(fromTerm,
1809
+ // The declared `sh:path`, like every other predicate — not the property
1810
+ // shape's own IRI, which identifies the description of the property.
1811
+ resolvePropertyPredicateTerm(trav.property), varTerm(trav.to));
1812
+ // The traversal edge binds the target var; each dependent leaf property is
1813
+ // a nested OPTIONAL *within* that scope, so a missing optional property
1814
+ // (e.g. a bestFriend with no hobby) doesn't drop the whole group, while an
1815
+ // absent edge still leaves every leaf var unbound (no cross-entity match).
1816
+ let travNode = { type: 'bgp', triples: [traversalTriple] };
1817
+ for (const leaf of (_b = travAnchoredByTo.get(trav.to)) !== null && _b !== void 0 ? _b : []) {
1818
+ travNode = {
1819
+ type: 'left_join',
1820
+ left: travNode,
1821
+ right: { type: 'bgp', triples: [leaf] },
1822
+ };
1749
1823
  }
1824
+ whereAlgebra = { type: 'left_join', left: whereAlgebra, right: travNode };
1750
1825
  }
1751
- // Owned-subtree cascade OPTIONALs for replaced `contains` properties.
1752
- for (const optional of result.cascadeOptionals) {
1753
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: optional };
1826
+ // Independent old values + owned-subtree cleanup for `contains` replace/remove.
1827
+ const blocks = [...independent, ...result.cascadeOptionals];
1828
+ if (blocks.length > 0) {
1829
+ whereAlgebra = { type: 'left_join', left: whereAlgebra, right: unionOf(blocks) };
1754
1830
  }
1755
1831
  // Add BIND expressions for computed fields
1756
1832
  for (const ext of result.extends) {
@@ -1761,19 +1837,7 @@ function buildDeleteInsertPlan(query, options, opts) {
1761
1837
  expression: ext.expression,
1762
1838
  };
1763
1839
  }
1764
- // The type triple leads the INSERT, mirroring `create`'s triple order.
1765
- const insertPatterns = (opts === null || opts === void 0 ? void 0 : opts.ensureType)
1766
- ? [
1767
- tripleOf(subjectTerm, iriTerm(RDF_TYPE), iriTerm(opts.ensureType)),
1768
- ...result.insertPatterns,
1769
- ]
1770
- : result.insertPatterns;
1771
- return {
1772
- type: 'delete_insert',
1773
- deletePatterns: result.deletePatterns,
1774
- insertPatterns,
1775
- whereAlgebra,
1776
- };
1840
+ return whereAlgebra;
1777
1841
  }
1778
1842
  // ---------------------------------------------------------------------------
1779
1843
  // Owned-subtree cascade
@@ -1833,8 +1897,11 @@ function collectContainment() {
1833
1897
  * Build DELETE patterns + WHERE OPTIONAL blocks that cascade-delete the owned subtree
1834
1898
  * reachable from `rootTerm` via `contains` edges. `varPrefix` keeps generated variables
1835
1899
  * unique across multiple roots in one query. Returns empty when nothing is owned.
1900
+ *
1901
+ * `anchor`, when given, leads every block: the triple that binds a variable root, so
1902
+ * each block stays correct on its own (as a UNION branch) when no such root exists.
1836
1903
  */
1837
- export function buildOwnedCascade(rootTerm, varPrefix) {
1904
+ export function buildOwnedCascade(rootTerm, varPrefix, anchor) {
1838
1905
  const { containsPreds, dependentTypes } = collectContainment();
1839
1906
  if (containsPreds.length === 0 || dependentTypes.length === 0) {
1840
1907
  return { deletePatterns: [], whereOptionals: [] };
@@ -1858,6 +1925,7 @@ export function buildOwnedCascade(rootTerm, varPrefix) {
1858
1925
  whereOptionals.push({
1859
1926
  type: 'bgp',
1860
1927
  triples: [
1928
+ ...(anchor ? [anchor] : []),
1861
1929
  tripleOf(rootTerm, pathTerm, owned),
1862
1930
  tripleOf(owned, iriTerm(RDF_TYPE), iriTerm(typeId)),
1863
1931
  tripleOf(owned, p, o),
@@ -1936,14 +2004,10 @@ export function deleteToAlgebra(query, _options) {
1936
2004
  blocks.push(...cascade.whereOptionals);
1937
2005
  }
1938
2006
  }
1939
- let union = blocks[0];
1940
- for (let i = 1; i < blocks.length; i++) {
1941
- union = { type: 'union', left: union, right: blocks[i] };
1942
- }
1943
2007
  const whereAlgebra = {
1944
2008
  type: 'join',
1945
2009
  left: { type: 'bgp', triples: guards },
1946
- right: union,
2010
+ right: unionOf(blocks),
1947
2011
  };
1948
2012
  return {
1949
2013
  type: 'delete_insert',
@@ -1968,63 +2032,63 @@ function isBlankNodeProperty(prop) {
1968
2032
  return nk === shacl.BlankNode.id || nk === shacl.BlankNodeOrIRI.id;
1969
2033
  }
1970
2034
  /**
1971
- * Recursively builds DELETE + WHERE patterns for blank-node-typed properties.
2035
+ * Builds DELETE patterns + one WHERE block per blank node reachable from `parentVar`
2036
+ * through blank-node-typed properties, recursing into each property's valueShape for
2037
+ * nested blank nodes (e.g. Person → Address (blank) → GeoPoint (blank)).
1972
2038
  *
1973
- * For each blank-node property on the shape:
1974
- * - DELETE: `?bnVar ?pN ?oN .` (wildcard all triples on the blank node)
1975
- * - WHERE: `OPTIONAL { ?parent <property> ?bnVar . FILTER(isBlank(?bnVar)) . ?bnVar ?pN ?oN . }`
2039
+ * - DELETE: `?bnN ?pN ?oN .` (wildcard all triples on the blank node)
2040
+ * - WHERE block: the path of edges from the root down to `?bnN`, `?bnN ?pN ?oN`, and
2041
+ * `FILTER(isBlank(…))` for every blank node on that path.
1976
2042
  *
1977
- * Recurses into the property's valueShape to handle nested blank nodes
1978
- * (e.g. Person → Address (blank) → GeoPoint (blank)).
2043
+ * Each block carries its own path from the root, so the blocks are independent of one
2044
+ * another and are joined with a UNION by the caller. Nested as OPTIONALs, a node's
2045
+ * triples multiplied with every blank node below and beside it.
1979
2046
  */
1980
- function walkBlankNodeTree(shapeId, parentVar, depth, deletePatterns) {
2047
+ function blankNodeBlocks(shapeId, parentVar, path, counter, deletePatterns, blocks) {
1981
2048
  var _a, _b, _c;
1982
2049
  const nodeShape = (_b = (_a = getShapeClass(shapeId)) === null || _a === void 0 ? void 0 : _a.shape) !== null && _b !== void 0 ? _b : getNodeShape(shapeId);
1983
2050
  if (!nodeShape)
1984
- return null;
1985
- let optionals = null;
2051
+ return;
1986
2052
  const props = getPropertyShapes(nodeShape, true);
1987
2053
  for (const prop of props) {
1988
2054
  if (!isBlankNodeProperty(prop))
1989
2055
  continue;
1990
- const bnVar = `bn${depth}`;
1991
- const pVar = `p${depth}`;
1992
- const oVar = `o${depth}`;
1993
- // DELETE pattern: wildcard all triples on the blank node
1994
- deletePatterns.push(tripleOf(varTerm(bnVar), varTerm(pVar), varTerm(oVar)));
1995
- // WHERE: parent --<property>--> ?bnVar
1996
- const traverseTriple = tripleOf(varTerm(parentVar), resolvePropertyPredicateTerm(prop.id), varTerm(bnVar));
1997
- // FILTER(isBlank(?bnVar))
1998
- const isBlankFilter = {
1999
- kind: 'function_expr',
2000
- name: 'isBlank',
2001
- args: [{ kind: 'variable_expr', name: bnVar }],
2002
- };
2003
- // ?bnVar ?pN ?oN
2004
- const wildcardTriple = tripleOf(varTerm(bnVar), varTerm(pVar), varTerm(oVar));
2005
- // Build inner pattern: traverse + filter + wildcard
2006
- let innerPattern = {
2007
- type: 'bgp',
2008
- triples: [traverseTriple, wildcardTriple],
2056
+ const n = counter.next++;
2057
+ const bnVar = `bn${n}`;
2058
+ const wildcardTriple = tripleOf(varTerm(bnVar), varTerm(`p${n}`), varTerm(`o${n}`));
2059
+ deletePatterns.push(wildcardTriple);
2060
+ const here = {
2061
+ triples: [
2062
+ ...path.triples,
2063
+ tripleOf(varTerm(parentVar), resolvePropertyPredicateTerm(prop.id), varTerm(bnVar)),
2064
+ ],
2065
+ bnVars: [...path.bnVars, bnVar],
2009
2066
  };
2010
- innerPattern = { type: 'filter', expression: isBlankFilter, inner: innerPattern };
2011
- // Recurse into valueShape for nested blank nodes
2012
- if ((_c = prop.valueShape) === null || _c === void 0 ? void 0 : _c.id) {
2013
- const nestedOptional = walkBlankNodeTree(prop.valueShape.id, bnVar, depth + 1, deletePatterns);
2014
- if (nestedOptional) {
2015
- innerPattern = { type: 'left_join', left: innerPattern, right: nestedOptional };
2016
- }
2017
- }
2018
- // Wrap in OPTIONAL (left_join)
2019
- if (optionals) {
2020
- optionals = { type: 'left_join', left: optionals, right: innerPattern };
2067
+ let block = { type: 'bgp', triples: [...here.triples, wildcardTriple] };
2068
+ for (const v of here.bnVars) {
2069
+ block = {
2070
+ type: 'filter',
2071
+ expression: { kind: 'function_expr', name: 'isBlank', args: [{ kind: 'variable_expr', name: v }] },
2072
+ inner: block,
2073
+ };
2021
2074
  }
2022
- else {
2023
- optionals = innerPattern;
2075
+ blocks.push(block);
2076
+ if ((_c = prop.valueShape) === null || _c === void 0 ? void 0 : _c.id) {
2077
+ blankNodeBlocks(prop.valueShape.id, bnVar, here, counter, deletePatterns, blocks);
2024
2078
  }
2025
- depth++;
2026
2079
  }
2027
- return optionals;
2080
+ }
2081
+ /**
2082
+ * The blank-node cleanup blocks of a bulk delete, already joined with the root's own
2083
+ * triples as ONE UNION so their matches add up instead of multiplying; `null` when the
2084
+ * shape has no blank-node property (the root wildcard then stays in the required BGP).
2085
+ */
2086
+ function bulkDeleteUnion(shapeId, subjectVar, rootWildcard, deletePatterns) {
2087
+ const blocks = [];
2088
+ blankNodeBlocks(shapeId, subjectVar, { triples: [], bnVars: [] }, { next: 1 }, deletePatterns, blocks);
2089
+ if (blocks.length === 0)
2090
+ return null;
2091
+ return unionOf([{ type: 'bgp', triples: [rootWildcard] }, ...blocks]);
2028
2092
  }
2029
2093
  /**
2030
2094
  * Converts an IRDeleteAllMutation to a SparqlDeleteInsertPlan.
@@ -2038,15 +2102,13 @@ export function deleteAllToAlgebra(query, _options) {
2038
2102
  const deletePatterns = [
2039
2103
  tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')),
2040
2104
  ];
2041
- // WHERE: type triple + root wildcard
2105
+ // WHERE: type triple, then the root wildcard and the blank-node cleanup
2042
2106
  const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(resolveShapeScanIri(query.shape)));
2043
2107
  const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o'));
2044
- let whereAlgebra = { type: 'bgp', triples: [typeTriple, rootWildcard] };
2045
- // Walk blank node tree for cleanup
2046
- const blankNodeOptional = walkBlankNodeTree(query.shape, subjectVar, 1, deletePatterns);
2047
- if (blankNodeOptional) {
2048
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: blankNodeOptional };
2049
- }
2108
+ const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, deletePatterns);
2109
+ const whereAlgebra = union
2110
+ ? { type: 'join', left: { type: 'bgp', triples: [typeTriple] }, right: union }
2111
+ : { type: 'bgp', triples: [typeTriple, rootWildcard] };
2050
2112
  return {
2051
2113
  type: 'delete_insert',
2052
2114
  deletePatterns,
@@ -2066,10 +2128,14 @@ export function deleteWhereToAlgebra(query, _options) {
2066
2128
  const deletePatterns = [
2067
2129
  tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')),
2068
2130
  ];
2069
- // WHERE: type triple + root wildcard
2131
+ // WHERE: type triple + the where clause; the root wildcard and blank-node cleanup follow
2070
2132
  const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(resolveShapeScanIri(query.shape)));
2071
2133
  const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o'));
2072
- let whereAlgebra = { type: 'bgp', triples: [typeTriple, rootWildcard] };
2134
+ const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, deletePatterns);
2135
+ let whereAlgebra = {
2136
+ type: 'bgp',
2137
+ triples: union ? [typeTriple] : [typeTriple, rootWildcard],
2138
+ };
2073
2139
  // Process where patterns (traversals from the where clause)
2074
2140
  const traverseTriples = [];
2075
2141
  const optionalPropertyTriples = [];
@@ -2089,10 +2155,8 @@ export function deleteWhereToAlgebra(query, _options) {
2089
2155
  // Convert and add filter expression
2090
2156
  const filterExpr = convertExpression(query.where, registry, []);
2091
2157
  whereAlgebra = { type: 'filter', expression: filterExpr, inner: whereAlgebra };
2092
- // Walk blank node tree for cleanup
2093
- const blankNodeOptional = walkBlankNodeTree(query.shape, subjectVar, 1, deletePatterns);
2094
- if (blankNodeOptional) {
2095
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: blankNodeOptional };
2158
+ if (union) {
2159
+ whereAlgebra = { type: 'join', left: whereAlgebra, right: union };
2096
2160
  }
2097
2161
  return {
2098
2162
  type: 'delete_insert',
@@ -2109,7 +2173,6 @@ export function deleteWhereToAlgebra(query, _options) {
2109
2173
  * filter conditions from the where clause.
2110
2174
  */
2111
2175
  export function updateWhereToAlgebra(query, options) {
2112
- var _a, _b, _c;
2113
2176
  const subjectTerm = varTerm('a0');
2114
2177
  const result = processUpdateFields(query.data, subjectTerm, options);
2115
2178
  // WHERE: type triple is always required
@@ -2134,62 +2197,11 @@ export function updateWhereToAlgebra(query, options) {
2134
2197
  whereAlgebra = { type: 'filter', expression: filterExpr, inner: whereAlgebra };
2135
2198
  }
2136
2199
  // Old-value triples anchored on a traversal *target* belong INSIDE that
2137
- // traversal's OPTIONAL group, not beside it. Emitted beside it, the leaf's
2138
- // subject variable is introduced by an OPTIONAL that shares no variable with
2139
- // anything to its left — a left join with no join condition, i.e. a cartesian
2140
- // product over every node in the store carrying that predicate. The following
2141
- // OPTIONAL cannot repair it: the variable is already bound, and OPTIONAL never
2142
- // removes rows. `updateToAlgebra` performs the same split; this path did not.
2143
- const travTos = new Set(((_a = query.traversalPatterns) !== null && _a !== void 0 ? _a : []).map((t) => t.to));
2144
- const travAnchoredByTo = new Map();
2145
- const subjectAnchored = [];
2146
- for (const triple of result.oldValueTriples) {
2147
- if (triple.subject.kind === 'variable' && travTos.has(triple.subject.name)) {
2148
- const list = (_b = travAnchoredByTo.get(triple.subject.name)) !== null && _b !== void 0 ? _b : [];
2149
- list.push(triple);
2150
- travAnchoredByTo.set(triple.subject.name, list);
2151
- }
2152
- else {
2153
- subjectAnchored.push(triple);
2154
- }
2155
- }
2156
- whereAlgebra = wrapOldValueOptionals(whereAlgebra, subjectAnchored);
2157
- // Add traversal OPTIONAL patterns (for multi-segment expression refs)
2158
- // These must come BEFORE expression BINDs since the BINDs reference traversal variables.
2159
- if (query.traversalPatterns) {
2160
- for (const trav of query.traversalPatterns) {
2161
- const fromTerm = trav.from === '__mutation_subject__' ? varTerm('a0') : varTerm(trav.from);
2162
- const traversalTriple = tripleOf(fromTerm,
2163
- // The declared `sh:path`, like every other predicate — not the property
2164
- // shape's own IRI, which identifies the description of the property.
2165
- resolvePropertyPredicateTerm(trav.property), varTerm(trav.to));
2166
- // The edge binds the target variable; each dependent leaf property is a
2167
- // nested OPTIONAL within that scope, so a missing leaf does not drop the
2168
- // group while an absent edge leaves every leaf variable unbound.
2169
- let travNode = { type: 'bgp', triples: [traversalTriple] };
2170
- for (const leaf of (_c = travAnchoredByTo.get(trav.to)) !== null && _c !== void 0 ? _c : []) {
2171
- travNode = {
2172
- type: 'left_join',
2173
- left: travNode,
2174
- right: { type: 'bgp', triples: [leaf] },
2175
- };
2176
- }
2177
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: travNode };
2178
- }
2179
- }
2180
- // Owned-subtree cascade OPTIONALs for replaced `contains` properties.
2181
- for (const optional of result.cascadeOptionals) {
2182
- whereAlgebra = { type: 'left_join', left: whereAlgebra, right: optional };
2183
- }
2184
- // Add BIND expressions for computed fields
2185
- for (const ext of result.extends) {
2186
- whereAlgebra = {
2187
- type: 'extend',
2188
- inner: whereAlgebra,
2189
- variable: ext.variable,
2190
- expression: ext.expression,
2191
- };
2192
- }
2200
+ // traversal's OPTIONAL group, not beside it: beside it, the leaf's subject variable
2201
+ // is introduced by an OPTIONAL that shares no variable with anything to its left — a
2202
+ // cartesian product over every node carrying that predicate. `buildUpdateWhere` does
2203
+ // that split for both update paths.
2204
+ whereAlgebra = buildUpdateWhere(whereAlgebra, result, query.traversalPatterns, subjectTerm);
2193
2205
  return {
2194
2206
  type: 'delete_insert',
2195
2207
  deletePatterns: result.deletePatterns,