From 130ecd7836870dba7d8f252199a1631bec400927 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 10:58:37 +0000 Subject: [PATCH 01/20] Update 007 ideation: revise for 2.0 API, add route options for 3 features Rewrote the ideation doc to reflect the current 2.0 codebase state (DeleteBuilder, UpdateBuilder, .for()/.forAll() patterns, existing NOT EXISTS support). Restructured into 3 features with multiple implementation routes: MINUS/NOT EXISTS, bulk delete, and conditional update. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/ideas/007-advanced-query-patterns.md | 382 +++++++++++++++------- 1 file changed, 268 insertions(+), 114 deletions(-) diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md index 131603a..5eebb99 100644 --- a/docs/ideas/007-advanced-query-patterns.md +++ b/docs/ideas/007-advanced-query-patterns.md @@ -1,174 +1,328 @@ -# Advanced Query Patterns (MINUS & DELETE WHERE) +# Advanced Query Patterns ## Summary -Add DSL support for two SPARQL 1.1 features: -1. **MINUS (set difference)** — Exclude results matching a pattern -2. **DELETE WHERE (bulk delete)** — Delete triples matching a pattern without a separate WHERE clause +Add DSL support for three features: +1. **MINUS / NOT EXISTS (set exclusion)** — Exclude results matching a pattern +2. **Bulk Delete (DELETE WHERE)** — Delete all entities of a type, or matching a condition +3. **Conditional Update (update().where())** — Update entities matching a condition without pre-fetching IDs -Both use algebra types already defined in `SparqlAlgebra.ts`. +All three build on algebra types already defined in `SparqlAlgebra.ts` and the existing builder pattern from 2.0. --- -## Part 1: MINUS (Set Difference) +## Codebase status (as of 2.0 pending changesets) -### Motivation +### What's changed since the original 007 draft -MINUS allows excluding results that match a secondary pattern. This is useful for "all X that are not Y" queries, which are common in data quality checks, access control filtering, and set operations. +| Area | Original assumption | Actual 2.0 state | +|------|---------------------|-------------------| +| `Shape.delete()` | Accepts `(id: string)` | Now accepts `(id: NodeId \| NodeId[] \| NodeReferenceValue[])`, returns `DeleteBuilder` | +| `Shape.update()` | Accepts `(id, data)` | Now accepts `(data?)`, returns `UpdateBuilder`; `.for(id)` is chained | +| `.for()` / `.forAll()` | Not discussed | `QueryBuilder` has both `.for(id)` and `.forAll(ids?)`. `UpdateBuilder` has `.for(id)` only (single). `DeleteBuilder` has `.for(ids)` (multi). | +| `.where()` | Not discussed | Exists on `QueryBuilder` only. Not on `UpdateBuilder` or `DeleteBuilder`. | +| `.deleteAll()` | Proposed | Does NOT exist yet | +| NOT EXISTS | Mentioned briefly | Fully working via `.every()` in the DSL, through `IRExistsExpression` + `IRNotExpression` | +| MINUS algebra | Proposed | `SparqlMinus` type + serialization exist. No IR node, no DSL method. | +| IR graph patterns | - | Union of: `shape_scan`, `traverse`, `join`, `optional`, `union`, `exists` | -### DSL examples +### Key architecture notes + +- **Builders are immutable** — every method returns a new builder instance via `.clone()` +- **PromiseLike** — all builders implement PromiseLike so `await Person.update({…}).for(id)` works +- **IR → Algebra → String** — three-layer pipeline: DSL → IR AST → SPARQL Algebra → SPARQL string +- **QueryDispatch** — mutations execute via `getQueryDispatch().deleteQuery(ir)` / `.updateQuery(ir)` + +--- + +## Feature 1: MINUS / NOT EXISTS (Set Exclusion) + +### Background: MINUS vs FILTER NOT EXISTS + +In SPARQL, these are *similar but not identical*: + +```sparql +-- MINUS: set difference based on shared variables +SELECT ?name WHERE { + ?s a ex:Person . ?s ex:name ?name . + MINUS { ?s a ex:Employee . } +} + +-- FILTER NOT EXISTS: filter that checks pattern non-existence +SELECT ?name WHERE { + ?s a ex:Person . ?s ex:name ?name . + FILTER NOT EXISTS { ?s a ex:Employee . } +} +``` + +**Key semantic difference:** MINUS computes set difference — if the MINUS pattern has *no variables in common* with the outer pattern, it excludes nothing (MINUS is a no-op). FILTER NOT EXISTS evaluates the inner pattern with variable bindings from the outer scope, so it always works as expected. + +**In practice:** For the common case (same subject variable), they produce identical results. They diverge only when variable scoping differs. Some SPARQL engines optimize one better than the other (e.g. Virtuoso prefers MINUS; Fuseki handles both well). + +### Current state + +- **NOT EXISTS** is already fully supported via `.every()` on QueryProxy properties → generates `FILTER NOT EXISTS { … }` +- **MINUS** has algebra + serialization support (`SparqlMinus`) but no IR pattern and no DSL method + +### Proposed DSL ```ts -// People who are NOT employees +// People who are NOT employees — exclude by type Person.select(p => p.name).minus(Employee) -// Generated SPARQL: -// SELECT ?name WHERE { -// ?s rdf:type ex:Person . -// OPTIONAL { ?s ex:name ?name . } -// MINUS { ?s rdf:type ex:Employee . } -// } +// Orders without a shippedDate — exclude by property +Order.select(o => o.id).minus(o => o.shippedDate) ``` +### Route A: Single `.minus()` method, always emit SPARQL MINUS + +Add `.minus()` to `QueryBuilder` that always generates the SPARQL `MINUS { … }` pattern. + +**Pros:** +- Simple 1:1 mapping from DSL to SPARQL +- MINUS is the more intuitive keyword for users ("all X minus Y") +- Straightforward to implement — new `IRMinusPattern`, convert to `SparqlMinus` + +**Cons:** +- MINUS has the variable-scoping gotcha (no shared vars = no exclusion) +- Some engines may optimize FILTER NOT EXISTS better + +**Implementation:** +- Add `IRMinusPattern` to `IRGraphPattern` union +- Add `.minus()` to `QueryBuilder` (accepts `ShapeConstructor` or property lambda) +- `irToAlgebra.ts`: convert `IRMinusPattern` → `SparqlMinus` + +### Route B: Single `.minus()` method, but emit FILTER NOT EXISTS under the hood + +Use the familiar `.minus()` name in the DSL, but generate `FILTER NOT EXISTS` in SPARQL since the semantics are more predictable. + +**Pros:** +- Avoids the variable-scoping pitfall +- NOT EXISTS already has full IR support (`IRExistsExpression` + `IRNotExpression`) +- Less new code — reuses existing pipeline + +**Cons:** +- DSL says "minus" but SPARQL says "NOT EXISTS" — could confuse users debugging SPARQL output +- Doesn't expose the actual MINUS pattern for users who specifically need it + +**Implementation:** +- Add `.minus()` to `QueryBuilder` +- Internally construct an `IRExistsExpression` wrapped in `IRNotExpression` +- No new IR types needed + +### Route C: `.minus()` emits MINUS, and document `.every()` as the NOT EXISTS equivalent + +Keep both patterns available. `.minus()` for SPARQL MINUS, `.every()` (already exists) for NOT EXISTS. Document the difference. + +**Pros:** +- Full SPARQL coverage — both patterns available +- No semantic mismatch between DSL name and generated SPARQL +- Power users can choose the right tool + +**Cons:** +- Two ways to do the same thing — could confuse beginners +- Need to document when to use which + +**Implementation:** +- Same as Route A (new `IRMinusPattern` etc.) +- Add documentation comparing `.minus()` vs `.every()` with negation + +### Route D: Skip `.minus()` entirely, rely on existing NOT EXISTS via `.where()` + +Since NOT EXISTS is already supported and covers the common cases, defer MINUS and instead document how to express exclusions with the existing `.where()` + `.every()` API. + +**Pros:** +- Zero new code +- Avoids API surface bloat + +**Cons:** +- `.every()` for negation isn't obvious — `.minus()` reads more naturally +- Doesn't expose the SPARQL MINUS pattern at all + +--- + +## Feature 2: Bulk Delete (DELETE WHERE) + +### Current state + +- `Shape.delete(ids)` → `DeleteBuilder.from(shape, ids)` → requires explicit IDs +- `DeleteBuilder.for(ids)` — chainable, but still requires IDs +- No way to delete "all entities of type X" or "entities matching condition Y" +- `SparqlDeleteWherePlan` algebra type exists and serializes correctly +- `deleteToAlgebra()` currently generates per-ID DELETE patterns with wildcard `?p ?o` + +### Proposed DSL + ```ts -// Orders that haven't been shipped (no shippedDate property) -Order.select(o => o.id).minus(o => o.shippedDate) +// Delete all temporary records +TempRecord.delete().all() +// or: TempRecord.deleteAll() -// Generated SPARQL: -// SELECT ?s WHERE { -// ?s rdf:type ex:Order . -// MINUS { ?s ex:shippedDate ?shipped . } -// } +// Delete inactive people (conditional) +Person.delete().where(p => p.status.equals('inactive')) ``` +~~`deleteProperty` is out of scope — property removal should use `.update()` with unset semantics.~~ + +### Route A: Extend `DeleteBuilder` with `.all()` and `.where()` + +Add new chainable methods to the existing `DeleteBuilder`: + ```ts -// People who don't have any pets -Person.select(p => p.name).minus(p => p.pets) - -// Generated SPARQL: -// SELECT ?name WHERE { -// ?s rdf:type ex:Person . -// OPTIONAL { ?s ex:name ?name . } -// MINUS { ?s ex:pets ?pets . } -// } +Person.delete().all() // delete all of type +Person.delete().where(p => p.status.equals('x')) // conditional delete +Person.delete('id-1') // existing by-ID (unchanged) ``` -### Algebra mapping +**Pros:** +- Consistent with existing builder pattern +- `.all()` and `.where()` are familiar from `QueryBuilder` +- Single entry point (`Shape.delete()`) with different chaining paths + +**Cons:** +- `Shape.delete()` currently requires IDs — making IDs optional is a breaking signature change +- Need to handle mutual exclusivity: `.for(ids)` vs `.all()` vs `.where()` can't be combined arbitrarily +- `.where()` on mutations is a new pattern — may need `WhereClause` type adapted for mutation context -Uses the existing `SparqlMinus` algebra node: +**Implementation:** +- Make `ids` optional in `DeleteBuilder.from()` +- Add `.all()` method (sets a flag, no IDs needed) +- Add `.where(fn)` method (stores a WhereClause) +- New IR variant: `IRDeleteWhereMutation` (kind: `'delete_where'`, shape, whereFn?) +- `irToAlgebra.ts`: generate `SparqlDeleteWherePlan` for `.all()`, or `SparqlDeleteInsertPlan` with WHERE filters for `.where()` + +### Route B: Separate static methods on Shape ```ts -type SparqlMinus = { - type: 'minus'; - left: SparqlAlgebraNode; - right: SparqlAlgebraNode; -}; +TempRecord.deleteAll() +Person.deleteWhere(p => p.status.equals('inactive')) ``` -Already serialized by `algebraToString.ts` as `left\nMINUS {\n right\n}`. +**Pros:** +- Clear distinction from ID-based `Shape.delete(id)` +- No need to make `delete()` overloaded +- Explicit naming prevents accidental bulk deletes -### MINUS vs NOT EXISTS +**Cons:** +- Adds more static methods to Shape class +- Less composable than builder pattern +- Inconsistent with the 2.0 builder approach (`.select()`, `.update()`, `.delete()`) -MINUS and NOT EXISTS (FILTER NOT EXISTS) serve similar purposes but have different semantics around unbound variables. The DSL should use MINUS for shape-level exclusion and NOT EXISTS for property-level checks. The existing `NOT EXISTS` support could be documented alongside MINUS to help users choose the right tool. +### Route C: Use `.delete()` + `.where()` only (no `.all()`) ---- +```ts +Person.delete().where(p => p.status.equals('inactive')) // conditional +Person.delete().where(() => true) // all (explicit) +// or: Person.delete().where() // all (no arg = match all) +``` + +**Pros:** +- Single new method (`.where()`) instead of two +- Forces user to think about what they're deleting +- `.where()` with no arg or always-true is explicit enough for "delete all" -## Part 2: DELETE WHERE (Bulk Delete) +**Cons:** +- "Delete all" is a common operation and `.where(() => true)` is awkward +- Missing a `.where()` call accidentally could be confusing (should it error or delete all?) -### Motivation +--- + +## Feature 3: Conditional Update (`update().where()`) -`DELETE WHERE` is a SPARQL shorthand where the delete pattern IS the where pattern. This avoids repeating patterns in both clauses. It's useful for: -- Bulk deletion of all entities of a type -- Removing all triples matching a simple pattern -- Cleaning up data without needing a separate WHERE clause +### Current state -Currently the DSL has `Person.delete(id)` which deletes a specific entity by ID. There's no way to delete multiple entities matching a pattern or delete all entities of a type. +- `Shape.update(data).for(id)` — updates a single, known entity +- No `.where()` on `UpdateBuilder` +- No `.forAll()` on `UpdateBuilder` (only on `QueryBuilder`) +- The SPARQL layer already generates `DELETE { old } INSERT { new } WHERE { match }` patterns -### DSL examples +### Proposed DSL ```ts -// Delete all temporary records -TempRecord.deleteAll() +// Set all inactive people's status to 'archived' +Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) -// Generated SPARQL: -// DELETE WHERE { -// ?s rdf:type ex:TempRecord . -// ?s ?p ?o . -// } +// Bulk update all entities of a type +Person.update({ verified: true }).all() ``` +### Route A: Add `.where()` and `.all()` to `UpdateBuilder` + ```ts -// Delete all triples of a specific property from all persons -Person.deleteProperty(p => p.temporaryFlag) - -// Generated SPARQL: -// DELETE WHERE { -// ?s rdf:type ex:Person . -// ?s ex:temporaryFlag ?val . -// } +Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) +Person.update({ verified: true }).all() +Person.update({ name: 'Bob' }).for('id-1') // existing (unchanged) ``` +**Pros:** +- Mirrors the pattern proposed for `DeleteBuilder` — consistent API +- Powerful — enables bulk updates without pre-fetching +- Natural SPARQL mapping to `DELETE/INSERT WHERE { … FILTER(…) }` + +**Cons:** +- **Significantly more complex** than delete — update needs to generate DELETE for old values AND INSERT for new values, all within a WHERE that also filters +- The current `updateToAlgebra()` assumes a single known entity ID — the WHERE pattern generation would need a fundamentally different approach +- `.where()` updates can't use OPTIONAL to handle missing old values the same way ID-based updates do +- Risk of unintended mass updates if `.where()` is too broad + +**Implementation:** +- Make `.for(id)` optional in `UpdateBuilder` +- Add `.where(fn)` and `.all()` methods +- New IR variant: `IRUpdateWhereMutation` (kind: `'update_where'`, shape, data, whereFn?) +- `irToAlgebra.ts`: generate new algebra plan for pattern-matched updates +- Need careful handling: for each field in `data`, generate DELETE for old value + INSERT for new value, within a WHERE that includes the filter condition + +### Route B: Separate `Shape.updateWhere()` static method + ```ts -// Delete entities matching a condition (combine with WHERE) -Person.deleteWhere(p => L.eq(p.status, 'inactive')) - -// Generated SPARQL: -// DELETE { -// ?s ?p ?o . -// } -// WHERE { -// ?s rdf:type ex:Person . -// ?s ex:status ?status . -// FILTER(?status = "inactive") -// ?s ?p ?o . -// } -// Note: This falls back to DELETE/INSERT/WHERE since it has a filter, -// but the DSL method makes the intent clear. +Person.updateWhere(p => p.status.equals('inactive'), { status: 'archived' }) ``` -### Algebra mapping +**Pros:** +- Clear separation from ID-based updates +- Harder to accidentally trigger + +**Cons:** +- Doesn't follow the builder pattern +- Less composable -Uses the existing `SparqlDeleteWherePlan`: +### Route C: Require two-step (select then update) + +Don't add `.where()` to `UpdateBuilder`. Instead, users fetch IDs first: ```ts -type SparqlDeleteWherePlan = { - type: 'delete_where'; - patterns: SparqlAlgebraNode; - graph?: string; -}; +const inactive = await Person.select(p => p.id).where(p => p.status.equals('inactive')) +await Promise.all(inactive.map(p => Person.update({ status: 'archived' }).for(p.id))) ``` -Already serialized by `algebraToString.ts` as `DELETE WHERE { ... }`. - -### When DELETE WHERE vs DELETE/INSERT/WHERE +**Pros:** +- Zero new mutation code +- Explicit, no surprises -| Scenario | Pattern | -|---|---| -| Delete entity by ID | Current `deleteToSparql` (DELETE/INSERT/WHERE) | -| Delete all entities of a type | `DELETE WHERE` | -| Delete specific property from entities | `DELETE WHERE` | -| Delete entities matching a filter | `DELETE { } WHERE { }` (not pure DELETE WHERE) | +**Cons:** +- N+1 problem — one update per entity +- Not atomic — race conditions between select and update +- Verbose for a common pattern --- -## Implementation considerations +## Open questions for discussion -### MINUS -- Add `.minus()` method to the query builder chain -- `.minus(ShapeClass)` generates MINUS with type guard triple -- `.minus(p => p.property)` generates MINUS with property triple -- IR needs a new `IRMinusPattern` graph pattern kind -- `irToAlgebra.ts` converts to `SparqlMinus` algebra node +### Feature 1: MINUS / NOT EXISTS +1. **Which route?** Given that NOT EXISTS already works via `.every()`, is `.minus()` worth adding? Or is it redundant API surface? +2. **If we add `.minus()`**, should it emit MINUS or NOT EXISTS? Does engine compatibility matter for your use cases? +3. **Should `.minus()` accept multiple patterns?** e.g. `.minus(Employee).minus(Contractor)` via chaining (which already works with immutable builders)? -### DELETE WHERE -- Add `.deleteAll()` and `.deleteProperty()` methods to the Shape class -- IR needs to distinguish between targeted delete (by ID) and pattern delete -- `irToAlgebra.ts` generates `SparqlDeleteWherePlan` for pattern-based deletes -- Result type for bulk deletes may differ from single-entity deletes (count only, no IDs) +### Feature 2: Bulk Delete +4. **Route A vs B vs C?** Extending `DeleteBuilder` with `.all()` + `.where()` (Route A) fits the 2.0 builder pattern best, but needs the signature change. Thoughts? +5. **Safety:** Should `.delete().all()` require an explicit opt-in (e.g. `.delete().all({ confirm: true })`) to prevent accidental mass deletes? +6. **Return type:** Should bulk delete return `{ deletedCount: number }` or just `void`? (SPARQL endpoints vary in what they report back.) -## Open questions +### Feature 3: Conditional Update +7. **Is this needed for 2.0?** It's the most complex of the three features. Should it be deferred? +8. **Route A vs C?** Route A (`.where()` on UpdateBuilder) is powerful but complex. Route C (select-then-update) works today with no code changes. Is the ergonomic gain worth the complexity? +9. **Field-level scoping:** For `Person.update({ status: 'archived' }).where(…)`, should the generated SPARQL delete *only* the `status` triples and insert new ones? Or should it touch all triples of matching entities? (The former is more surgical and safer.) -- Should `.minus()` accept multiple arguments (multiple MINUS clauses)? -- Should `.deleteAll()` require explicit confirmation / be marked as dangerous? -- How should `.deleteWhere()` interact with named graphs? -- Should bulk delete return a count of deleted triples, or is fire-and-forget acceptable? +### Cross-cutting +10. **Named graphs:** How should `.where()` and `.all()` interact with named graphs? Scope to default graph only? +11. **Priority order:** If we do all three, what's the implementation order? (Suggested: bulk delete → minus → conditional update, by ascending complexity.) From 0a8f85130b9ff41e3f766d97d9e710d7e1770b27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 11:52:20 +0000 Subject: [PATCH 02/20] Add plan for .minus() and .delete().where() - Implementation plan for SPARQL MINUS support on select queries - Implementation plan for where-clause-based delete mutations https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- plan.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..6a0ee00 --- /dev/null +++ b/plan.md @@ -0,0 +1,98 @@ +# Plan: `.minus()` on select queries + `.delete().where()` + +## 1. `.minus()` — Exclude results matching a pattern + +### DSL (proposed) + +```ts +// "Select persons who are NOT friends with someone named 'Moa'" +Person.select().minus((p) => p.friends.some((f) => f.name.equals('Moa'))) + +// Equivalent to .where() but negated — reuses the same WhereClause callback +``` + +### Generated SPARQL + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + MINUS { + ?a0 ?a1 . + ?a1 "Moa" . + } +} +``` + +### Implementation + +| Layer | File | Change | +|-------|------|--------| +| **DSL entry** | `SelectQuery.ts` | Add `.minus(callback)` method on `QueryProxy` / builder, stores a `WhereClause` marked as minus | +| **IR** | `IntermediateRepresentation.ts` | Add `IRMinusPattern` type: `{ kind: 'minus', pattern: IRTraversePattern[], filter?: IRExpression }` | +| **IR Desugar** | `IRDesugar.ts` | Process minus clauses the same way as where clauses but tag them as minus | +| **IR Canonicalize** | `IRCanonicalize.ts` | New canonical node `where_minus` wrapping the inner pattern | +| **IR Lower** | `IRLower.ts` | Lower `where_minus` → `minus_expr` in the IR plan | +| **Algebra** | `SparqlAlgebra.ts` | Add `SparqlMinus` node type: `{ type: 'minus', left: SparqlAlgebraNode, right: SparqlAlgebraNode }` | +| **irToAlgebra** | `irToAlgebra.ts` | Convert `minus_expr` → `SparqlMinus` | +| **algebraToString** | `algebraToString.ts` | Serialize `SparqlMinus` as `MINUS { … }` block | +| **Tests** | `query-fixtures.ts`, golden tests | Add fixtures + golden SPARQL assertions | + +### Key design decisions + +- `.minus()` takes the same `WhereClause` callback as `.where()`, so users already know the API +- Unlike `.where(NOT EXISTS {…})`, SPARQL `MINUS` does not share variable bindings — it's a **set difference**. This is the correct semantic for "exclude matching shapes" +- `.minus()` can be chained: `Person.select().where(…).minus(…).minus(…)` + +--- + +## 2. `.delete().where()` — Delete by query instead of by ID + +### DSL (proposed) + +```ts +// Delete all persons named 'Moa' +Person.delete().where((p) => p.name.equals('Moa')) + +// Delete friends of a specific person +Person.delete().where((p) => p.friends.some((f) => f.name.equals('Jinx'))) +``` + +### Generated SPARQL + +```sparql +DELETE { + ?a0 ?p ?o . + ?s ?p2 ?a0 . + ?a0 rdf:type . +} +WHERE { + ?a0 rdf:type . + ?a0 "Moa" . + ?a0 ?p ?o . + OPTIONAL { ?s ?p2 ?a0 . } +} +``` + +### Implementation + +| Layer | File | Change | +|-------|------|--------| +| **Builder** | `DeleteBuilder.ts` | Add `.where(callback)` method that stores a `WhereClause`, make `.for()` OR `.where()` required (not both) | +| **IR** | `IntermediateRepresentation.ts` | Extend `IRDeleteMutation` with optional `where?: CanonicalWhereExpression` and remove `ids` requirement (make it `ids?: …`) | +| **DeleteQuery** | `DeleteQuery.ts` | Add `DeleteWhereQueryFactory` that builds delete IR from a where clause instead of IDs | +| **irToAlgebra** | `irToAlgebra.ts` | Update `deleteToAlgebra` to handle where-based deletes: generate WHERE block from the where expression instead of fixed ID patterns | +| **Tests** | fixtures + golden tests | Add `deleteWhere` fixtures | + +### Key design decisions + +- `.for(ids)` and `.where(callback)` are mutually exclusive — `.build()` throws if both or neither are specified +- The cascade pattern (delete outgoing + incoming + type) is preserved, but subjects come from the WHERE match instead of literal IRIs +- This reuses the existing select-query WHERE pipeline (desugar → canonicalize → lower) so all `.equals()`, `.some()`, `.every()` predicates work inside `.delete().where()` + +--- + +## Phase order + +1. **Phase 1: `.minus()` on select** — self-contained, new node type through the full pipeline +2. **Phase 2: `.delete().where()`** — builds on existing where infrastructure, extends DeleteBuilder From e8f8989f44a95e8f55396093166ca123376d933a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 23:20:55 +0000 Subject: [PATCH 03/20] Record Feature 1 decision: Route A for .minus() with SPARQL MINUS - Accepts ShapeConstructor or WhereClause (same callback types as .select()) - Supports shape, single/multi property, boolean, and nested callbacks - Reuses existing callback processing, new IRMinusPattern through pipeline - Chainable: .minus(A).minus(B) produces separate MINUS blocks https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/ideas/007-advanced-query-patterns.md | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md index 5eebb99..4c7eba9 100644 --- a/docs/ideas/007-advanced-query-patterns.md +++ b/docs/ideas/007-advanced-query-patterns.md @@ -139,6 +139,34 @@ Since NOT EXISTS is already supported and covers the common cases, defer MINUS a - `.every()` for negation isn't obvious — `.minus()` reads more naturally - Doesn't expose the SPARQL MINUS pattern at all +### Decision: Route A — emit SPARQL MINUS + +**Chosen:** Route A with extended callback support. + +`.minus()` accepts `ShapeConstructor` or `WhereClause` (same callback types as `.select()`): + +```ts +// By shape +Person.select(p => p.name).minus(Employee) + +// Single property existence +Order.select(o => o.id).minus(o => o.shippedDate) + +// Multi property (AND — both must exist) +Person.select(p => p.name).minus(p => [p.email, p.phone]) + +// Boolean condition +Person.select(p => p.name).minus(p => p.status.equals('inactive')) + +// Nested +Person.select(p => p.name).minus(p => p.friends.some(f => f.name.equals('Moa'))) +``` + +**Implementation:** +- Reuses existing callback processing from `.select()` — only the Shape overload is new +- New `IRMinusPattern` through the pipeline, lands on existing `SparqlMinus` algebra + serialization +- Chainable: `.minus(A).minus(B)` produces two separate `MINUS { }` blocks + --- ## Feature 2: Bulk Delete (DELETE WHERE) From 1b67553193f2175947e2bf73fa5ca59f1275e422 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 23:59:18 +0000 Subject: [PATCH 04/20] Record Feature 2 decision: schema-aware bulk delete with blank node cleanup - .deleteAll() for bulk, .delete().where(cb) for conditional, .deleteWhere() as sugar - Returns void, no safety gate needed - Schema-aware blank node cleanup using explicit property paths from shape tree - FILTER(isBlank()) for sh:BlankNodeOrIRI properties - Recursive depth determined at codegen by walking shape tree https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/ideas/007-advanced-query-patterns.md | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md index 4c7eba9..6f21950 100644 --- a/docs/ideas/007-advanced-query-patterns.md +++ b/docs/ideas/007-advanced-query-patterns.md @@ -253,6 +253,47 @@ Person.delete().where(() => true) // all (explicit) - "Delete all" is a common operation and `.where(() => true)` is awkward - Missing a `.where()` call accidentally could be confusing (should it error or delete all?) +### Decision: `.deleteAll()` + `.delete().where()`, schema-aware blank node cleanup + +**Chosen:** Hybrid of Route A and B. + +- `Shape.deleteAll()` — explicit bulk delete, no safety gate needed +- `Shape.delete().where(cb)` — conditional delete +- `Shape.deleteWhere(cb)` — optional sugar for `.delete().where(cb)` +- `Shape.delete(id)` — existing by-ID (unchanged) +- Returns `void` + +**SPARQL generation — schema-aware blank node cleanup:** + +Uses explicit property paths from the shape tree to navigate to blank nodes, then wildcards their properties. Recursively walks as deep as blank-node-typed properties nest. `FILTER(isBlank())` always present (essential for `sh:BlankNodeOrIRI`). + +Example for `Person` with `address: BlankNode → Address { street, city, geo: BlankNodeOrIRI → GeoPoint { lat, lon } }`: + +```sparql +DELETE { + ?a0 ?p ?o . + ?addr ?p1 ?o1 . + ?geo ?p2 ?o2 . +} +WHERE { + ?a0 a . + ?a0 ?p ?o . + OPTIONAL { + ?a0
?addr . FILTER(isBlank(?addr)) . + ?addr ?p1 ?o1 . + OPTIONAL { + ?addr ?geo . FILTER(isBlank(?geo)) . + ?geo ?p2 ?o2 . + } + } +} +``` + +- Root: `?a0 ?p ?o` catches everything including `rdf:type` — no need for explicit `?a0 a ` in DELETE +- Blank node traversal: explicit property paths (`
`, ``) — efficient, no scanning +- Blank node cleanup: `?addr ?p1 ?o1` wildcard — catches all properties on the blank node +- Recursion depth: determined at codegen by walking the shape tree + --- ## Feature 3: Conditional Update (`update().where()`) From fedee4d4fd2f1cd51c1e852e0e9db8cfeb51d494 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:07:14 +0000 Subject: [PATCH 05/20] Record Feature 3 decision: .update().where() + .forAll() on UpdateBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thin layer over existing DELETE/INSERT/WHERE SPARQL generation - Swap hardcoded entity IRI for variable, add type triple + filter conditions - Field-level scoping: only touches fields in update data - .forAll() keeps OPTIONAL for old bindings; .where() drops it (filter implies existence) - No .updateWhere() sugar — just .update().where() https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/ideas/007-advanced-query-patterns.md | 81 +++++++++++++---------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md index 6f21950..2aaa68e 100644 --- a/docs/ideas/007-advanced-query-patterns.md +++ b/docs/ideas/007-advanced-query-patterns.md @@ -341,57 +341,70 @@ Person.update({ name: 'Bob' }).for('id-1') // existing (unchanged) - `irToAlgebra.ts`: generate new algebra plan for pattern-matched updates - Need careful handling: for each field in `data`, generate DELETE for old value + INSERT for new value, within a WHERE that includes the filter condition -### Route B: Separate `Shape.updateWhere()` static method +### Decision: `.update().where()` + `.forAll()` — thin layer over existing SPARQL generation -```ts -Person.updateWhere(p => p.status.equals('inactive'), { status: 'archived' }) -``` +**Chosen:** Route A — add `.where()` and `.forAll()` to `UpdateBuilder`. No `.updateWhere()` sugar. -**Pros:** -- Clear separation from ID-based updates -- Harder to accidentally trigger +- `Person.update(data).where(cb)` — conditional update +- `Person.update(data).forAll()` — bulk update all instances of type +- `Person.update(data).for(id)` — existing by-ID (unchanged) -**Cons:** -- Doesn't follow the builder pattern -- Less composable +**Key insight:** The existing `updateToAlgebra()` already generates the full `DELETE { old } INSERT { new } WHERE { OPTIONAL { old bindings } }` pattern. Conditional update is a thin addition — swap the hardcoded `` subject for a `?a0` variable, add `?a0 a ` to WHERE, and append the filter conditions from the `.where()` callback. -### Route C: Require two-step (select then update) +**Field-level scoping:** Only touches fields in the update data — surgical. Does NOT delete/reinsert unrelated triples. -Don't add `.where()` to `UpdateBuilder`. Instead, users fetch IDs first: +**Example — `.where()`:** ```ts -const inactive = await Person.select(p => p.id).where(p => p.status.equals('inactive')) -await Promise.all(inactive.map(p => Person.update({ status: 'archived' }).for(p.id))) +Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) ``` -**Pros:** -- Zero new mutation code -- Explicit, no surprises +```sparql +DELETE { ?a0 ?old_status . } +INSERT { ?a0 "archived" . } +WHERE { + ?a0 a . + ?a0 ?old_status . + FILTER(?old_status = "inactive") +} +``` -**Cons:** -- N+1 problem — one update per entity -- Not atomic — race conditions between select and update -- Verbose for a common pattern +**Example — `.forAll()`:** + +```ts +Person.update({ verified: true }).forAll() +``` + +```sparql +DELETE { ?a0 ?old_verified . } +INSERT { ?a0 true . } +WHERE { + ?a0 a . + OPTIONAL { ?a0 ?old_verified . } +} +``` + +Note: `.forAll()` keeps the OPTIONAL for old value bindings (entity may not have the field yet). `.where()` drops the OPTIONAL since the filter condition implies the field exists. + +**Implementation:** +- Add `.where(fn)` and `.forAll()` to `UpdateBuilder` +- Make `.for(id)` optional (require one of `.for()`, `.forAll()`, or `.where()` before `.build()`) +- New IR variant: `IRUpdateWhereMutation` (kind: `'update_where'`, shape, data, whereFn?) +- `updateToAlgebra`: parameterize subject — `iriTerm(id)` for `.for()`, variable for `.where()`/`.forAll()`, add type triple + filter conditions to WHERE --- -## Open questions for discussion +## Open questions (resolved) ### Feature 1: MINUS / NOT EXISTS -1. **Which route?** Given that NOT EXISTS already works via `.every()`, is `.minus()` worth adding? Or is it redundant API surface? -2. **If we add `.minus()`**, should it emit MINUS or NOT EXISTS? Does engine compatibility matter for your use cases? -3. **Should `.minus()` accept multiple patterns?** e.g. `.minus(Employee).minus(Contractor)` via chaining (which already works with immutable builders)? +- **Chosen:** Route A with extended callback support — `.minus()` emitting SPARQL `MINUS` ### Feature 2: Bulk Delete -4. **Route A vs B vs C?** Extending `DeleteBuilder` with `.all()` + `.where()` (Route A) fits the 2.0 builder pattern best, but needs the signature change. Thoughts? -5. **Safety:** Should `.delete().all()` require an explicit opt-in (e.g. `.delete().all({ confirm: true })`) to prevent accidental mass deletes? -6. **Return type:** Should bulk delete return `{ deletedCount: number }` or just `void`? (SPARQL endpoints vary in what they report back.) +- **Chosen:** `.deleteAll()` + `.delete().where()` — schema-aware blank node cleanup, returns `void` ### Feature 3: Conditional Update -7. **Is this needed for 2.0?** It's the most complex of the three features. Should it be deferred? -8. **Route A vs C?** Route A (`.where()` on UpdateBuilder) is powerful but complex. Route C (select-then-update) works today with no code changes. Is the ergonomic gain worth the complexity? -9. **Field-level scoping:** For `Person.update({ status: 'archived' }).where(…)`, should the generated SPARQL delete *only* the `status` triples and insert new ones? Or should it touch all triples of matching entities? (The former is more surgical and safer.) +- **Chosen:** `.update().where()` + `.forAll()` — thin layer over existing SPARQL generation, field-level scoping -### Cross-cutting -10. **Named graphs:** How should `.where()` and `.all()` interact with named graphs? Scope to default graph only? -11. **Priority order:** If we do all three, what's the implementation order? (Suggested: bulk delete → minus → conditional update, by ascending complexity.) +### Cross-cutting (still open) +1. **Named graphs:** How should `.where()` and `.all()`/`.forAll()` interact with named graphs? Scope to default graph only? +2. **Priority order:** Suggested: bulk delete → minus → conditional update, by ascending complexity. From 87803f1b290016a1a81841dd77c0b86221fa02f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:23:34 +0000 Subject: [PATCH 06/20] Resolve cross-cutting questions: defer named graphs, order TBD in planning https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/ideas/007-advanced-query-patterns.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md index 2aaa68e..12fdbdf 100644 --- a/docs/ideas/007-advanced-query-patterns.md +++ b/docs/ideas/007-advanced-query-patterns.md @@ -405,6 +405,6 @@ Note: `.forAll()` keeps the OPTIONAL for old value bindings (entity may not have ### Feature 3: Conditional Update - **Chosen:** `.update().where()` + `.forAll()` — thin layer over existing SPARQL generation, field-level scoping -### Cross-cutting (still open) -1. **Named graphs:** How should `.where()` and `.all()`/`.forAll()` interact with named graphs? Scope to default graph only? -2. **Priority order:** Suggested: bulk delete → minus → conditional update, by ascending complexity. +### Cross-cutting (resolved) +1. **Named graphs:** Deferred — not in scope for now. +2. **Priority order:** To be determined during planning. From 291e4dc845b6129d4d63b26acf425fdcf74f3d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:30:24 +0000 Subject: [PATCH 07/20] Add implementation plan for advanced query patterns (MINUS, bulk delete, conditional update) Covers all three features from ideation with inter-component contracts, file change matrix, IR types, algebra conversion signatures, and pitfalls. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 500 ++++++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/plans/001-advanced-query-patterns.md diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md new file mode 100644 index 0000000..ad88031 --- /dev/null +++ b/docs/plans/001-advanced-query-patterns.md @@ -0,0 +1,500 @@ +# Plan: Advanced Query Patterns + +Implements three features from [ideation doc](../ideas/007-advanced-query-patterns.md): +1. **MINUS** — `.minus()` on `QueryBuilder` +2. **Bulk Delete** — `.deleteAll()` + `.delete().where()` +3. **Conditional Update** — `.update().where()` + `.update().forAll()` + +Named graphs: deferred (out of scope). + +--- + +## Architecture Overview + +All three features follow the same pipeline: + +``` +DSL (Builder) → IR AST → SPARQL Algebra → SPARQL String +``` + +Each feature adds: +- **Builder method(s)** — new chainable methods on existing builders +- **IR type(s)** — new variant(s) in the IR union +- **Algebra conversion** — new case(s) in `irToAlgebra.ts` +- **Serialization** — reuses existing `algebraToString.ts` (no changes needed for any feature) + +### Files that change + +| File | F1 | F2 | F3 | +|------|----|----|-----| +| `src/queries/QueryBuilder.ts` | `.minus()` | | | +| `src/queries/DeleteBuilder.ts` | | `.where()`, `.all()` | | +| `src/queries/UpdateBuilder.ts` | | | `.where()`, `.forAll()` | +| `src/shapes/Shape.ts` | | `.deleteAll()`, `.deleteWhere()` | | +| `src/queries/IntermediateRepresentation.ts` | `IRMinusPattern` | `IRDeleteWhereMutation`, `IRDeleteAllMutation` | `IRUpdateWhereMutation` | +| `src/queries/IRMutation.ts` | | `buildCanonicalDeleteWhereMutationIR`, `buildCanonicalDeleteAllMutationIR` | `buildCanonicalUpdateWhereMutationIR` | +| `src/sparql/irToAlgebra.ts` | minus in `selectToAlgebra` | `deleteWhereToAlgebra`, `deleteAllToAlgebra` | `updateWhereToAlgebra` | +| `src/queries/queryDispatch.ts` | | new dispatch methods | new dispatch methods | +| `src/queries/DeleteQuery.ts` | | new factory methods | | +| `src/queries/UpdateQuery.ts` | | | new factory methods | +| `src/tests/sparql-mutation-golden.test.ts` | | golden tests | golden tests | +| `src/tests/sparql-select-golden.test.ts` | golden tests | | | +| `src/tests/mutation-builder.test.ts` | | builder equiv tests | builder equiv tests | + +--- + +## Feature 1: MINUS (`QueryBuilder.minus()`) + +### DSL + +```ts +// By shape — exclude entities that are also of another type +Person.select(p => p.name).minus(Employee) + +// By property existence +Order.select(o => o.id).minus(o => o.shippedDate) + +// By condition +Person.select(p => p.name).minus(p => p.status.equals('inactive')) + +// Chained — produces two separate MINUS { } blocks +Person.select(p => p.name).minus(Employee).minus(Contractor) +``` + +### IR Contract + +```ts +// New pattern added to IRGraphPattern union +export type IRMinusPattern = { + kind: 'minus'; + pattern: IRGraphPattern; // The pattern to subtract + filter?: IRExpression; // Optional filter within the MINUS block +}; + +// Updated union: +export type IRGraphPattern = + | IRShapeScanPattern + | IRTraversePattern + | IRJoinPattern + | IROptionalPattern + | IRUnionPattern + | IRExistsPattern + | IRMinusPattern; // ← new +``` + +### Builder → IR + +`QueryBuilder.minus()` accepts: +- `ShapeConstructor` — creates an `IRShapeScanPattern` for the shape's type triple +- `WhereClause` callback — reuses `processWhereClause()` to produce `IRTraversePattern` + `IRExpression` + +Stored as `_minusPatterns: IRMinusPattern[]` on the builder. Each `.minus()` call appends to the array (immutable clone). + +In `build()`, minus patterns are added to `IRSelectQuery.patterns[]`. + +### Algebra Conversion (`selectToAlgebra`) + +When processing `IRSelectQuery.patterns`, an `IRMinusPattern` converts to: + +```ts +// Wraps current algebra in SparqlMinus +algebra = { + type: 'minus', + left: algebra, // everything so far + right: minusAlgebra, // the MINUS block's content +} satisfies SparqlMinus; +``` + +The right side is built by converting the inner `IRGraphPattern` + optional `IRExpression` to algebra, same as any other pattern. + +### Serialization + +Already exists in `algebraToString.ts`: +```ts +case 'minus': + return `${left}\nMINUS {\n${indent(right)}\n}`; +``` + +No changes needed. + +### SPARQL output + +```sparql +SELECT ?name WHERE { + ?a0 a . + ?a0 ?name . + MINUS { + ?a0 a . + } +} +``` + +--- + +## Feature 2: Bulk Delete + +### DSL + +```ts +// Delete all instances of a type +TempRecord.deleteAll() // static sugar +TempRecord.delete().all() // builder equivalent + +// Conditional delete +Person.delete().where(p => p.status.equals('inactive')) +Person.deleteWhere(p => p.status.equals('inactive')) // static sugar + +// Existing by-ID (unchanged) +Person.delete('id-1') +``` + +### IR Contract + +```ts +// New mutation types +export type IRDeleteAllMutation = { + kind: 'delete_all'; + shape: string; // shape IRI +}; + +export type IRDeleteWhereMutation = { + kind: 'delete_where'; + shape: string; // shape IRI + where: IRExpression; // filter condition from callback + wherePatterns: IRGraphPattern[]; // traverse patterns needed by the filter +}; + +// Note: both need the shape ID to: +// 1. Generate `?a0 a ` in WHERE +// 2. Walk the shape tree for blank node cleanup +``` + +### Builder changes (`DeleteBuilder`) + +New fields on `DeleteBuilderInit`: +```ts +interface DeleteBuilderInit { + shape: ShapeConstructor; + ids?: NodeId[]; + mode?: 'ids' | 'all' | 'where'; // mutual exclusivity + whereFn?: WhereClause; +} +``` + +New methods: +```ts +all(): DeleteBuilder // sets mode = 'all' +where(fn: WhereClause): DeleteBuilder // sets mode = 'where', stores fn +``` + +`build()` dispatches by mode: +- `'ids'` (default) → existing `DeleteQueryFactory` +- `'all'` → `buildCanonicalDeleteAllMutationIR()` +- `'where'` → `buildCanonicalDeleteWhereMutationIR()` + +### Shape static methods + +```ts +// Shape.ts additions +static deleteAll(this: ShapeConstructor): DeleteBuilder { + return DeleteBuilder.from(this).all(); +} + +static deleteWhere( + this: ShapeConstructor, + fn: WhereClause, +): DeleteBuilder { + return DeleteBuilder.from(this).where(fn); +} +``` + +`Shape.delete()` signature unchanged — still requires IDs. `Shape.delete()` with no args is NOT supported (prevents accidental bulk delete). + +### Algebra Conversion + +#### `deleteAllToAlgebra(ir: IRDeleteAllMutation)` → `SparqlDeleteInsertPlan` + +Walks the shape tree to generate schema-aware blank node cleanup: + +```ts +function deleteAllToAlgebra( + ir: IRDeleteAllMutation, + options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const shape = resolveShapeById(ir.shape); + + // 1. Root: ?a0 ?p ?o (catches all triples including rdf:type) + const deletePatterns = [triple(var('a0'), var('p'), var('o'))]; + const whereRequired = [ + triple(var('a0'), iriTerm(rdf.type), iriTerm(ir.shape)), + triple(var('a0'), var('p'), var('o')), + ]; + + // 2. Walk shape tree for blank node properties + const optionalBlocks = walkBlankNodeTree(shape, 'a0', deletePatterns); + + // 3. Build WHERE: required BGP + nested OPTIONALs for blank nodes + let whereAlgebra = buildWhereWithOptionals(whereRequired, optionalBlocks); + + return { + type: 'delete_insert', + deletePatterns, + insertPatterns: [], + whereAlgebra, + }; +} +``` + +#### `walkBlankNodeTree(shape, parentVar, deletePatterns)` — recursive helper + +```ts +// For each property with nodeKind containing BlankNode: +// 1. Add to deletePatterns: ?bN ?pN ?oN +// 2. Create OPTIONAL block: { ?parent ?bN . FILTER(isBlank(?bN)) . ?bN ?pN ?oN } +// 3. If property has valueShape, recurse into that shape +// Returns array of optional blocks to nest +``` + +Uses existing APIs: +- `NodeShape.getPropertyShapes(true)` — all properties including inherited +- `PropertyShape.nodeKind` + `nodeKindToAtomics()` — detect blank node properties +- `PropertyShape.valueShape` + `getShapeClass()` — follow nested shapes + +#### `deleteWhereToAlgebra(ir: IRDeleteWhereMutation)` → `SparqlDeleteInsertPlan` + +Same blank node cleanup as `deleteAllToAlgebra`, plus filter conditions appended to WHERE: + +```ts +function deleteWhereToAlgebra( + ir: IRDeleteWhereMutation, + options?: SparqlOptions, +): SparqlDeleteInsertPlan { + // Same as deleteAll, but wrap whereAlgebra in SparqlFilter + // with the converted IRExpression from ir.where +} +``` + +### SPARQL output — `.deleteAll()` + +```sparql +DELETE { + ?a0 ?p ?o . + ?addr ?p1 ?o1 . +} +WHERE { + ?a0 a . + ?a0 ?p ?o . + OPTIONAL { + ?a0
?addr . FILTER(isBlank(?addr)) . + ?addr ?p1 ?o1 . + } +} +``` + +### SPARQL output — `.delete().where()` + +```sparql +DELETE { + ?a0 ?p ?o . +} +WHERE { + ?a0 a . + ?a0 ?p ?o . + ?a0 ?status . + FILTER(?status = "inactive") +} +``` + +### QueryDispatch + +`deleteQuery()` currently accepts `DeleteQuery` (which is `IRDeleteMutation`). Need to widen the type: + +```ts +export type DeleteQuery = IRDeleteMutation | IRDeleteAllMutation | IRDeleteWhereMutation; +``` + +The dispatch implementation routes by `kind`: +- `'delete'` → existing `deleteToAlgebra` → `deleteInsertPlanToSparql` +- `'delete_all'` → `deleteAllToAlgebra` → `deleteInsertPlanToSparql` +- `'delete_where'` → `deleteWhereToAlgebra` → `deleteInsertPlanToSparql` + +--- + +## Feature 3: Conditional Update + +### DSL + +```ts +// Conditional update +Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) + +// Bulk update all instances +Person.update({ verified: true }).forAll() + +// Existing by-ID (unchanged) +Person.update({ name: 'Bob' }).for('id-1') +``` + +### IR Contract + +```ts +export type IRUpdateWhereMutation = { + kind: 'update_where'; + shape: string; // shape IRI + data: IRNodeData; // same data format as IRUpdateMutation + where?: IRExpression; // filter condition (absent for forAll) + wherePatterns?: IRGraphPattern[]; // traverse patterns needed by the filter +}; +``` + +### Builder changes (`UpdateBuilder`) + +New fields on `UpdateBuilderInit`: +```ts +interface UpdateBuilderInit { + shape: ShapeConstructor; + data?: UpdatePartial; + targetId?: string; + mode?: 'id' | 'all' | 'where'; // mutual exclusivity + whereFn?: WhereClause; +} +``` + +New methods: +```ts +forAll(): UpdateBuilder // sets mode = 'all' +where(fn: WhereClause): UpdateBuilder // sets mode = 'where', stores fn +``` + +`build()` dispatches by mode: +- `'id'` (default) → existing `UpdateQueryFactory` +- `'all'` or `'where'` → `buildCanonicalUpdateWhereMutationIR()` + +### Algebra Conversion + +#### `updateWhereToAlgebra(ir: IRUpdateWhereMutation)` → `SparqlDeleteInsertPlan` + +Key insight: reuses the same field-level DELETE/INSERT logic from existing `updateToAlgebra()`, but parameterizes the subject. + +```ts +function updateWhereToAlgebra( + ir: IRUpdateWhereMutation, + options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const subjectVar = variable('a0'); + + // 1. Type triple in WHERE: ?a0 a + const typeTriple = triple(subjectVar, iriTerm(rdf.type), iriTerm(ir.shape)); + + // 2. For each field in ir.data, generate: + // DELETE: ?a0 ?old_property . + // INSERT: ?a0 "newValue" . + // WHERE: ?a0 ?old_property . (OPTIONAL for forAll, required for where) + + // 3. If ir.where exists, convert to SparqlFilter wrapping WHERE + // If ir.where absent (forAll), wrap old-value bindings in OPTIONAL + + // 4. Return SparqlDeleteInsertPlan +} +``` + +The field processing logic should be extracted from `updateToAlgebra()` into a shared helper that takes a subject term (either IRI or variable). + +### SPARQL output — `.where()` + +```sparql +DELETE { ?a0 ?old_status . } +INSERT { ?a0 "archived" . } +WHERE { + ?a0 a . + ?a0 ?old_status . + FILTER(?old_status = "inactive") +} +``` + +### SPARQL output — `.forAll()` + +```sparql +DELETE { ?a0 ?old_verified . } +INSERT { ?a0 true . } +WHERE { + ?a0 a . + OPTIONAL { ?a0 ?old_verified . } +} +``` + +### QueryDispatch + +`updateQuery()` type widens: + +```ts +export type UpdateQuery = IRUpdateMutation | IRUpdateWhereMutation; +``` + +Dispatch routes by `kind`: +- `'update'` → existing `updateToAlgebra` → `deleteInsertPlanToSparql` +- `'update_where'` → `updateWhereToAlgebra` → `deleteInsertPlanToSparql` + +--- + +## Inter-Component Contracts + +### IR ↔ Algebra boundary + +New conversion functions exported from `irToAlgebra.ts`: + +```ts +export function deleteAllToAlgebra(ir: IRDeleteAllMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; +export function deleteWhereToAlgebra(ir: IRDeleteWhereMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; +export function updateWhereToAlgebra(ir: IRUpdateWhereMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; +``` + +All return `SparqlDeleteInsertPlan` — reuses existing `deleteInsertPlanToSparql()` serialization. + +### Builder ↔ IR boundary + +Builders produce IR via factory functions in `IRMutation.ts`: + +```ts +export function buildCanonicalDeleteAllMutationIR(input: { shape: NodeShape }): IRDeleteAllMutation; +export function buildCanonicalDeleteWhereMutationIR(input: { shape: NodeShape; where: ... }): IRDeleteWhereMutation; +export function buildCanonicalUpdateWhereMutationIR(input: { shape: NodeShape; data: ...; where?: ... }): IRUpdateWhereMutation; +``` + +### WHERE clause reuse + +Both `DeleteBuilder.where()` and `UpdateBuilder.where()` accept `WhereClause` — the same type used by `QueryBuilder.where()`. Processing uses the existing `processWhereClause()` from `SelectQuery.ts`. + +### Shared blank node tree walker + +New utility for Feature 2, potentially reusable: + +```ts +// irToAlgebra.ts or new utility file +function walkBlankNodeTree( + shape: NodeShape, + parentVar: string, + deletePatterns: SparqlTriple[], + depth?: number, +): OptionalBlock[]; +``` + +--- + +## Potential Pitfalls + +1. **WHERE callback in mutation context**: `processWhereClause()` currently creates a proxy via `createProxiedPathBuilder(shape)`. This works because it only needs the shape definition, not a query context. Should work as-is for mutations, but needs verification. + +2. **Variable naming conflicts**: `updateWhereToAlgebra` uses `?a0` as subject (matching query convention) and `?old_*` for old values (matching existing update convention). These must not collide with variables generated by WHERE filter processing. + +3. **Blank node cleanup depth**: Recursive shape tree walking could theoretically be unbounded if shapes have circular references. Should cap recursion depth (e.g., 10 levels) with a clear error. + +4. **`updateToAlgebra` refactoring**: Extracting field processing into a shared helper is the riskiest change — it touches working code. The existing tests in `sparql-mutation-golden.test.ts` provide a safety net, but should be run after every refactor step. + +5. **DeleteBuilder.from() signature**: Currently `from(shape, ids?)` — making `ids` truly optional means `DeleteBuilder.from(Shape)` returns a builder with no IDs and no mode set. `build()` must validate that exactly one of `ids`/`all`/`where` is specified. + +--- + +## Remaining Decisions + +None — all feature decisions are recorded in the ideation doc. Implementation order will be determined in tasks mode. From a39f13835483e969604f3bc027dd490fabfdf723 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:36:23 +0000 Subject: [PATCH 08/20] Phase 1: Add IR types for MINUS, bulk delete, and conditional update - IRMinusPattern added to IRGraphPattern union - IRDeleteAllMutation, IRDeleteWhereMutation, IRUpdateWhereMutation types - Canonical IR builder functions for new mutation types - Widened DeleteQuery and UpdateQuery type unions - SparqlStore dispatches by kind with stubs for new mutation types https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 162 +++++++++++++++++++++- src/queries/DeleteQuery.ts | 8 +- src/queries/IRMutation.ts | 57 ++++++++ src/queries/IntermediateRepresentation.ts | 38 ++++- src/queries/UpdateQuery.ts | 4 +- src/sparql/SparqlStore.ts | 6 + 6 files changed, 267 insertions(+), 8 deletions(-) diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index ad88031..6712562 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -495,6 +495,164 @@ function walkBlankNodeTree( --- -## Remaining Decisions +## Implementation Phases -None — all feature decisions are recorded in the ideation doc. Implementation order will be determined in tasks mode. +### Dependency graph + +``` +Phase 1 (IR types) + ├── Phase 2 (MINUS) ─┐ + ├── Phase 3 (Bulk Delete) ├── Phase 5 (Integration) + └── Phase 4 (Cond. Update) ─┘ +``` + +Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three touch `irToAlgebra.ts` (different sections). To avoid merge conflicts on shared files, execute sequentially: 1 → 2 → 3 → 4 → 5. + +--- + +### Phase 1: IR Types & Contracts — COMPLETED + +**Goal:** Add all new IR types, mutation type unions, and canonical IR builder stubs. No logic — types only. Unblocks all subsequent phases. + +**Status:** Done. `tsc --noEmit` passes. All 633 tests pass. + +**Files:** +- `src/queries/IntermediateRepresentation.ts` — add `IRMinusPattern` to `IRGraphPattern` union +- `src/queries/IRMutation.ts` — add `IRDeleteAllMutation`, `IRDeleteWhereMutation`, `IRUpdateWhereMutation` types + stub `buildCanonical*` functions +- `src/queries/DeleteQuery.ts` — widen `DeleteQuery` type union +- `src/queries/UpdateQuery.ts` — widen `UpdateQuery` type union + +**Tasks:** +1. Add `IRMinusPattern` type and extend `IRGraphPattern` union. +2. Add `IRDeleteAllMutation`, `IRDeleteWhereMutation`, `IRUpdateWhereMutation` types. +3. Add stub `buildCanonicalDeleteAllMutationIR()`, `buildCanonicalDeleteWhereMutationIR()`, `buildCanonicalUpdateWhereMutationIR()` — return correct typed objects from input params. +4. Widen `DeleteQuery` to `IRDeleteMutation | IRDeleteAllMutation | IRDeleteWhereMutation`. +5. Widen `UpdateQuery` to `IRUpdateMutation | IRUpdateWhereMutation`. + +**Validation:** +- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 +- `npm test` — all existing tests still pass (no regressions) + +--- + +### Phase 2: MINUS on QueryBuilder + +**Goal:** Full `.minus()` support: builder method → IR → algebra → SPARQL string. + +**Files:** +- `src/queries/QueryBuilder.ts` — add `.minus()` method +- `src/sparql/irToAlgebra.ts` — handle `IRMinusPattern` in `selectToAlgebra` +- `src/test-helpers/query-fixtures.ts` — add minus fixture factories +- `src/tests/sparql-select-golden.test.ts` — add golden tests + +**Tasks:** +1. Add `.minus()` method to `QueryBuilder` accepting `ShapeConstructor | WhereClause`. Store as `_minusPatterns` array on builder init. Clone appends. +2. In `build()` / `buildSelectQuery()`, convert minus patterns to `IRMinusPattern` entries in `IRSelectQuery.patterns[]`. +3. In `selectToAlgebra`, add case for `'minus'` pattern kind: wrap current algebra in `SparqlMinus { left, right }`. +4. Add query fixture factories: `minusShape`, `minusProperty`, `minusCondition`, `minusChained`. +5. Add golden tests asserting exact SPARQL output. + +**Fixtures & golden tests:** + +| Fixture | DSL | Expected SPARQL contains | +|---------|-----|--------------------------| +| `minusShape` | `Person.select(p => p.name).minus(Employee)` | `MINUS { ?a0 a . }` | +| `minusProperty` | `Person.select(p => p.name).minus(p => p.hobby)` | `MINUS { ?a0 ?a0_hobby . }` | +| `minusCondition` | `Person.select(p => p.name).minus(p => p.hobby.equals('Chess'))` | `MINUS { ?a0 ?a0_hobby . FILTER(?a0_hobby = "Chess") }` | +| `minusChained` | `Person.select(p => p.name).minus(Employee).minus(p => p.hobby)` | Two separate `MINUS { }` blocks | + +**Validation:** +- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 +- `npm test` — all existing tests pass + 4 new minus golden tests pass +- Assert each golden test uses exact `toBe` matching on full SPARQL string + +--- + +### Phase 3: Bulk Delete + +**Goal:** `.deleteAll()`, `.delete().all()`, `.delete().where()`, `.deleteWhere()` — full pipeline. + +**Files:** +- `src/queries/DeleteBuilder.ts` — add `mode`, `whereFn`, `.all()`, `.where()` methods, dispatch in `build()` +- `src/shapes/Shape.ts` — add `deleteAll()`, `deleteWhere()` static methods +- `src/queries/IRMutation.ts` — implement `buildCanonicalDeleteAllMutationIR()`, `buildCanonicalDeleteWhereMutationIR()` (replace stubs) +- `src/sparql/irToAlgebra.ts` — add `deleteAllToAlgebra()`, `deleteWhereToAlgebra()`, `walkBlankNodeTree()` helper, export `deleteAllToSparql()`, `deleteWhereToSparql()` convenience wrappers +- `src/test-helpers/query-fixtures.ts` — add delete fixture factories +- `src/tests/sparql-mutation-golden.test.ts` — add golden tests + +**Tasks:** +1. Add `mode` and `whereFn` to `DeleteBuilderInit`. Add `.all()` and `.where(fn)` methods. +2. Update `build()` to dispatch by mode: `'ids'` → existing factory, `'all'` → `buildCanonicalDeleteAllMutationIR`, `'where'` → `buildCanonicalDeleteWhereMutationIR`. Validate mutual exclusivity. +3. Add `Shape.deleteAll()` and `Shape.deleteWhere(fn)` static methods. +4. Implement `buildCanonicalDeleteAllMutationIR()` — returns `{ kind: 'delete_all', shape: shape.id }`. +5. Implement `buildCanonicalDeleteWhereMutationIR()` — processes WHERE callback via `processWhereClause`, converts to IR expressions/patterns. +6. Implement `deleteAllToAlgebra()` with `walkBlankNodeTree()` for schema-aware blank node cleanup. +7. Implement `deleteWhereToAlgebra()` — same base as deleteAll + filter conditions from WHERE. +8. Add convenience wrappers `deleteAllToSparql()`, `deleteWhereToSparql()`. +9. Add fixtures and golden tests. + +**Fixtures & golden tests:** + +| Fixture | DSL | Key assertions | +|---------|-----|----------------| +| `deleteAll` | `Person.deleteAll()` | `DELETE { ?a0 ?p ?o . }` + `WHERE { ?a0 a . ?a0 ?p ?o . }` | +| `deleteAllBuilder` | `Person.delete().all()` | Same SPARQL as `deleteAll` (builder equivalence) | +| `deleteWhere` | `Person.delete().where(p => p.hobby.equals('Chess'))` | `FILTER` with hobby equals in WHERE | +| `deleteWhereSugar` | `Person.deleteWhere(p => p.hobby.equals('Chess'))` | Same SPARQL as `deleteWhere` | + +**Validation:** +- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 +- `npm test` — all existing tests pass + new delete golden tests pass +- Assert `deleteAll` and `deleteAllBuilder` produce identical SPARQL +- Assert `deleteWhere` and `deleteWhereSugar` produce identical SPARQL + +--- + +### Phase 4: Conditional Update + +**Goal:** `.update().where()` and `.update().forAll()` — full pipeline. + +**Files:** +- `src/queries/UpdateBuilder.ts` — add `mode`, `whereFn`, `.forAll()`, `.where()` methods, dispatch in `build()` +- `src/queries/IRMutation.ts` — implement `buildCanonicalUpdateWhereMutationIR()` (replace stub) +- `src/sparql/irToAlgebra.ts` — add `updateWhereToAlgebra()`, extract shared field processing helper from `updateToAlgebra()`, export `updateWhereToSparql()` convenience wrapper +- `src/test-helpers/query-fixtures.ts` — add update fixture factories +- `src/tests/sparql-mutation-golden.test.ts` — add golden tests + +**Tasks:** +1. Add `mode` and `whereFn` to `UpdateBuilderInit`. Add `.forAll()` and `.where(fn)` methods. +2. Update `build()` to dispatch by mode: `'id'` → existing factory, `'all'`/`'where'` → `buildCanonicalUpdateWhereMutationIR`. Validate: require data via `.set()` before `.forAll()`/`.where()`. +3. Implement `buildCanonicalUpdateWhereMutationIR()` — processes WHERE callback, builds IR. +4. Extract shared field processing from `updateToAlgebra()` into a helper that takes a subject term (IRI or variable). Ensure existing `updateToAlgebra()` calls the helper (refactor, not rewrite). Run existing tests after this step. +5. Implement `updateWhereToAlgebra()` using the shared helper with `?a0` variable subject + type triple + filter. +6. Add convenience wrapper `updateWhereToSparql()`. +7. Add fixtures and golden tests. + +**Fixtures & golden tests:** + +| Fixture | DSL | Key assertions | +|---------|-----|----------------| +| `updateWhere` | `Person.update({hobby: 'Chess'}).where(p => p.hobby.equals('Jogging'))` | `DELETE { ?a0 ?old_hobby . }` + `INSERT { ?a0 "Chess" . }` + `FILTER` in WHERE | +| `updateForAll` | `Person.update({hobby: 'Chess'}).forAll()` | Same DELETE/INSERT + `OPTIONAL` for old binding in WHERE, no FILTER | +| `updateWhereMultiField` | `Person.update({hobby: 'Chess', name: 'Bob'}).where(p => p.hobby.equals('Jogging'))` | Two DELETE + two INSERT + FILTER | + +**Validation:** +- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 +- `npm test` — ALL existing tests pass (critical: refactored `updateToAlgebra` must not regress) + new update golden tests pass +- After step 4 (refactor), run `npm test` before proceeding — this is the safety gate + +--- + +### Phase 5: Integration Verification + +**Goal:** Full compile, full test suite, verify all features work together. + +**Tasks:** +1. Run `npm run compile` — both CJS and ESM must succeed. +2. Run `npm test` — full test suite, 0 failures. +3. Verify barrel exports: new types and functions are importable from the package entry point if applicable. + +**Validation:** +- `npm run compile` exits 0 +- `npm test` exits 0 with 0 failures +- No TypeScript errors in any configuration diff --git a/src/queries/DeleteQuery.ts b/src/queries/DeleteQuery.ts index 8c74d18..548f77d 100644 --- a/src/queries/DeleteQuery.ts +++ b/src/queries/DeleteQuery.ts @@ -1,14 +1,18 @@ import {Shape, ShapeConstructor} from '../shapes/Shape.js'; import {NodeReferenceValue, UpdatePartial} from './QueryFactory.js'; import {MutationQueryFactory, NodeId} from './MutationQuery.js'; -import {IRDeleteMutation} from './IntermediateRepresentation.js'; +import { + IRDeleteMutation, + IRDeleteAllMutation, + IRDeleteWhereMutation, +} from './IntermediateRepresentation.js'; import {buildCanonicalDeleteMutationIR} from './IRMutation.js'; /** * The canonical DeleteQuery type — an IR AST node representing a delete mutation. * This is the type received by IQuadStore.deleteQuery(). */ -export type DeleteQuery = IRDeleteMutation; +export type DeleteQuery = IRDeleteMutation | IRDeleteAllMutation | IRDeleteWhereMutation; export type DeleteResponse = { /** diff --git a/src/queries/IRMutation.ts b/src/queries/IRMutation.ts index 5ba8ff9..7089704 100644 --- a/src/queries/IRMutation.ts +++ b/src/queries/IRMutation.ts @@ -10,11 +10,16 @@ import { import { IRCreateMutation, IRDeleteMutation, + IRDeleteAllMutation, + IRDeleteWhereMutation, + IRUpdateWhereMutation, IRFieldValue, IRNodeData, IRFieldUpdate, IRSetModificationValue, IRUpdateMutation, + IRExpression, + IRGraphPattern, } from './IntermediateRepresentation.js'; type CreateMutationInput = { @@ -127,3 +132,55 @@ export const buildCanonicalDeleteMutationIR = ( ids: query.ids.map((id) => ({id: id.id})), }; }; + +type DeleteAllMutationInput = { + shape: NodeShape; +}; + +/** Builds an IRDeleteAllMutation — delete all instances of a shape type. */ +export const buildCanonicalDeleteAllMutationIR = ( + query: DeleteAllMutationInput, +): IRDeleteAllMutation => { + return { + kind: 'delete_all', + shape: query.shape.id, + }; +}; + +type DeleteWhereMutationInput = { + shape: NodeShape; + where: IRExpression; + wherePatterns: IRGraphPattern[]; +}; + +/** Builds an IRDeleteWhereMutation — delete instances matching a condition. */ +export const buildCanonicalDeleteWhereMutationIR = ( + query: DeleteWhereMutationInput, +): IRDeleteWhereMutation => { + return { + kind: 'delete_where', + shape: query.shape.id, + where: query.where, + wherePatterns: query.wherePatterns, + }; +}; + +type UpdateWhereMutationInput = { + shape: NodeShape; + updates: NodeDescriptionValue; + where?: IRExpression; + wherePatterns?: IRGraphPattern[]; +}; + +/** Builds an IRUpdateWhereMutation — update instances matching a condition or all. */ +export const buildCanonicalUpdateWhereMutationIR = ( + query: UpdateWhereMutationInput, +): IRUpdateWhereMutation => { + return { + kind: 'update_where', + shape: query.shape.id, + data: toNodeData(query.updates), + where: query.where, + wherePatterns: query.wherePatterns, + }; +}; diff --git a/src/queries/IntermediateRepresentation.ts b/src/queries/IntermediateRepresentation.ts index 793866d..4f7be8f 100644 --- a/src/queries/IntermediateRepresentation.ts +++ b/src/queries/IntermediateRepresentation.ts @@ -5,7 +5,14 @@ export type IRAlias = string; export type IRValue = string | number | boolean | null; -export type IRQuery = IRSelectQuery | IRCreateMutation | IRUpdateMutation | IRDeleteMutation; +export type IRQuery = + | IRSelectQuery + | IRCreateMutation + | IRUpdateMutation + | IRDeleteMutation + | IRDeleteAllMutation + | IRDeleteWhereMutation + | IRUpdateWhereMutation; export type IRSelectQuery = { kind: 'select'; @@ -43,7 +50,8 @@ export type IRGraphPattern = | IRJoinPattern | IROptionalPattern | IRUnionPattern - | IRExistsPattern; + | IRExistsPattern + | IRMinusPattern; export type IRShapeScanPattern = { kind: 'shape_scan'; @@ -79,6 +87,12 @@ export type IRExistsPattern = { pattern: IRGraphPattern; }; +export type IRMinusPattern = { + kind: 'minus'; + pattern: IRGraphPattern; + filter?: IRExpression; +}; + export type IRExpression = | IRLiteralExpression | IRReferenceExpression @@ -184,6 +198,26 @@ export type IRDeleteMutation = { ids: NodeReferenceValue[]; }; +export type IRDeleteAllMutation = { + kind: 'delete_all'; + shape: string; +}; + +export type IRDeleteWhereMutation = { + kind: 'delete_where'; + shape: string; + where: IRExpression; + wherePatterns: IRGraphPattern[]; +}; + +export type IRUpdateWhereMutation = { + kind: 'update_where'; + shape: string; + data: IRNodeData; + where?: IRExpression; + wherePatterns?: IRGraphPattern[]; +}; + export type IRNodeData = { shape: string; fields: IRFieldUpdate[]; diff --git a/src/queries/UpdateQuery.ts b/src/queries/UpdateQuery.ts index 2803eeb..be19af1 100644 --- a/src/queries/UpdateQuery.ts +++ b/src/queries/UpdateQuery.ts @@ -7,14 +7,14 @@ import { toNodeReference, } from './QueryFactory.js'; import {MutationQueryFactory} from './MutationQuery.js'; -import {IRUpdateMutation} from './IntermediateRepresentation.js'; +import {IRUpdateMutation, IRUpdateWhereMutation} from './IntermediateRepresentation.js'; import {buildCanonicalUpdateMutationIR} from './IRMutation.js'; /** * The canonical UpdateQuery type — an IR AST node representing an update mutation. * This is the type received by IQuadStore.updateQuery(). */ -export type UpdateQuery = IRUpdateMutation; +export type UpdateQuery = IRUpdateMutation | IRUpdateWhereMutation; export class UpdateQueryFactory< ShapeType extends Shape, diff --git a/src/sparql/SparqlStore.ts b/src/sparql/SparqlStore.ts index 505548f..6349ed4 100644 --- a/src/sparql/SparqlStore.ts +++ b/src/sparql/SparqlStore.ts @@ -84,12 +84,18 @@ export abstract class SparqlStore implements IQuadStore { } async updateQuery(query: UpdateQuery): Promise { + if (query.kind === 'update_where') { + throw new Error('update_where is not yet implemented in SparqlStore'); + } const sparql = updateToSparql(query, this.options); await this.executeSparqlUpdate(sparql); return mapSparqlUpdateResult(query); } async deleteQuery(query: DeleteQuery): Promise { + if (query.kind === 'delete_all' || query.kind === 'delete_where') { + throw new Error(`${query.kind} is not yet implemented in SparqlStore`); + } const sparql = deleteToSparql(query, this.options); await this.executeSparqlUpdate(sparql); return { From c5e5afb3e03577262dd049b809dbc87243142099 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:46:12 +0000 Subject: [PATCH 09/20] Phase 2: Implement .minus() on QueryBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryBuilder.minus(ShapeConstructor | WhereClause) with immutable chaining - IRMinusPattern threaded through desugar → canonicalize → lower pipeline - selectToAlgebra wraps algebra in SparqlMinus nodes - Supports shape exclusion, condition-based exclusion, and chained MINUS blocks - 3 new golden tests: minusShape, minusCondition, minusChained https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 4 +- src/queries/IRCanonicalize.ts | 13 ++++++- src/queries/IRDesugar.ts | 18 +++++++++ src/queries/IRLower.ts | 45 ++++++++++++++++++++++- src/queries/QueryBuilder.ts | 42 +++++++++++++++++++++ src/sparql/irToAlgebra.ts | 27 ++++++++++++++ src/test-helpers/query-fixtures.ts | 14 +++++++ src/tests/sparql-select-golden.test.ts | 29 +++++++++++++++ 8 files changed, 189 insertions(+), 3 deletions(-) diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index 6712562..9226c7f 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -535,10 +535,12 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to --- -### Phase 2: MINUS on QueryBuilder +### Phase 2: MINUS on QueryBuilder — COMPLETED **Goal:** Full `.minus()` support: builder method → IR → algebra → SPARQL string. +**Status:** Done. 3 new golden tests pass. All 636 tests pass. + **Files:** - `src/queries/QueryBuilder.ts` — add `.minus()` method - `src/sparql/irToAlgebra.ts` — handle `IRMinusPattern` in `selectToAlgebra` diff --git a/src/queries/IRCanonicalize.ts b/src/queries/IRCanonicalize.ts index 759776c..aaa5893 100644 --- a/src/queries/IRCanonicalize.ts +++ b/src/queries/IRCanonicalize.ts @@ -38,10 +38,17 @@ export type CanonicalWhereExpression = | CanonicalWhereExists | CanonicalWhereNot; -export type CanonicalDesugaredSelectQuery = Omit & { +/** A canonicalized MINUS entry. */ +export type CanonicalMinusEntry = { + shapeId?: string; where?: CanonicalWhereExpression; }; +export type CanonicalDesugaredSelectQuery = Omit & { + where?: CanonicalWhereExpression; + minusEntries?: CanonicalMinusEntry[]; +}; + const toComparison = ( comparison: DesugaredWhereComparison, ): CanonicalWhereComparison => { @@ -192,5 +199,9 @@ export const canonicalizeDesugaredSelectQuery = ( return { ...query, where: query.where ? canonicalizeWhere(query.where) : undefined, + minusEntries: query.minusEntries?.map((entry) => ({ + shapeId: entry.shapeId, + where: entry.where ? canonicalizeWhere(entry.where) : undefined, + })), }; }; diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index 8f267bc..c4aafa8 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -18,6 +18,12 @@ import type {PropertyShape} from '../shapes/SHACL.js'; /** * Pipeline input type — accepts FieldSet entries directly. */ +/** A raw MINUS entry before desugaring. */ +export type RawMinusEntry = { + shapeId?: string; + where?: WherePath; +}; + export type RawSelectInput = { entries: readonly FieldSetEntry[]; where?: WherePath; @@ -28,6 +34,7 @@ export type RawSelectInput = { limit?: number; offset?: number; singleResult?: boolean; + minusEntries?: RawMinusEntry[]; }; export type DesugaredPropertyStep = { @@ -118,6 +125,12 @@ export type DesugaredWhereArg = } | DesugaredWhere; +/** A desugared MINUS entry. */ +export type DesugaredMinusEntry = { + shapeId?: string; + where?: DesugaredWhere; +}; + export type DesugaredSelectQuery = { kind: 'desugared_select'; shapeId?: string; @@ -129,6 +142,7 @@ export type DesugaredSelectQuery = { selections: DesugaredSelection[]; sortBy?: DesugaredSortBy; where?: DesugaredWhere; + minusEntries?: DesugaredMinusEntry[]; }; const isShapeRef = (value: unknown): value is ShapeReferenceValue => @@ -411,5 +425,9 @@ export const desugarSelectQuery = (query: RawSelectInput): DesugaredSelectQuery selections, sortBy: toSortBy(query), where: query.where ? toWhere(query.where) : undefined, + minusEntries: query.minusEntries?.map((entry) => ({ + shapeId: entry.shapeId, + where: entry.where ? toWhere(entry.where) : undefined, + })), }; }; diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 9154f92..17dd31b 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -357,10 +357,53 @@ export const lowerSelectQuery = ( })) : undefined; + // Lower MINUS entries → IRMinusPattern objects + const minusPatterns: IRGraphPattern[] = []; + if (canonical.minusEntries) { + for (const entry of canonical.minusEntries) { + if (entry.shapeId) { + // Shape exclusion: MINUS { ?a0 a } + minusPatterns.push({ + kind: 'minus', + pattern: {kind: 'shape_scan', shape: entry.shapeId, alias: ctx.rootAlias}, + }); + } else if (entry.where) { + // Condition-based exclusion: MINUS { ?a0 ?val . FILTER(...) } + const minusTraversals: IRTraversePattern[] = []; + const localTraversalMap = new Map(); + const minusResolveTraversal = (fromAlias: string, propertyShapeId: string): string => { + const key = `${fromAlias}:${propertyShapeId}`; + const existing = localTraversalMap.get(key); + if (existing) return existing; + const toAlias = ctx.generateAlias(); + minusTraversals.push({ + kind: 'traverse', + from: fromAlias, + to: toAlias, + property: propertyShapeId, + }); + localTraversalMap.set(key, toAlias); + return toAlias; + }; + const minusOptions: PathLoweringOptions = { + rootAlias: ctx.rootAlias, + resolveTraversal: minusResolveTraversal, + }; + const filter = lowerWhere(entry.where, ctx, minusOptions); + const innerPattern: IRGraphPattern = minusTraversals.length === 1 + ? minusTraversals[0] + : minusTraversals.length > 1 + ? {kind: 'join', patterns: minusTraversals} + : {kind: 'shape_scan', shape: canonical.shapeId || '', alias: ctx.rootAlias}; + minusPatterns.push({kind: 'minus', pattern: innerPattern, filter}); + } + } + } + return { kind: 'select', root, - patterns: ctx.getPatterns(), + patterns: [...ctx.getPatterns(), ...minusPatterns], projection, where, orderBy, diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index 3a10739..700b62d 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -36,6 +36,12 @@ interface PreloadEntry { component: QueryComponentLike; } +/** A MINUS entry — either a shape type exclusion or a WHERE-clause condition. */ +interface MinusEntry { + shapeId?: string; + whereFn?: WhereClause; +} + /** Internal state bag for QueryBuilder. */ interface QueryBuilderInit { shape: ShapeConstructor; @@ -51,6 +57,7 @@ interface QueryBuilderInit { selectAllLabels?: string[]; fieldSet?: FieldSet; preloads?: PreloadEntry[]; + minusEntries?: MinusEntry[]; } /** @@ -82,6 +89,7 @@ export class QueryBuilder private readonly _selectAllLabels?: string[]; private readonly _fieldSet?: FieldSet; private readonly _preloads?: PreloadEntry[]; + private readonly _minusEntries?: MinusEntry[]; private constructor(init: QueryBuilderInit) { this._shape = init.shape; @@ -97,6 +105,7 @@ export class QueryBuilder this._selectAllLabels = init.selectAllLabels; this._fieldSet = init.fieldSet; this._preloads = init.preloads; + this._minusEntries = init.minusEntries; } /** Create a shallow clone with overrides. */ @@ -115,6 +124,7 @@ export class QueryBuilder selectAllLabels: this._selectAllLabels, fieldSet: this._fieldSet, preloads: this._preloads, + minusEntries: this._minusEntries, ...overrides, }); } @@ -175,6 +185,25 @@ export class QueryBuilder return this.clone({whereFn: fn}); } + /** + * Exclude results matching a MINUS pattern. + * + * Accepts a shape constructor (exclude by type) or a WHERE callback (exclude by condition). + * Chainable: `.minus(A).minus(B)` produces two separate `MINUS { }` blocks. + */ + minus(shapeOrFn: ShapeConstructor | WhereClause): QueryBuilder { + const entry: MinusEntry = {}; + if (typeof shapeOrFn === 'function' && 'shape' in shapeOrFn) { + // ShapeConstructor — has a static .shape property + entry.shapeId = (shapeOrFn as ShapeConstructor).shape?.id; + } else { + // WhereClause callback + entry.whereFn = shapeOrFn as WhereClause; + } + const existing = this._minusEntries || []; + return this.clone({minusEntries: [...existing, entry]}); + } + /** Set sort order. */ orderBy(fn: QueryBuildFn, direction: 'ASC' | 'DESC' = 'ASC'): QueryBuilder { return this.clone({sortByFn: fn as any, sortDirection: direction}); @@ -434,6 +463,19 @@ export class QueryBuilder input.subjects = this._subjects; } + // Process minus entries → convert callbacks to WherePaths + if (this._minusEntries && this._minusEntries.length > 0) { + input.minusEntries = this._minusEntries.map((entry) => { + if (entry.shapeId) { + return {shapeId: entry.shapeId}; + } + if (entry.whereFn) { + return {where: processWhereClause(entry.whereFn, this._shape)}; + } + return {}; + }); + } + return input; } diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index e892a70..5c9d3b3 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -321,6 +321,24 @@ export function selectToAlgebra( } } + // 5b. MINUS patterns — wrap algebra in SparqlMinus for each minus pattern + for (const pattern of query.patterns) { + if (pattern.kind === 'minus') { + let minusAlgebra = convertExistsPattern(pattern.pattern, registry); + if (pattern.filter) { + const minusPropertyTriples: SparqlTriple[] = []; + processExpressionForProperties(pattern.filter, registry, minusPropertyTriples); + // Add property triples into the MINUS block + if (minusPropertyTriples.length > 0) { + minusAlgebra = joinNodes(minusAlgebra, {type: 'bgp', triples: minusPropertyTriples}); + } + const filterExpr = convertExpression(pattern.filter, registry, minusPropertyTriples); + minusAlgebra = {type: 'filter', expression: filterExpr, inner: minusAlgebra}; + } + algebra = {type: 'minus', left: algebra, right: minusAlgebra}; + } + } + // 6. SubjectId → Filter / SubjectIds → VALUES if (query.subjectIds && query.subjectIds.length > 0) { // Multiple subjects: use VALUES clause for efficient filtering @@ -508,6 +526,11 @@ function processPattern( processPattern(pattern.pattern, registry, traverseTriples, optionalPropertyTriples, filteredTraverseBlocks); break; } + + case 'minus': { + // MINUS patterns are handled separately in selectToAlgebra — skip in processPattern. + break; + } } } @@ -786,6 +809,10 @@ function convertExistsPattern( return convertExistsPattern(pattern.pattern, registry); } + case 'minus': { + return convertExistsPattern(pattern.pattern, registry); + } + default: throw new Error(`Unsupported pattern kind in EXISTS: ${(pattern as never as {kind: string}).kind}`); } diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index ed83f1d..8059c6b 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -442,4 +442,18 @@ export const queryFactories = { Employee.select((e) => e.bestFriend.select((bf) => ({name: bf.name, dept: bf.department})), ), + + // --- MINUS pattern tests --- + + // Exclude by shape type + minusShape: () => + Person.select((p) => p.name).minus(Employee), + + // Exclude by condition + minusCondition: () => + Person.select((p) => p.name).minus((p) => p.hobby.equals('Chess')), + + // Chained MINUS — two separate MINUS blocks + minusChained: () => + Person.select((p) => p.name).minus(Employee).minus((p) => p.hobby.equals('Chess')), }; diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index 26eeb6e..1e1cd84 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -1085,3 +1085,32 @@ WHERE { expect(sparql).toContain(`<${P}/name>`); }); }); + +// --------------------------------------------------------------------------- +// MINUS patterns +// --------------------------------------------------------------------------- + +describe('SPARQL golden — MINUS patterns', () => { + test('minusShape — exclude by shape type', async () => { + const sparql = await goldenSelect(queryFactories.minusShape); + expect(sparql).toContain('MINUS {'); + expect(sparql).toContain(`rdf:type <${E}>`); + expect(sparql).toContain(`<${P}/name>`); + }); + + test('minusCondition — exclude by property condition', async () => { + const sparql = await goldenSelect(queryFactories.minusCondition); + expect(sparql).toContain('MINUS {'); + expect(sparql).toContain(`<${P}/hobby>`); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('"Chess"'); + }); + + test('minusChained — two separate MINUS blocks', async () => { + const sparql = await goldenSelect(queryFactories.minusChained); + const minusCount = (sparql.match(/MINUS \{/g) || []).length; + expect(minusCount).toBe(2); + expect(sparql).toContain(`rdf:type <${E}>`); + expect(sparql).toContain('"Chess"'); + }); +}); From 15d3182e143d5789abbc40d24da745f38c2d4c55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 00:57:32 +0000 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20implement=20bulk=20delete=20?= =?UTF-8?q?=E2=80=94=20.deleteAll(),=20.delete().where(),=20Shape.deleteAl?= =?UTF-8?q?l/deleteWhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of advanced query patterns: - DeleteBuilder rewritten with mode-based dispatch (ids/all/where) - Shape.deleteAll() and Shape.deleteWhere() static convenience methods - deleteAllToAlgebra() with schema-aware blank node cleanup via walkBlankNodeTree() - deleteWhereToAlgebra() with WHERE filter condition support - SparqlStore dispatch wired up for delete_all and delete_where - Golden tests for both deleteAll and deleteWhere https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/DeleteBuilder.ts | 71 +++++-- src/shapes/Shape.ts | 16 ++ src/sparql/SparqlStore.ts | 13 +- src/sparql/irToAlgebra.ts | 239 ++++++++++++++++++++++- src/test-helpers/query-fixtures.ts | 8 + src/tests/sparql-mutation-golden.test.ts | 29 +++ 6 files changed, 360 insertions(+), 16 deletions(-) diff --git a/src/queries/DeleteBuilder.ts b/src/queries/DeleteBuilder.ts index cc84403..38c216a 100644 --- a/src/queries/DeleteBuilder.ts +++ b/src/queries/DeleteBuilder.ts @@ -3,6 +3,16 @@ import {resolveShape} from './resolveShape.js'; import {DeleteQueryFactory, DeleteQuery, DeleteResponse} from './DeleteQuery.js'; import {NodeId} from './MutationQuery.js'; import {getQueryDispatch} from './queryDispatch.js'; +import {WhereClause, processWhereClause} from './SelectQuery.js'; +import { + buildCanonicalDeleteAllMutationIR, + buildCanonicalDeleteWhereMutationIR, +} from './IRMutation.js'; +import {toWhere} from './IRDesugar.js'; +import {canonicalizeWhere} from './IRCanonicalize.js'; +import {lowerWhereToIR} from './IRLower.js'; + +type DeleteMode = 'ids' | 'all' | 'where'; /** * Internal state bag for DeleteBuilder. @@ -10,6 +20,8 @@ import {getQueryDispatch} from './queryDispatch.js'; interface DeleteBuilderInit { shape: ShapeConstructor; ids?: NodeId[]; + mode?: DeleteMode; + whereFn?: WhereClause; } /** @@ -27,16 +39,22 @@ export class DeleteBuilder { private readonly _shape: ShapeConstructor; private readonly _ids?: NodeId[]; + private readonly _mode?: DeleteMode; + private readonly _whereFn?: WhereClause; private constructor(init: DeleteBuilderInit) { this._shape = init.shape; this._ids = init.ids; + this._mode = init.mode; + this._whereFn = init.whereFn; } private clone(overrides: Partial> = {}): DeleteBuilder { return new DeleteBuilder({ shape: this._shape, ids: this._ids, + mode: this._mode, + whereFn: this._whereFn, ...overrides, }); } @@ -45,15 +63,6 @@ export class DeleteBuilder // Static constructors // --------------------------------------------------------------------------- - /** - * Create a DeleteBuilder for the given shape. - * - * Optionally accepts IDs inline for backwards compatibility: - * ```ts - * DeleteBuilder.from(Person).for({id: '...'}) // preferred - * DeleteBuilder.from(Person, {id: '...'}) // also supported - * ``` - */ static from( shape: ShapeConstructor | string, ids?: NodeId | NodeId[], @@ -61,7 +70,7 @@ export class DeleteBuilder const resolved = resolveShape(shape); if (ids !== undefined) { const idsArray = Array.isArray(ids) ? ids : [ids]; - return new DeleteBuilder({shape: resolved, ids: idsArray}); + return new DeleteBuilder({shape: resolved, ids: idsArray, mode: 'ids'}); } return new DeleteBuilder({shape: resolved}); } @@ -73,18 +82,54 @@ export class DeleteBuilder /** Specify the target IDs to delete. */ for(ids: NodeId | NodeId[]): DeleteBuilder { const idsArray = Array.isArray(ids) ? ids : [ids]; - return this.clone({ids: idsArray}); + return this.clone({ids: idsArray, mode: 'ids'}); + } + + /** Delete all instances of this shape type. */ + all(): DeleteBuilder { + return this.clone({mode: 'all', ids: undefined, whereFn: undefined}); + } + + /** Delete instances matching a condition. */ + where(fn: WhereClause): DeleteBuilder { + return this.clone({mode: 'where', whereFn: fn, ids: undefined}); } // --------------------------------------------------------------------------- // Build & execute // --------------------------------------------------------------------------- - /** Build the IR mutation. Throws if no IDs were specified via .for(). */ + /** Build the IR mutation. */ build(): DeleteQuery { + const mode = this._mode || (this._ids ? 'ids' : undefined); + + if (mode === 'all') { + return buildCanonicalDeleteAllMutationIR({ + shape: this._shape.shape, + }); + } + + if (mode === 'where') { + if (!this._whereFn) { + throw new Error( + 'DeleteBuilder.where() requires a condition callback.', + ); + } + const wherePath = processWhereClause(this._whereFn, this._shape); + const desugared = toWhere(wherePath); + const canonical = canonicalizeWhere(desugared); + const {where, wherePatterns} = lowerWhereToIR(canonical); + return buildCanonicalDeleteWhereMutationIR({ + shape: this._shape.shape, + where, + wherePatterns, + }); + } + + // Default: ID-based delete if (!this._ids || this._ids.length === 0) { throw new Error( - 'DeleteBuilder requires at least one ID to delete. Specify targets with .for(ids).', + 'DeleteBuilder requires at least one ID to delete. Specify targets with .for(ids), .all(), or .where().', ); } const factory = new DeleteQueryFactory( diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index 44b1c87..cc66142 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -9,6 +9,7 @@ import { QueryResponseToResultType, QueryShape, SelectAllQueryResponse, + WhereClause, } from '../queries/SelectQuery.js'; import {NodeReferenceValue, UpdatePartial} from '../queries/QueryFactory.js'; import {NodeId} from '../queries/MutationQuery.js'; @@ -181,6 +182,21 @@ export abstract class Shape { return DeleteBuilder.from(this, id) as DeleteBuilder; } + /** Delete all instances of this shape type. */ + static deleteAll( + this: ShapeConstructor, + ): DeleteBuilder { + return (DeleteBuilder.from(this) as DeleteBuilder).all(); + } + + /** Delete instances matching a condition. Sugar for `.delete().where(fn)`. */ + static deleteWhere( + this: ShapeConstructor, + fn: WhereClause, + ): DeleteBuilder { + return (DeleteBuilder.from(this) as DeleteBuilder).where(fn); + } + static mapPropertyShapes( this: ShapeConstructor, mapFunction?: PropertyShapeMapFunction, diff --git a/src/sparql/SparqlStore.ts b/src/sparql/SparqlStore.ts index 6349ed4..24adfca 100644 --- a/src/sparql/SparqlStore.ts +++ b/src/sparql/SparqlStore.ts @@ -14,6 +14,8 @@ import { createToSparql, updateToSparql, deleteToSparql, + deleteAllToSparql, + deleteWhereToSparql, } from './irToAlgebra.js'; import { mapSparqlSelectResult, @@ -93,8 +95,15 @@ export abstract class SparqlStore implements IQuadStore { } async deleteQuery(query: DeleteQuery): Promise { - if (query.kind === 'delete_all' || query.kind === 'delete_where') { - throw new Error(`${query.kind} is not yet implemented in SparqlStore`); + if (query.kind === 'delete_all') { + const sparql = deleteAllToSparql(query, this.options); + await this.executeSparqlUpdate(sparql); + return {deleted: [], count: 0}; + } + if (query.kind === 'delete_where') { + const sparql = deleteWhereToSparql(query, this.options); + await this.executeSparqlUpdate(sparql); + return {deleted: [], count: 0}; } const sparql = deleteToSparql(query, this.options); await this.executeSparqlUpdate(sparql); diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 5c9d3b3..2379d15 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -3,6 +3,9 @@ import { IRCreateMutation, IRUpdateMutation, IRDeleteMutation, + IRDeleteAllMutation, + IRDeleteWhereMutation, + IRUpdateWhereMutation, IRGraphPattern, IRExpression, IRFieldValue, @@ -14,6 +17,7 @@ import { SparqlSelectPlan, SparqlInsertDataPlan, SparqlDeleteInsertPlan, + SparqlDeleteWherePlan, SparqlAlgebraNode, SparqlBGP, SparqlTriple, @@ -30,7 +34,27 @@ import { selectPlanToSparql, insertDataPlanToSparql, deleteInsertPlanToSparql, + deleteWherePlanToSparql, } from './algebraToString.js'; +// Lazy-loaded to avoid circular dependency: +// irToAlgebra → ShapeClass → Shape → CreateBuilder → … → SHACL → Shape (circular) +let _getShapeClass: typeof import('../utils/ShapeClass.js').getShapeClass | undefined; +function lazyGetShapeClass(id: string) { + if (!_getShapeClass) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _getShapeClass = require('../utils/ShapeClass.js').getShapeClass; + } + return _getShapeClass!(id); +} + +let _shacl: typeof import('../ontologies/shacl.js').shacl | undefined; +function lazyShacl() { + if (!_shacl) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _shacl = require('../ontologies/shacl.js').shacl; + } + return _shacl!; +} import {rdf} from '../ontologies/rdf.js'; import {xsd} from '../ontologies/xsd.js'; @@ -1150,6 +1174,198 @@ export function deleteToAlgebra( }; } +// --------------------------------------------------------------------------- +// Blank node tree walking for schema-aware delete cleanup +// --------------------------------------------------------------------------- + +/** + * Checks whether a PropertyShape points to blank nodes (sh:BlankNode or + * sh:BlankNodeOrIRI). Returns true when the property's range *may* include + * blank node values that should be cleaned up on delete. + */ +function isBlankNodeProperty(prop: {nodeKind?: {id?: string}}): boolean { + const nk = prop.nodeKind?.id; + if (!nk) return false; + const s = lazyShacl(); + return nk === s.BlankNode.id || nk === s.BlankNodeOrIRI.id; +} + +/** + * Recursively builds DELETE + WHERE patterns for blank-node-typed properties. + * + * For each blank-node property on the shape: + * - DELETE: `?bnVar ?pN ?oN .` (wildcard all triples on the blank node) + * - WHERE: `OPTIONAL { ?parent ?bnVar . FILTER(isBlank(?bnVar)) . ?bnVar ?pN ?oN . }` + * + * Recurses into the property's valueShape to handle nested blank nodes + * (e.g. Person → Address (blank) → GeoPoint (blank)). + */ +function walkBlankNodeTree( + shapeId: string, + parentVar: string, + depth: number, + deletePatterns: SparqlTriple[], +): SparqlAlgebraNode | null { + const shapeClass = lazyGetShapeClass(shapeId); + if (!shapeClass?.shape) return null; + + let optionals: SparqlAlgebraNode | null = null; + + const props = shapeClass.shape.getPropertyShapes(true); + for (const prop of props) { + if (!isBlankNodeProperty(prop)) continue; + + const bnVar = `bn${depth}`; + const pVar = `p${depth}`; + const oVar = `o${depth}`; + + // DELETE pattern: wildcard all triples on the blank node + deletePatterns.push(tripleOf(varTerm(bnVar), varTerm(pVar), varTerm(oVar))); + + // WHERE: parent ----> ?bnVar + const traverseTriple = tripleOf( + varTerm(parentVar), + iriTerm(prop.path[0].id), + varTerm(bnVar), + ); + // FILTER(isBlank(?bnVar)) + const isBlankFilter: SparqlExpression = { + kind: 'function_expr', + name: 'isBlank', + args: [{kind: 'variable_expr', name: bnVar}], + }; + // ?bnVar ?pN ?oN + const wildcardTriple = tripleOf(varTerm(bnVar), varTerm(pVar), varTerm(oVar)); + + // Build inner pattern: traverse + filter + wildcard + let innerPattern: SparqlAlgebraNode = { + type: 'bgp', + triples: [traverseTriple, wildcardTriple], + }; + innerPattern = {type: 'filter', expression: isBlankFilter, inner: innerPattern}; + + // Recurse into valueShape for nested blank nodes + if (prop.valueShape?.id) { + const nestedOptional = walkBlankNodeTree( + prop.valueShape.id, + bnVar, + depth + 1, + deletePatterns, + ); + if (nestedOptional) { + innerPattern = {type: 'left_join', left: innerPattern, right: nestedOptional}; + } + } + + // Wrap in OPTIONAL (left_join) + if (optionals) { + optionals = {type: 'left_join', left: optionals, right: innerPattern}; + } else { + optionals = innerPattern; + } + + depth++; + } + + return optionals; +} + +/** + * Converts an IRDeleteAllMutation to a SparqlDeleteInsertPlan. + * + * Generates DELETE { ?a0 ?p ?o . [blank node wildcards] } + * WHERE { ?a0 a . ?a0 ?p ?o . OPTIONAL { [blank node traversals] } } + */ +export function deleteAllToAlgebra( + query: IRDeleteAllMutation, + _options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const subjectVar = 'a0'; + + // DELETE patterns: root wildcard + const deletePatterns: SparqlTriple[] = [ + tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')), + ]; + + // WHERE: type triple + root wildcard + const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(query.shape)); + const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')); + let whereAlgebra: SparqlAlgebraNode = {type: 'bgp', triples: [typeTriple, rootWildcard]}; + + // Walk blank node tree for cleanup + const blankNodeOptional = walkBlankNodeTree(query.shape, subjectVar, 1, deletePatterns); + if (blankNodeOptional) { + whereAlgebra = {type: 'left_join', left: whereAlgebra, right: blankNodeOptional}; + } + + return { + type: 'delete_insert', + deletePatterns, + insertPatterns: [], + whereAlgebra, + }; +} + +/** + * Converts an IRDeleteWhereMutation to a SparqlDeleteInsertPlan. + * + * Like deleteAllToAlgebra but adds filter conditions from the where clause. + */ +export function deleteWhereToAlgebra( + query: IRDeleteWhereMutation, + _options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const subjectVar = 'a0'; + const registry = new VariableRegistry(); + + // DELETE patterns: root wildcard + const deletePatterns: SparqlTriple[] = [ + tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')), + ]; + + // WHERE: type triple + root wildcard + const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(query.shape)); + const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o')); + let whereAlgebra: SparqlAlgebraNode = {type: 'bgp', triples: [typeTriple, rootWildcard]}; + + // Process where patterns (traversals from the where clause) + const traverseTriples: SparqlTriple[] = []; + const optionalPropertyTriples: SparqlTriple[] = []; + for (const pattern of query.wherePatterns) { + processPattern(pattern, registry, traverseTriples, optionalPropertyTriples); + } + + // Add traverse triples to required BGP + if (traverseTriples.length > 0) { + whereAlgebra = joinNodes(whereAlgebra, {type: 'bgp', triples: traverseTriples}); + } + + // Process expression to discover property triples + processExpressionForProperties(query.where, registry, optionalPropertyTriples); + + // Add optional property triples + for (const triple of optionalPropertyTriples) { + whereAlgebra = joinNodes(whereAlgebra, {type: 'bgp', triples: [triple]}); + } + + // Convert and add filter expression + const filterExpr = convertExpression(query.where, registry, []); + whereAlgebra = {type: 'filter', expression: filterExpr, inner: whereAlgebra}; + + // Walk blank node tree for cleanup + const blankNodeOptional = walkBlankNodeTree(query.shape, subjectVar, 1, deletePatterns); + if (blankNodeOptional) { + whereAlgebra = {type: 'left_join', left: whereAlgebra, right: blankNodeOptional}; + } + + return { + type: 'delete_insert', + deletePatterns, + insertPatterns: [], + whereAlgebra, + }; +} + // --------------------------------------------------------------------------- // Convenience wrappers: IR → algebra → SPARQL string in one call // --------------------------------------------------------------------------- @@ -1192,7 +1408,6 @@ export function updateToSparql( /** * Converts an IRDeleteMutation to a SPARQL string. - * Stub: will be implemented when algebraToString is available. */ export function deleteToSparql( query: IRDeleteMutation, @@ -1201,3 +1416,25 @@ export function deleteToSparql( const plan = deleteToAlgebra(query, options); return deleteInsertPlanToSparql(plan, options); } + +/** + * Converts an IRDeleteAllMutation to a SPARQL string. + */ +export function deleteAllToSparql( + query: IRDeleteAllMutation, + options?: SparqlOptions, +): string { + const plan = deleteAllToAlgebra(query, options); + return deleteInsertPlanToSparql(plan, options); +} + +/** + * Converts an IRDeleteWhereMutation to a SPARQL string. + */ +export function deleteWhereToSparql( + query: IRDeleteWhereMutation, + options?: SparqlOptions, +): string { + const plan = deleteWhereToAlgebra(query, options); + return deleteInsertPlanToSparql(plan, options); +} diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index 8059c6b..3aa15b0 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -456,4 +456,12 @@ export const queryFactories = { // Chained MINUS — two separate MINUS blocks minusChained: () => Person.select((p) => p.name).minus(Employee).minus((p) => p.hobby.equals('Chess')), + + // --- Bulk delete tests --- + + // Delete all instances of a shape + deleteAll: () => Person.deleteAll(), + + // Delete with where condition + deleteWhere: () => Person.deleteWhere((p) => p.hobby.equals('Chess')), }; diff --git a/src/tests/sparql-mutation-golden.test.ts b/src/tests/sparql-mutation-golden.test.ts index f01bcce..b292ea2 100644 --- a/src/tests/sparql-mutation-golden.test.ts +++ b/src/tests/sparql-mutation-golden.test.ts @@ -15,11 +15,15 @@ import { createToSparql, updateToSparql, deleteToSparql, + deleteAllToSparql, + deleteWhereToSparql, } from '../sparql/irToAlgebra'; import type { IRCreateMutation, IRUpdateMutation, IRDeleteMutation, + IRDeleteAllMutation, + IRDeleteWhereMutation, } from '../queries/IntermediateRepresentation'; import '../ontologies/rdf'; @@ -387,3 +391,28 @@ WHERE { }`); }); }); + +// --------------------------------------------------------------------------- +// Bulk delete mutation tests +// --------------------------------------------------------------------------- + +describe('SPARQL golden — bulk delete mutations', () => { + test('deleteAll — deletes all instances of shape', async () => { + const ir = (await captureQuery(queryFactories.deleteAll)) as IRDeleteAllMutation; + expect(ir.kind).toBe('delete_all'); + const sparql = deleteAllToSparql(ir); + expect(sparql).toContain('DELETE'); + expect(sparql).toContain(`rdf:type <${P}>`); + expect(sparql).toContain('?a0 ?p ?o'); + }); + + test('deleteWhere — deletes instances matching condition', async () => { + const ir = (await captureQuery(queryFactories.deleteWhere)) as IRDeleteWhereMutation; + expect(ir.kind).toBe('delete_where'); + const sparql = deleteWhereToSparql(ir); + expect(sparql).toContain('DELETE'); + expect(sparql).toContain(`rdf:type <${P}>`); + expect(sparql).toContain('?a0 ?p ?o'); + expect(sparql).toContain('FILTER'); + }); +}); From ac395a87a1c036e47b1003aa125c17ee69dbd023 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 01:01:03 +0000 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20implement=20conditional=20update?= =?UTF-8?q?=20=E2=80=94=20.update().where()=20and=20.update().forAll()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of advanced query patterns: - UpdateBuilder extended with .where(fn) and .forAll() fluent methods - updateWhereToAlgebra() generates DELETE/INSERT/WHERE with variable subject (?a0) instead of hardcoded entity IRI, adds type triple and filter conditions - SparqlStore dispatch wired up for update_where - Golden tests for both updateForAll and updateWhere https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/UpdateBuilder.ts | 87 ++++++++++-- src/sparql/SparqlStore.ts | 5 +- src/sparql/irToAlgebra.ts | 168 ++++++++++++++++++++++- src/test-helpers/query-fixtures.ts | 8 ++ src/tests/sparql-mutation-golden.test.ts | 31 +++++ 5 files changed, 288 insertions(+), 11 deletions(-) diff --git a/src/queries/UpdateBuilder.ts b/src/queries/UpdateBuilder.ts index fae7db8..afa84df 100644 --- a/src/queries/UpdateBuilder.ts +++ b/src/queries/UpdateBuilder.ts @@ -3,6 +3,13 @@ import {resolveShape} from './resolveShape.js'; import {AddId, UpdatePartial, NodeReferenceValue} from './QueryFactory.js'; import {UpdateQueryFactory, UpdateQuery} from './UpdateQuery.js'; import {getQueryDispatch} from './queryDispatch.js'; +import {WhereClause, processWhereClause} from './SelectQuery.js'; +import {buildCanonicalUpdateWhereMutationIR} from './IRMutation.js'; +import {toWhere} from './IRDesugar.js'; +import {canonicalizeWhere} from './IRCanonicalize.js'; +import {lowerWhereToIR} from './IRLower.js'; + +type UpdateMode = 'for' | 'forAll' | 'where'; /** * Internal state bag for UpdateBuilder. @@ -11,6 +18,8 @@ interface UpdateBuilderInit { shape: ShapeConstructor; data?: UpdatePartial; targetId?: string; + mode?: UpdateMode; + whereFn?: WhereClause; } /** @@ -23,8 +32,6 @@ interface UpdateBuilderInit { * const result = await UpdateBuilder.from(Person).for({id: '...'}).set({name: 'Bob'}); * ``` * - * `.for(id)` must be called before `.build()` or `.exec()`. - * * Internally delegates to UpdateQueryFactory for IR generation. */ export class UpdateBuilder = UpdatePartial> @@ -33,11 +40,15 @@ export class UpdateBuilder = private readonly _shape: ShapeConstructor; private readonly _data?: UpdatePartial; private readonly _targetId?: string; + private readonly _mode?: UpdateMode; + private readonly _whereFn?: WhereClause; private constructor(init: UpdateBuilderInit) { this._shape = init.shape; this._data = init.data; this._targetId = init.targetId; + this._mode = init.mode; + this._whereFn = init.whereFn; } private clone(overrides: Partial> = {}): UpdateBuilder { @@ -45,6 +56,8 @@ export class UpdateBuilder = shape: this._shape, data: this._data, targetId: this._targetId, + mode: this._mode, + whereFn: this._whereFn, ...overrides, }); } @@ -62,10 +75,20 @@ export class UpdateBuilder = // Fluent API // --------------------------------------------------------------------------- - /** Target a specific entity by ID. Required before build/exec. */ + /** Target a specific entity by ID. */ for(id: string | NodeReferenceValue): UpdateBuilder { const resolvedId = typeof id === 'string' ? id : id.id; - return this.clone({targetId: resolvedId}) as unknown as UpdateBuilder; + return this.clone({targetId: resolvedId, mode: 'for'}) as unknown as UpdateBuilder; + } + + /** Update all instances of this shape type. */ + forAll(): UpdateBuilder { + return this.clone({mode: 'forAll', targetId: undefined, whereFn: undefined}) as unknown as UpdateBuilder; + } + + /** Update instances matching a condition. */ + where(fn: WhereClause): UpdateBuilder { + return this.clone({mode: 'where', whereFn: fn, targetId: undefined}) as unknown as UpdateBuilder; } /** Set the update data. */ @@ -77,16 +100,33 @@ export class UpdateBuilder = // Build & execute // --------------------------------------------------------------------------- - /** Build the IR mutation. Throws if no target ID was set via .for(). */ + /** Build the IR mutation. */ build(): UpdateQuery { - if (!this._targetId) { + if (!this._data) { throw new Error( - 'UpdateBuilder requires .for(id) before .build(). Specify which entity to update.', + 'UpdateBuilder requires .set(data) before .build(). Specify what to update.', ); } - if (!this._data) { + + const mode = this._mode || (this._targetId ? 'for' : undefined); + + if (mode === 'forAll') { + return this.buildUpdateWhere(); + } + + if (mode === 'where') { + if (!this._whereFn) { + throw new Error( + 'UpdateBuilder.where() requires a condition callback.', + ); + } + return this.buildUpdateWhere(); + } + + // Default: ID-based update + if (!this._targetId) { throw new Error( - 'UpdateBuilder requires .set(data) before .build(). Specify what to update.', + 'UpdateBuilder requires .for(id), .forAll(), or .where() before .build().', ); } const factory = new UpdateQueryFactory>( @@ -97,6 +137,35 @@ export class UpdateBuilder = return factory.build(); } + private buildUpdateWhere(): UpdateQuery { + // Build description through UpdateQueryFactory internals + const factory = new UpdateQueryFactory>( + this._shape, + '__placeholder__', // not used for where/forAll + this._data!, + ); + const description = factory.fields; + + let where; + let wherePatterns; + + if (this._whereFn) { + const wherePath = processWhereClause(this._whereFn, this._shape); + const desugared = toWhere(wherePath); + const canonical = canonicalizeWhere(desugared); + const lowered = lowerWhereToIR(canonical); + where = lowered.where; + wherePatterns = lowered.wherePatterns; + } + + return buildCanonicalUpdateWhereMutationIR({ + shape: this._shape.shape, + updates: description, + where, + wherePatterns, + }); + } + /** Execute the mutation. */ exec(): Promise> { return getQueryDispatch().updateQuery(this.build()) as Promise>; diff --git a/src/sparql/SparqlStore.ts b/src/sparql/SparqlStore.ts index 24adfca..be798da 100644 --- a/src/sparql/SparqlStore.ts +++ b/src/sparql/SparqlStore.ts @@ -13,6 +13,7 @@ import { selectToSparql, createToSparql, updateToSparql, + updateWhereToSparql, deleteToSparql, deleteAllToSparql, deleteWhereToSparql, @@ -87,7 +88,9 @@ export abstract class SparqlStore implements IQuadStore { async updateQuery(query: UpdateQuery): Promise { if (query.kind === 'update_where') { - throw new Error('update_where is not yet implemented in SparqlStore'); + const sparql = updateWhereToSparql(query, this.options); + await this.executeSparqlUpdate(sparql); + return {id: ''} as UpdateResult; } const sparql = updateToSparql(query, this.options); await this.executeSparqlUpdate(sparql); diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 2379d15..c2727f7 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -1366,13 +1366,168 @@ export function deleteWhereToAlgebra( }; } +/** + * Converts an IRUpdateWhereMutation to a SparqlDeleteInsertPlan. + * + * Like updateToAlgebra but uses a variable subject (?a0) instead of a + * hardcoded entity IRI, adds a type triple, and optionally includes + * filter conditions from the where clause. + */ +export function updateWhereToAlgebra( + query: IRUpdateWhereMutation, + options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const subjectTerm = varTerm('a0'); + const deletePatterns: SparqlTriple[] = []; + const insertPatterns: SparqlTriple[] = []; + const oldValueTriples: SparqlTriple[] = []; + + // Process fields — same logic as updateToAlgebra but with variable subject + for (const field of query.data.fields) { + const propertyTerm = iriTerm(field.property); + const suffix = propertySuffix(field.property); + + // Check for set modification ({add, remove}) + if ( + field.value && + typeof field.value === 'object' && + !Array.isArray(field.value) && + !(field.value instanceof Date) && + !('id' in field.value) && + !('shape' in field.value) && + ('add' in field.value || 'remove' in field.value) + ) { + const setMod = field.value as IRSetModificationValue; + + if (setMod.remove) { + for (const removeItem of setMod.remove) { + const removeTerm = iriTerm((removeItem as NodeReferenceValue).id); + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); + } + } + + if (setMod.add) { + for (const addItem of setMod.add) { + if (addItem && typeof addItem === 'object' && 'shape' in addItem && 'fields' in addItem) { + const nested = generateNodeDataTriples(addItem as IRNodeData, options); + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); + insertPatterns.push(...nested.triples); + } else { + const terms = fieldValueToTerms(addItem, options); + for (const term of terms) { + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); + } + } + } + } + + continue; + } + + // Unset (undefined/null) + if (field.value === undefined || field.value === null) { + const oldVar = varTerm(`old_${suffix}`); + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + continue; + } + + // Array overwrite + if (Array.isArray(field.value)) { + const oldVar = varTerm(`old_${suffix}`); + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + + for (const item of field.value) { + if (item && typeof item === 'object' && 'shape' in item && 'fields' in item) { + const nested = generateNodeDataTriples(item as IRNodeData, options); + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); + insertPatterns.push(...nested.triples); + } else { + const terms = fieldValueToTerms(item, options); + for (const term of terms) { + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); + } + } + } + continue; + } + + // Nested create + if (typeof field.value === 'object' && 'shape' in field.value && 'fields' in field.value) { + const oldVar = varTerm(`old_${suffix}`); + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + + const nested = generateNodeDataTriples(field.value as IRNodeData, options); + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); + insertPatterns.push(...nested.triples); + continue; + } + + // Simple value update + const oldVar = varTerm(`old_${suffix}`); + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + + const terms = fieldValueToTerms(field.value, options); + for (const term of terms) { + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); + } + } + + // WHERE: type triple is always required + const typeTriple = tripleOf(subjectTerm, iriTerm(RDF_TYPE), iriTerm(query.data.shape)); + let whereAlgebra: SparqlAlgebraNode = {type: 'bgp', triples: [typeTriple]}; + + // Process where filter conditions (if any) + if (query.where && query.wherePatterns) { + const registry = new VariableRegistry(); + const traverseTriples: SparqlTriple[] = []; + const optionalPropertyTriples: SparqlTriple[] = []; + + for (const pattern of query.wherePatterns) { + processPattern(pattern, registry, traverseTriples, optionalPropertyTriples); + } + + if (traverseTriples.length > 0) { + whereAlgebra = joinNodes(whereAlgebra, {type: 'bgp', triples: traverseTriples}); + } + + processExpressionForProperties(query.where, registry, optionalPropertyTriples); + + for (const triple of optionalPropertyTriples) { + whereAlgebra = joinNodes(whereAlgebra, {type: 'bgp', triples: [triple]}); + } + + const filterExpr = convertExpression(query.where, registry, []); + whereAlgebra = {type: 'filter', expression: filterExpr, inner: whereAlgebra}; + } + + // Wrap old value triples in OPTIONAL — entity may not have the field yet + for (const triple of oldValueTriples) { + whereAlgebra = { + type: 'left_join', + left: whereAlgebra, + right: {type: 'bgp', triples: [triple]}, + }; + } + + return { + type: 'delete_insert', + deletePatterns, + insertPatterns, + whereAlgebra, + }; +} + // --------------------------------------------------------------------------- // Convenience wrappers: IR → algebra → SPARQL string in one call // --------------------------------------------------------------------------- /** * Converts an IRSelectQuery to a SPARQL string. - * Stub: will be implemented when algebraToString is available. */ export function selectToSparql( query: IRSelectQuery, @@ -1438,3 +1593,14 @@ export function deleteWhereToSparql( const plan = deleteWhereToAlgebra(query, options); return deleteInsertPlanToSparql(plan, options); } + +/** + * Converts an IRUpdateWhereMutation to a SPARQL string. + */ +export function updateWhereToSparql( + query: IRUpdateWhereMutation, + options?: SparqlOptions, +): string { + const plan = updateWhereToAlgebra(query, options); + return deleteInsertPlanToSparql(plan, options); +} diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index 3aa15b0..0f1e03b 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -464,4 +464,12 @@ export const queryFactories = { // Delete with where condition deleteWhere: () => Person.deleteWhere((p) => p.hobby.equals('Chess')), + + // --- Conditional update tests --- + + // Update all instances + updateForAll: () => Person.update({hobby: 'Chess'}).forAll(), + + // Update with where condition + updateWhere: () => Person.update({hobby: 'Archived'}).where((p) => p.hobby.equals('Chess')), }; diff --git a/src/tests/sparql-mutation-golden.test.ts b/src/tests/sparql-mutation-golden.test.ts index b292ea2..d4ff4de 100644 --- a/src/tests/sparql-mutation-golden.test.ts +++ b/src/tests/sparql-mutation-golden.test.ts @@ -14,6 +14,7 @@ import {captureQuery} from '../test-helpers/query-capture-store'; import { createToSparql, updateToSparql, + updateWhereToSparql, deleteToSparql, deleteAllToSparql, deleteWhereToSparql, @@ -24,6 +25,7 @@ import type { IRDeleteMutation, IRDeleteAllMutation, IRDeleteWhereMutation, + IRUpdateWhereMutation, } from '../queries/IntermediateRepresentation'; import '../ontologies/rdf'; @@ -416,3 +418,32 @@ describe('SPARQL golden — bulk delete mutations', () => { expect(sparql).toContain('FILTER'); }); }); + +// --------------------------------------------------------------------------- +// Conditional update mutation tests +// --------------------------------------------------------------------------- + +describe('SPARQL golden — conditional update mutations', () => { + test('updateForAll — updates all instances of shape', async () => { + const ir = (await captureQuery(queryFactories.updateForAll)) as IRUpdateWhereMutation; + expect(ir.kind).toBe('update_where'); + const sparql = updateWhereToSparql(ir); + expect(sparql).toContain('DELETE'); + expect(sparql).toContain('INSERT'); + expect(sparql).toContain(`rdf:type <${P}>`); + expect(sparql).toContain('?a0'); + // Should NOT have FILTER (no where condition) + expect(sparql).not.toContain('FILTER'); + }); + + test('updateWhere — updates instances matching condition', async () => { + const ir = (await captureQuery(queryFactories.updateWhere)) as IRUpdateWhereMutation; + expect(ir.kind).toBe('update_where'); + const sparql = updateWhereToSparql(ir); + expect(sparql).toContain('DELETE'); + expect(sparql).toContain('INSERT'); + expect(sparql).toContain(`rdf:type <${P}>`); + expect(sparql).toContain('?a0'); + expect(sparql).toContain('FILTER'); + }); +}); From cd931757cf63ef2dd08c802883958837f15eeb25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 01:01:52 +0000 Subject: [PATCH 12/20] docs: mark all phases as completed in plan doc https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index 9226c7f..f92b727 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -570,7 +570,7 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to --- -### Phase 3: Bulk Delete +### Phase 3: Bulk Delete — COMPLETED **Goal:** `.deleteAll()`, `.delete().all()`, `.delete().where()`, `.deleteWhere()` — full pipeline. @@ -610,7 +610,7 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to --- -### Phase 4: Conditional Update +### Phase 4: Conditional Update — COMPLETED **Goal:** `.update().where()` and `.update().forAll()` — full pipeline. @@ -645,7 +645,7 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to --- -### Phase 5: Integration Verification +### Phase 5: Integration Verification — COMPLETED **Goal:** Full compile, full test suite, verify all features work together. From 08f3335035528ee43db85477cecc4735a2f8bd92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 01:02:49 +0000 Subject: [PATCH 13/20] fix: include IRDesugar and IRLower changes missed from Phase 2 commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export toWhere from IRDesugar and add lowerWhereToIR to IRLower — these changes support the WHERE processing in mutation builders. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/IRDesugar.ts | 2 +- src/queries/IRLower.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index c4aafa8..b982916 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -359,7 +359,7 @@ const toWhereComparison = (path: WherePath): DesugaredWhereComparison => { }; }; -const toWhere = (path: WherePath): DesugaredWhere => { +export const toWhere = (path: WherePath): DesugaredWhere => { if ((path as WhereAndOr).firstPath) { const grouped = path as WhereAndOr; return { diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 17dd31b..62c5caf 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -415,3 +415,45 @@ export const lowerSelectQuery = ( resultMap: resultMapEntries, }; }; + +/** + * Standalone WHERE lowering — converts a CanonicalWhereExpression to IR expression + patterns. + * Used by mutation builders (DeleteBuilder, UpdateBuilder) that don't go through the select pipeline. + */ +export const lowerWhereToIR = ( + where: CanonicalWhereExpression, + rootAlias: string = 'a0', +): {where: IRExpression; wherePatterns: IRGraphPattern[]} => { + let counter = 1; // start at 1 since a0 is the root + const traversals: IRTraversePattern[] = []; + const localTraversalMap = new Map(); + + const ctx = { + generateAlias(): string { + return `a${counter++}`; + }, + }; + + const resolveTraversal = (fromAlias: string, propertyShapeId: string): string => { + const key = `${fromAlias}:${propertyShapeId}`; + const existing = localTraversalMap.get(key); + if (existing) return existing; + const toAlias = ctx.generateAlias(); + traversals.push({ + kind: 'traverse', + from: fromAlias, + to: toAlias, + property: propertyShapeId, + }); + localTraversalMap.set(key, toAlias); + return toAlias; + }; + + const options: PathLoweringOptions = { + rootAlias, + resolveTraversal, + }; + + const expr = lowerWhere(where, ctx as any, options); + return {where: expr, wherePatterns: traversals}; +}; From 8d8f5e3ad96af070efce1bf64fb8fd1acb14a736 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 01:14:29 +0000 Subject: [PATCH 14/20] =?UTF-8?q?refactor:=20address=20review=20gaps=20?= =?UTF-8?q?=E2=80=94=20shared=20helper,=20void=20returns,=20equivalence=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract processUpdateFields() and wrapOldValueOptionals() from updateToAlgebra, shared with updateWhereToAlgebra (eliminates duplication) - deleteAll/deleteWhere/updateWhere now resolve to void (not placeholder objects) via typed R parameter on DeleteBuilder and UpdateBuilder - Add builder equivalence tests: Person.deleteAll() === DeleteBuilder.from().all() and Person.deleteWhere(fn) === DeleteBuilder.from().where(fn) https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/DeleteBuilder.ts | 43 ++--- src/queries/UpdateBuilder.ts | 46 +++--- src/shapes/Shape.ts | 8 +- src/sparql/irToAlgebra.ts | 199 +++++++---------------- src/test-helpers/query-fixtures.ts | 5 + src/tests/sparql-mutation-golden.test.ts | 18 ++ 6 files changed, 135 insertions(+), 184 deletions(-) diff --git a/src/queries/DeleteBuilder.ts b/src/queries/DeleteBuilder.ts index 38c216a..2ff6aa4 100644 --- a/src/queries/DeleteBuilder.ts +++ b/src/queries/DeleteBuilder.ts @@ -30,12 +30,13 @@ interface DeleteBuilderInit { * Implements PromiseLike so mutations execute on `await`: * ```ts * const result = await DeleteBuilder.from(Person).for({id: '...'}); + * await DeleteBuilder.from(Person).all(); // returns void * ``` * - * Internally delegates to DeleteQueryFactory for IR generation. + * R is the resolved type: DeleteResponse for ID-based, void for bulk operations. */ -export class DeleteBuilder - implements PromiseLike, Promise +export class DeleteBuilder + implements PromiseLike, Promise { private readonly _shape: ShapeConstructor; private readonly _ids?: NodeId[]; @@ -49,7 +50,7 @@ export class DeleteBuilder this._whereFn = init.whereFn; } - private clone(overrides: Partial> = {}): DeleteBuilder { + private clone(overrides: Partial> = {}): DeleteBuilder { return new DeleteBuilder({ shape: this._shape, ids: this._ids, @@ -66,7 +67,7 @@ export class DeleteBuilder static from( shape: ShapeConstructor | string, ids?: NodeId | NodeId[], - ): DeleteBuilder { + ): DeleteBuilder { const resolved = resolveShape(shape); if (ids !== undefined) { const idsArray = Array.isArray(ids) ? ids : [ids]; @@ -80,19 +81,19 @@ export class DeleteBuilder // --------------------------------------------------------------------------- /** Specify the target IDs to delete. */ - for(ids: NodeId | NodeId[]): DeleteBuilder { + for(ids: NodeId | NodeId[]): DeleteBuilder { const idsArray = Array.isArray(ids) ? ids : [ids]; - return this.clone({ids: idsArray, mode: 'ids'}); + return this.clone({ids: idsArray, mode: 'ids'}) as DeleteBuilder; } - /** Delete all instances of this shape type. */ - all(): DeleteBuilder { - return this.clone({mode: 'all', ids: undefined, whereFn: undefined}); + /** Delete all instances of this shape type. Returns void. */ + all(): DeleteBuilder { + return this.clone({mode: 'all', ids: undefined, whereFn: undefined}) as DeleteBuilder; } - /** Delete instances matching a condition. */ - where(fn: WhereClause): DeleteBuilder { - return this.clone({mode: 'where', whereFn: fn, ids: undefined}); + /** Delete instances matching a condition. Returns void. */ + where(fn: WhereClause): DeleteBuilder { + return this.clone({mode: 'where', whereFn: fn, ids: undefined}) as DeleteBuilder; } // --------------------------------------------------------------------------- @@ -140,16 +141,20 @@ export class DeleteBuilder } /** Execute the mutation. */ - exec(): Promise { - return getQueryDispatch().deleteQuery(this.build()); + exec(): Promise { + const mode = this._mode || (this._ids ? 'ids' : undefined); + if (mode === 'all' || mode === 'where') { + return getQueryDispatch().deleteQuery(this.build()).then(() => undefined) as Promise; + } + return getQueryDispatch().deleteQuery(this.build()) as Promise; } // --------------------------------------------------------------------------- // Promise interface // --------------------------------------------------------------------------- - then( - onfulfilled?: ((value: DeleteResponse) => TResult1 | PromiseLike) | null, + then( + onfulfilled?: ((value: R) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.exec().then(onfulfilled, onrejected); @@ -157,11 +162,11 @@ export class DeleteBuilder catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, - ): Promise { + ): Promise { return this.then().catch(onrejected); } - finally(onfinally?: (() => void) | null): Promise { + finally(onfinally?: (() => void) | null): Promise { return this.then().finally(onfinally); } diff --git a/src/queries/UpdateBuilder.ts b/src/queries/UpdateBuilder.ts index afa84df..e86db45 100644 --- a/src/queries/UpdateBuilder.ts +++ b/src/queries/UpdateBuilder.ts @@ -30,12 +30,13 @@ interface UpdateBuilderInit { * Implements PromiseLike so mutations execute on `await`: * ```ts * const result = await UpdateBuilder.from(Person).for({id: '...'}).set({name: 'Bob'}); + * await UpdateBuilder.from(Person).set({hobby: 'x'}).forAll(); // returns void * ``` * - * Internally delegates to UpdateQueryFactory for IR generation. + * R is the resolved type: AddId for ID-based, void for bulk operations. */ -export class UpdateBuilder = UpdatePartial> - implements PromiseLike>, Promise> +export class UpdateBuilder = UpdatePartial, R = AddId> + implements PromiseLike, Promise { private readonly _shape: ShapeConstructor; private readonly _data?: UpdatePartial; @@ -51,7 +52,7 @@ export class UpdateBuilder = this._whereFn = init.whereFn; } - private clone(overrides: Partial> = {}): UpdateBuilder { + private clone(overrides: Partial> = {}): UpdateBuilder { return new UpdateBuilder({ shape: this._shape, data: this._data, @@ -76,24 +77,24 @@ export class UpdateBuilder = // --------------------------------------------------------------------------- /** Target a specific entity by ID. */ - for(id: string | NodeReferenceValue): UpdateBuilder { + for(id: string | NodeReferenceValue): UpdateBuilder> { const resolvedId = typeof id === 'string' ? id : id.id; - return this.clone({targetId: resolvedId, mode: 'for'}) as unknown as UpdateBuilder; + return this.clone({targetId: resolvedId, mode: 'for'}) as unknown as UpdateBuilder>; } - /** Update all instances of this shape type. */ - forAll(): UpdateBuilder { - return this.clone({mode: 'forAll', targetId: undefined, whereFn: undefined}) as unknown as UpdateBuilder; + /** Update all instances of this shape type. Returns void. */ + forAll(): UpdateBuilder { + return this.clone({mode: 'forAll', targetId: undefined, whereFn: undefined}) as unknown as UpdateBuilder; } - /** Update instances matching a condition. */ - where(fn: WhereClause): UpdateBuilder { - return this.clone({mode: 'where', whereFn: fn, targetId: undefined}) as unknown as UpdateBuilder; + /** Update instances matching a condition. Returns void. */ + where(fn: WhereClause): UpdateBuilder { + return this.clone({mode: 'where', whereFn: fn, targetId: undefined}) as unknown as UpdateBuilder; } /** Set the update data. */ - set>(data: NewU): UpdateBuilder { - return this.clone({data}) as unknown as UpdateBuilder; + set>(data: NewU): UpdateBuilder { + return this.clone({data}) as unknown as UpdateBuilder; } // --------------------------------------------------------------------------- @@ -138,7 +139,6 @@ export class UpdateBuilder = } private buildUpdateWhere(): UpdateQuery { - // Build description through UpdateQueryFactory internals const factory = new UpdateQueryFactory>( this._shape, '__placeholder__', // not used for where/forAll @@ -167,16 +167,20 @@ export class UpdateBuilder = } /** Execute the mutation. */ - exec(): Promise> { - return getQueryDispatch().updateQuery(this.build()) as Promise>; + exec(): Promise { + const mode = this._mode || (this._targetId ? 'for' : undefined); + if (mode === 'forAll' || mode === 'where') { + return getQueryDispatch().updateQuery(this.build()).then(() => undefined) as Promise; + } + return getQueryDispatch().updateQuery(this.build()) as Promise; } // --------------------------------------------------------------------------- // Promise interface // --------------------------------------------------------------------------- - then, TResult2 = never>( - onfulfilled?: ((value: AddId) => TResult1 | PromiseLike) | null, + then( + onfulfilled?: ((value: R) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.exec().then(onfulfilled, onrejected); @@ -184,11 +188,11 @@ export class UpdateBuilder = catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, - ): Promise | TResult> { + ): Promise { return this.then().catch(onrejected); } - finally(onfinally?: (() => void) | null): Promise> { + finally(onfinally?: (() => void) | null): Promise { return this.then().finally(onfinally); } diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index cc66142..a19d6f2 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -182,18 +182,18 @@ export abstract class Shape { return DeleteBuilder.from(this, id) as DeleteBuilder; } - /** Delete all instances of this shape type. */ + /** Delete all instances of this shape type. Returns void. */ static deleteAll( this: ShapeConstructor, - ): DeleteBuilder { + ): DeleteBuilder { return (DeleteBuilder.from(this) as DeleteBuilder).all(); } - /** Delete instances matching a condition. Sugar for `.delete().where(fn)`. */ + /** Delete instances matching a condition. Sugar for `.delete().where(fn)`. Returns void. */ static deleteWhere( this: ShapeConstructor, fn: WhereClause, - ): DeleteBuilder { + ): DeleteBuilder { return (DeleteBuilder.from(this) as DeleteBuilder).where(fn); } diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index c2727f7..585eede 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -988,19 +988,28 @@ export function createToAlgebra( }; } +// --------------------------------------------------------------------------- +// Shared update field processing +// --------------------------------------------------------------------------- + /** - * Converts an IRUpdateMutation to a SparqlDeleteInsertPlan. + * Processes IRNodeData fields into DELETE/INSERT/WHERE triples. + * Shared between updateToAlgebra (IRI subject) and updateWhereToAlgebra (variable subject). */ -export function updateToAlgebra( - query: IRUpdateMutation, +function processUpdateFields( + data: IRNodeData, + subjectTerm: SparqlTerm, options?: SparqlOptions, -): SparqlDeleteInsertPlan { - const subjectTerm = iriTerm(query.id); +): { + deletePatterns: SparqlTriple[]; + insertPatterns: SparqlTriple[]; + oldValueTriples: SparqlTriple[]; +} { const deletePatterns: SparqlTriple[] = []; const insertPatterns: SparqlTriple[] = []; - const whereTriples: SparqlTriple[] = []; + const oldValueTriples: SparqlTriple[] = []; - for (const field of query.data.fields) { + for (const field of data.fields) { const propertyTerm = iriTerm(field.property); const suffix = propertySuffix(field.property); @@ -1016,20 +1025,17 @@ export function updateToAlgebra( ) { const setMod = field.value as IRSetModificationValue; - // Remove specific values if (setMod.remove) { for (const removeItem of setMod.remove) { const removeTerm = iriTerm((removeItem as NodeReferenceValue).id); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); - whereTriples.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); } } - // Add new values if (setMod.add) { for (const addItem of setMod.add) { if (addItem && typeof addItem === 'object' && 'shape' in addItem && 'fields' in addItem) { - // Nested create in add const nested = generateNodeDataTriples(addItem as IRNodeData, options); insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); insertPatterns.push(...nested.triples); @@ -1049,7 +1055,7 @@ export function updateToAlgebra( if (field.value === undefined || field.value === null) { const oldVar = varTerm(`old_${suffix}`); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - whereTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); continue; } @@ -1057,7 +1063,7 @@ export function updateToAlgebra( if (Array.isArray(field.value)) { const oldVar = varTerm(`old_${suffix}`); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - whereTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); for (const item of field.value) { if (item && typeof item === 'object' && 'shape' in item && 'fields' in item) { @@ -1078,7 +1084,7 @@ export function updateToAlgebra( if (typeof field.value === 'object' && 'shape' in field.value && 'fields' in field.value) { const oldVar = varTerm(`old_${suffix}`); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - whereTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); const nested = generateNodeDataTriples(field.value as IRNodeData, options); insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); @@ -1089,7 +1095,7 @@ export function updateToAlgebra( // Simple value update — delete old + insert new const oldVar = varTerm(`old_${suffix}`); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - whereTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); const terms = fieldValueToTerms(field.value, options); for (const term of terms) { @@ -1097,28 +1103,45 @@ export function updateToAlgebra( } } - // Wrap WHERE triples in OPTIONAL so UPDATE succeeds even when the old - // value doesn't exist (e.g. setting bestFriend when none was set before). - let whereAlgebra: SparqlAlgebraNode; - if (whereTriples.length === 0) { - whereAlgebra = {type: 'bgp', triples: []}; - } else if (whereTriples.length === 1) { - whereAlgebra = { + return {deletePatterns, insertPatterns, oldValueTriples}; +} + +/** + * Wraps old-value triples in OPTIONAL (LEFT JOIN) so UPDATE succeeds + * even when the old value doesn't exist. + */ +function wrapOldValueOptionals( + base: SparqlAlgebraNode, + oldValueTriples: SparqlTriple[], +): SparqlAlgebraNode { + let algebra = base; + if (oldValueTriples.length === 0) { + return algebra; + } + for (const triple of oldValueTriples) { + algebra = { type: 'left_join', - left: {type: 'bgp', triples: []}, - right: {type: 'bgp', triples: whereTriples}, + left: algebra, + right: {type: 'bgp', triples: [triple]}, }; - } else { - // Wrap each triple in its own OPTIONAL for independent matching - whereAlgebra = {type: 'bgp', triples: []}; - for (const triple of whereTriples) { - whereAlgebra = { - type: 'left_join', - left: whereAlgebra, - right: {type: 'bgp', triples: [triple]}, - }; - } } + return algebra; +} + +/** + * Converts an IRUpdateMutation to a SparqlDeleteInsertPlan. + */ +export function updateToAlgebra( + query: IRUpdateMutation, + options?: SparqlOptions, +): SparqlDeleteInsertPlan { + const subjectTerm = iriTerm(query.id); + const {deletePatterns, insertPatterns, oldValueTriples} = processUpdateFields(query.data, subjectTerm, options); + + const whereAlgebra = wrapOldValueOptionals( + {type: 'bgp', triples: []}, + oldValueTriples, + ); return { type: 'delete_insert', @@ -1378,104 +1401,7 @@ export function updateWhereToAlgebra( options?: SparqlOptions, ): SparqlDeleteInsertPlan { const subjectTerm = varTerm('a0'); - const deletePatterns: SparqlTriple[] = []; - const insertPatterns: SparqlTriple[] = []; - const oldValueTriples: SparqlTriple[] = []; - - // Process fields — same logic as updateToAlgebra but with variable subject - for (const field of query.data.fields) { - const propertyTerm = iriTerm(field.property); - const suffix = propertySuffix(field.property); - - // Check for set modification ({add, remove}) - if ( - field.value && - typeof field.value === 'object' && - !Array.isArray(field.value) && - !(field.value instanceof Date) && - !('id' in field.value) && - !('shape' in field.value) && - ('add' in field.value || 'remove' in field.value) - ) { - const setMod = field.value as IRSetModificationValue; - - if (setMod.remove) { - for (const removeItem of setMod.remove) { - const removeTerm = iriTerm((removeItem as NodeReferenceValue).id); - deletePatterns.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); - oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, removeTerm)); - } - } - - if (setMod.add) { - for (const addItem of setMod.add) { - if (addItem && typeof addItem === 'object' && 'shape' in addItem && 'fields' in addItem) { - const nested = generateNodeDataTriples(addItem as IRNodeData, options); - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); - insertPatterns.push(...nested.triples); - } else { - const terms = fieldValueToTerms(addItem, options); - for (const term of terms) { - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); - } - } - } - } - - continue; - } - - // Unset (undefined/null) - if (field.value === undefined || field.value === null) { - const oldVar = varTerm(`old_${suffix}`); - deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - continue; - } - - // Array overwrite - if (Array.isArray(field.value)) { - const oldVar = varTerm(`old_${suffix}`); - deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - - for (const item of field.value) { - if (item && typeof item === 'object' && 'shape' in item && 'fields' in item) { - const nested = generateNodeDataTriples(item as IRNodeData, options); - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); - insertPatterns.push(...nested.triples); - } else { - const terms = fieldValueToTerms(item, options); - for (const term of terms) { - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); - } - } - } - continue; - } - - // Nested create - if (typeof field.value === 'object' && 'shape' in field.value && 'fields' in field.value) { - const oldVar = varTerm(`old_${suffix}`); - deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - - const nested = generateNodeDataTriples(field.value as IRNodeData, options); - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, iriTerm(nested.uri))); - insertPatterns.push(...nested.triples); - continue; - } - - // Simple value update - const oldVar = varTerm(`old_${suffix}`); - deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); - - const terms = fieldValueToTerms(field.value, options); - for (const term of terms) { - insertPatterns.push(tripleOf(subjectTerm, propertyTerm, term)); - } - } + const {deletePatterns, insertPatterns, oldValueTriples} = processUpdateFields(query.data, subjectTerm, options); // WHERE: type triple is always required const typeTriple = tripleOf(subjectTerm, iriTerm(RDF_TYPE), iriTerm(query.data.shape)); @@ -1505,14 +1431,7 @@ export function updateWhereToAlgebra( whereAlgebra = {type: 'filter', expression: filterExpr, inner: whereAlgebra}; } - // Wrap old value triples in OPTIONAL — entity may not have the field yet - for (const triple of oldValueTriples) { - whereAlgebra = { - type: 'left_join', - left: whereAlgebra, - right: {type: 'bgp', triples: [triple]}, - }; - } + whereAlgebra = wrapOldValueOptionals(whereAlgebra, oldValueTriples); return { type: 'delete_insert', diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index 0f1e03b..d65eaa1 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -5,6 +5,7 @@ import {xsd} from '../ontologies/xsd'; import {ShapeSet} from '../collections/ShapeSet'; import {getQueryContext} from '../queries/QueryContext'; import {NodeReferenceValue, UpdatePartial} from '../queries/QueryFactory'; +import {DeleteBuilder} from '../queries/DeleteBuilder'; const tmpPropBase = 'linked://tmp/props/'; const tmpTypeBase = 'linked://tmp/types/'; @@ -465,6 +466,10 @@ export const queryFactories = { // Delete with where condition deleteWhere: () => Person.deleteWhere((p) => p.hobby.equals('Chess')), + // Builder-chain equivalents for equivalence testing + deleteAllBuilder: () => DeleteBuilder.from(Person).all(), + deleteWhereBuilder: () => DeleteBuilder.from(Person).where((p) => p.hobby.equals('Chess')), + // --- Conditional update tests --- // Update all instances diff --git a/src/tests/sparql-mutation-golden.test.ts b/src/tests/sparql-mutation-golden.test.ts index d4ff4de..88b1bbe 100644 --- a/src/tests/sparql-mutation-golden.test.ts +++ b/src/tests/sparql-mutation-golden.test.ts @@ -447,3 +447,21 @@ describe('SPARQL golden — conditional update mutations', () => { expect(sparql).toContain('FILTER'); }); }); + +// --------------------------------------------------------------------------- +// Builder equivalence tests — sugar methods produce identical SPARQL +// --------------------------------------------------------------------------- + +describe('SPARQL golden — builder equivalence', () => { + test('Person.deleteAll() === DeleteBuilder.from(Person).all()', async () => { + const irSugar = (await captureQuery(queryFactories.deleteAll)) as IRDeleteAllMutation; + const irBuilder = (await captureQuery(queryFactories.deleteAllBuilder)) as IRDeleteAllMutation; + expect(deleteAllToSparql(irSugar)).toBe(deleteAllToSparql(irBuilder)); + }); + + test('Person.deleteWhere(fn) === DeleteBuilder.from(Person).where(fn)', async () => { + const irSugar = (await captureQuery(queryFactories.deleteWhere)) as IRDeleteWhereMutation; + const irBuilder = (await captureQuery(queryFactories.deleteWhereBuilder)) as IRDeleteWhereMutation; + expect(deleteWhereToSparql(irSugar)).toBe(deleteWhereToSparql(irBuilder)); + }); +}); From 15c0c38eae4907d17c1ab4b6bc8e5a7780b6a8dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 03:19:02 +0000 Subject: [PATCH 15/20] docs: add Phase 6 plan for MINUS multi-property existence support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan for .minus(p => [p.hobby, p.name]) — exclude entities where all listed properties exist. Threads propertyPaths through the pipeline without touching WhereClause or processWhereClause. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 94 +++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index f92b727..21154da 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -658,3 +658,97 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to - `npm run compile` exits 0 - `npm test` exits 0 with 0 failures - No TypeScript errors in any configuration + +--- + +### Phase 6: MINUS Multi-Property Existence + +**Goal:** Support `.minus(p => [p.hobby, p.name])` — exclude entities where ALL listed properties exist. + +**Semantics:** +```ts +// Exclude any Person that has BOTH a hobby AND a name +Person.select(p => p.name).minus(p => [p.hobby, p.name]) +``` +Generates: +```sparql +SELECT ?a0 ?a0_name WHERE { + ?a0 a . ?a0 ?a0_name . + MINUS { ?a0 ?m0 . ?a0 ?m1 . } +} +``` + +**Architecture:** + +The `.minus()` callback currently only accepts `WhereClause` (returns `Evaluation`). We need a third callback return type: an **array of `QueryBuilderObject`** instances representing property existence. + +The key insight: we do NOT need to change the WhereClause type or processWhereClause. Instead, we add a **new callback type** to `.minus()` specifically, and process it at the `QueryBuilder.toRawInput()` level — converting property paths into `RawMinusEntry` objects with a new `propertyPaths` field that carries the raw property URIs. This new field then flows through desugar → canonicalize → lower → algebra as a simple BGP of `?a0 ?varN` triples. + +**Data flow:** + +``` +QueryBuilder.minus(p => [p.hobby, p.name]) + ↓ toRawInput(): detect array, extract property paths from QueryBuilderObjects +RawMinusEntry { propertyPaths: ['linked://tmp/props/hobby', 'linked://tmp/props/name'] } + ↓ desugarSelectQuery(): pass through (no desugaring needed for plain property URIs) +DesugaredMinusEntry { propertyPaths: [...] } + ↓ canonicalizeDesugaredSelectQuery(): pass through +CanonicalMinusEntry { propertyPaths: [...] } + ↓ lowerSelectQuery(): convert to IRMinusPattern with traverse patterns +IRMinusPattern { kind: 'minus', pattern: { kind: 'join', patterns: [traverse, traverse] } } + ↓ irToAlgebra step 5b: existing code handles it — no filter, just inner pattern +SparqlMinus { left: ..., right: { bgp: [?a0 ?m0, ?a0 ?m1] } } +``` + +**Files:** + +| File | Change | +|------|--------| +| `src/queries/QueryBuilder.ts` | Widen `.minus()` to accept array-returning callbacks; detect array in `toRawInput()` and extract property URIs | +| `src/queries/IRDesugar.ts` | Add `propertyPaths?: string[]` to `RawMinusEntry` and `DesugaredMinusEntry`; thread through | +| `src/queries/IRCanonicalize.ts` | Add `propertyPaths?: string[]` to `CanonicalMinusEntry`; thread through | +| `src/queries/IRLower.ts` | Handle `propertyPaths` case: generate `IRTraversePattern[]` with fresh aliases, wrap in join | +| `src/test-helpers/query-fixtures.ts` | Add `minusPropertyExists` fixture | +| `src/tests/sparql-select-golden.test.ts` | Add golden test | + +**Tasks:** + +1. **QueryBuilder.ts** — Widen the `.minus()` callback type: + - Change signature to `minus(shapeOrFn: ShapeConstructor | WhereClause | MinusPropertyCallback)` + - Where `MinusPropertyCallback = (s: ToQueryBuilderObject) => QueryBuilderObject[]` + - In `toRawInput()`, after calling the callback, check `Array.isArray(result)`: + - If array: extract property URI from each `QueryBuilderObject` via `.property.path[0].id` + - Store as `propertyPaths: string[]` on the `RawMinusEntry` + - Single property `.minus(p => p.hobby)` should also work — detect `result instanceof QueryBuilderObject` and wrap in array + +2. **IRDesugar.ts** — Add `propertyPaths` to entry types: + - `RawMinusEntry`: add `propertyPaths?: string[]` + - `DesugaredMinusEntry`: add `propertyPaths?: string[]` + - In `desugarSelectQuery()`: thread `propertyPaths` through (no transformation needed) + +3. **IRCanonicalize.ts** — Add `propertyPaths` to canonical entry: + - `CanonicalMinusEntry`: add `propertyPaths?: string[]` + - In `canonicalizeDesugaredSelectQuery()`: thread through + +4. **IRLower.ts** — Handle `propertyPaths` in minus lowering: + - In the `canonical.minusEntries` loop, add a new branch: `if (entry.propertyPaths)` + - For each property URI, create an `IRTraversePattern` from `rootAlias` to a fresh `m0`, `m1`, etc. alias + - Wrap in `IRJoinPattern` if multiple, push as `IRMinusPattern` with no filter + +5. **Fixtures + golden tests**: + - `minusPropertyExists: () => Person.select(p => p.name).minus(p => [p.hobby, p.name])` + - `minusSingleProperty: () => Person.select(p => p.name).minus(p => p.hobby)` (single, no array) + - Assert SPARQL output contains `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` + +**Potential issues:** + +1. **Type widening**: The `.minus()` callback type must accept `(p) => QueryBuilderObject | QueryBuilderObject[] | Evaluation`. TypeScript overloads or a union return type handle this. The processing in `toRawInput()` does runtime detection (`instanceof Evaluation` vs `Array.isArray` vs `instanceof QueryBuilderObject`). + +2. **Nested property paths**: `.minus(p => [p.bestFriend.name])` — what should this mean? The ideation only shows flat properties. For now, we support only direct properties of the root shape. If `p.bestFriend.name` is passed, the `property.path[0].id` gives the property on the last segment. We should instead walk `getPropertyPath()` and only support single-segment paths, throwing for multi-segment. + +3. **Variable naming in MINUS block**: Fresh variables are needed that don't collide with the outer query. Using `m0`, `m1`, ... prefix avoids collision with the `a0` root alias. + +**Validation:** +- `npx tsc --noEmit` exits 0 +- `npm test` — all existing tests pass + new minus property golden tests pass +- Verify SPARQL output manually: `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` From 5a0121ee379e0adfcead0a3d5e1f29fd49d0ee29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 04:11:39 +0000 Subject: [PATCH 16/20] docs: revise Phase 6 plan to support nested paths in MINUS multi-property Update the Phase 6 plan to support .minus(p => [p.bestFriend.name]) with nested path traversal, not just flat properties. Reuses existing FieldSet.collectPropertySegments() and createProxiedPathBuilder() for maximum code reuse. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- docs/plans/001-advanced-query-patterns.md | 169 ++++++++++++++++------ 1 file changed, 121 insertions(+), 48 deletions(-) diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index 21154da..c26aa33 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -661,94 +661,167 @@ Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three to --- -### Phase 6: MINUS Multi-Property Existence +### Phase 6: MINUS Multi-Property Existence (with Nested Path Support) -**Goal:** Support `.minus(p => [p.hobby, p.name])` — exclude entities where ALL listed properties exist. +**Goal:** Support `.minus(p => [p.hobby, p.name])` and `.minus(p => [p.bestFriend.name])` — exclude entities where ALL listed property paths exist. **Semantics:** ```ts -// Exclude any Person that has BOTH a hobby AND a name +// Flat: exclude any Person that has BOTH a hobby AND a name Person.select(p => p.name).minus(p => [p.hobby, p.name]) + +// Nested: exclude any Person whose bestFriend has a name +Person.select(p => p.name).minus(p => [p.bestFriend.name]) + +// Mixed: exclude Persons with hobby AND whose bestFriend has a name +Person.select(p => p.name).minus(p => [p.hobby, p.bestFriend.name]) + +// Single property (no array): exclude Persons that have a hobby +Person.select(p => p.name).minus(p => p.hobby) ``` + Generates: ```sparql -SELECT ?a0 ?a0_name WHERE { - ?a0 a . ?a0 ?a0_name . - MINUS { ?a0 ?m0 . ?a0 ?m1 . } -} +-- Flat multi-property +MINUS { ?a0 ?m0 . ?a0 ?m1 . } + +-- Nested path +MINUS { ?a0 ?m0 . ?m0 ?m1 . } + +-- Mixed +MINUS { ?a0 ?m0 . ?a0 ?m1 . ?m1 ?m2 . } ``` **Architecture:** -The `.minus()` callback currently only accepts `WhereClause` (returns `Evaluation`). We need a third callback return type: an **array of `QueryBuilderObject`** instances representing property existence. +The `.minus()` callback currently only accepts `WhereClause` (returns `Evaluation`). We add a third return type: `QueryBuilderObject | QueryBuilderObject[]` for property existence. + +Detection in `toRawInput()` is runtime: `Array.isArray(result)` or `isQueryBuilderObject(result)` vs `result instanceof Evaluation`. The callback proxy (`createProxiedPathBuilder`) already chains `QueryBuilderObject` instances for nested access — `p.bestFriend.name` produces a chain with `.subject → .property` links. + +We extract full property paths using `FieldSet.collectPropertySegments()`, which walks the `.subject` chain and returns `PropertyShape[]` in root-to-leaf order. Each path becomes a `PropertyPathSegment[]` that flows through the pipeline as `propertyPaths: PropertyPathSegment[][]`. -The key insight: we do NOT need to change the WhereClause type or processWhereClause. Instead, we add a **new callback type** to `.minus()` specifically, and process it at the `QueryBuilder.toRawInput()` level — converting property paths into `RawMinusEntry` objects with a new `propertyPaths` field that carries the raw property URIs. This new field then flows through desugar → canonicalize → lower → algebra as a simple BGP of `?a0 ?varN` triples. +**Key reuse:** +- `createProxiedPathBuilder(shape)` — same proxy used by `.select()` callbacks +- `FieldSet.collectPropertySegments(obj)` — extracts `PropertyShape[]` path from QBO chain +- `isQueryBuilderObject()` from FieldSet — duck-type detection +- Existing `IRTraversePattern` chaining — same mechanism used by select/where for nested paths **Data flow:** ``` -QueryBuilder.minus(p => [p.hobby, p.name]) - ↓ toRawInput(): detect array, extract property paths from QueryBuilderObjects -RawMinusEntry { propertyPaths: ['linked://tmp/props/hobby', 'linked://tmp/props/name'] } - ↓ desugarSelectQuery(): pass through (no desugaring needed for plain property URIs) +QueryBuilder.minus(p => [p.bestFriend.name, p.hobby]) + ↓ toRawInput(): detect array, extract paths via collectPropertySegments + ↓ Each QBO → PropertyShape[] → map to {propertyShapeId, ...}[] +RawMinusEntry { + propertyPaths: [ + [{propertyShapeId: 'bestFriend-shape-id'}, {propertyShapeId: 'name-shape-id'}], + [{propertyShapeId: 'hobby-shape-id'}] + ] +} + ↓ desugarSelectQuery(): pass through (property IDs, no transformation needed) DesugaredMinusEntry { propertyPaths: [...] } ↓ canonicalizeDesugaredSelectQuery(): pass through CanonicalMinusEntry { propertyPaths: [...] } - ↓ lowerSelectQuery(): convert to IRMinusPattern with traverse patterns -IRMinusPattern { kind: 'minus', pattern: { kind: 'join', patterns: [traverse, traverse] } } + ↓ lowerSelectQuery(): convert each path to chained IRTraversePatterns +IRMinusPattern { + kind: 'minus', + pattern: { + kind: 'join', + patterns: [ + {kind: 'traverse', from: rootAlias, to: 'm0', property: 'bestFriend-id'}, + {kind: 'traverse', from: 'm0', to: 'm1', property: 'name-id'}, + {kind: 'traverse', from: rootAlias, to: 'm2', property: 'hobby-id'}, + ] + } +} ↓ irToAlgebra step 5b: existing code handles it — no filter, just inner pattern -SparqlMinus { left: ..., right: { bgp: [?a0 ?m0, ?a0 ?m1] } } +SparqlMinus { + left: ..., + right: { bgp: [?a0 ?m0, ?m0 ?m1, ?a0 ?m2] } +} +``` + +**New type:** + +```ts +// A single segment in a property path (used for MINUS property existence) +type PropertyPathSegment = { + propertyShapeId: string; +}; ``` **Files:** | File | Change | |------|--------| -| `src/queries/QueryBuilder.ts` | Widen `.minus()` to accept array-returning callbacks; detect array in `toRawInput()` and extract property URIs | -| `src/queries/IRDesugar.ts` | Add `propertyPaths?: string[]` to `RawMinusEntry` and `DesugaredMinusEntry`; thread through | -| `src/queries/IRCanonicalize.ts` | Add `propertyPaths?: string[]` to `CanonicalMinusEntry`; thread through | -| `src/queries/IRLower.ts` | Handle `propertyPaths` case: generate `IRTraversePattern[]` with fresh aliases, wrap in join | -| `src/test-helpers/query-fixtures.ts` | Add `minusPropertyExists` fixture | -| `src/tests/sparql-select-golden.test.ts` | Add golden test | +| `src/queries/QueryBuilder.ts` | Widen `.minus()` to accept property-returning callbacks; detect QBO/array in `toRawInput()`, extract paths via `collectPropertySegments()` | +| `src/queries/IRDesugar.ts` | Add `PropertyPathSegment` type; add `propertyPaths?: PropertyPathSegment[][]` to `RawMinusEntry` and `DesugaredMinusEntry`; thread through | +| `src/queries/IRCanonicalize.ts` | Add `propertyPaths?: PropertyPathSegment[][]` to `CanonicalMinusEntry`; thread through | +| `src/queries/IRLower.ts` | Handle `propertyPaths` case: generate chained `IRTraversePattern` sequences per path, wrap in join | +| `src/test-helpers/query-fixtures.ts` | Add `minusMultiProperty`, `minusNestedPath`, `minusMixed` fixtures | +| `src/tests/sparql-select-golden.test.ts` | Add golden tests for all three | **Tasks:** -1. **QueryBuilder.ts** — Widen the `.minus()` callback type: - - Change signature to `minus(shapeOrFn: ShapeConstructor | WhereClause | MinusPropertyCallback)` - - Where `MinusPropertyCallback = (s: ToQueryBuilderObject) => QueryBuilderObject[]` - - In `toRawInput()`, after calling the callback, check `Array.isArray(result)`: - - If array: extract property URI from each `QueryBuilderObject` via `.property.path[0].id` - - Store as `propertyPaths: string[]` on the `RawMinusEntry` - - Single property `.minus(p => p.hobby)` should also work — detect `result instanceof QueryBuilderObject` and wrap in array - -2. **IRDesugar.ts** — Add `propertyPaths` to entry types: - - `RawMinusEntry`: add `propertyPaths?: string[]` - - `DesugaredMinusEntry`: add `propertyPaths?: string[]` - - In `desugarSelectQuery()`: thread `propertyPaths` through (no transformation needed) - -3. **IRCanonicalize.ts** — Add `propertyPaths` to canonical entry: - - `CanonicalMinusEntry`: add `propertyPaths?: string[]` +1. **IRDesugar.ts** — Add `PropertyPathSegment` type and thread `propertyPaths`: + - Add type: `PropertyPathSegment = { propertyShapeId: string }` + - `RawMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` + - `DesugaredMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` + - In `desugarSelectQuery()`: thread `propertyPaths` through (no transformation) + +2. **IRCanonicalize.ts** — Thread `propertyPaths`: + - `CanonicalMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` - In `canonicalizeDesugaredSelectQuery()`: thread through +3. **QueryBuilder.ts** — Widen `.minus()` and add path extraction: + - Change `MinusEntry` to add `propertyPaths?: PropertyPathSegment[][]` + - In `toRawInput()`, change the minus entry processing: + - For `entry.whereFn`: call the callback via `createProxiedPathBuilder`, then inspect the result + - If result is `Evaluation` → existing WHERE-based path (unchanged) + - If result is array → map each element through `FieldSet.collectPropertySegments()` → `PropertyShape[]` → map to `PropertyPathSegment[]` + - If result is single QBO (not array, not Evaluation) → wrap in array, same as above + - Store as `propertyPaths` on the `RawMinusEntry` + - No signature change needed — `.minus()` already accepts `WhereClause` which is `(s) => Evaluation`. The callback type just becomes more permissive at runtime (returns QBO/array instead of Evaluation). TypeScript type can be widened with a union or overloads. + 4. **IRLower.ts** — Handle `propertyPaths` in minus lowering: - - In the `canonical.minusEntries` loop, add a new branch: `if (entry.propertyPaths)` - - For each property URI, create an `IRTraversePattern` from `rootAlias` to a fresh `m0`, `m1`, etc. alias + - In the `canonical.minusEntries` loop, add a branch: `if (entry.propertyPaths)` + - For each path (array of segments): + - Chain traverse patterns: first segment from `rootAlias`, each subsequent from previous alias + - Use `ctx.generateAlias()` for fresh aliases (`m0`, `m1`, ...) + - Collect all traverse patterns from all paths into one array - Wrap in `IRJoinPattern` if multiple, push as `IRMinusPattern` with no filter 5. **Fixtures + golden tests**: - - `minusPropertyExists: () => Person.select(p => p.name).minus(p => [p.hobby, p.name])` - - `minusSingleProperty: () => Person.select(p => p.name).minus(p => p.hobby)` (single, no array) - - Assert SPARQL output contains `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` + - `minusMultiProperty`: `Person.select(p => p.name).minus(p => [p.hobby, p.name])` + - Expected: `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` + - `minusNestedPath`: `Person.select(p => p.name).minus(p => [p.bestFriend.name])` + - Expected: `MINUS { ?a0 ?m0 . ?m0 ?m1 . }` + - `minusSingleProperty`: `Person.select(p => p.name).minus(p => p.hobby)` (single, no array) + - Expected: `MINUS { ?a0 ?m0 . }` (same as existing `minusProperty` but via property existence path) + +**Potential issues and mitigations:** + +1. **Runtime type detection**: The callback can return `Evaluation | QueryBuilderObject | QueryBuilderObject[]`. Detection order: `Array.isArray()` first, then `isQueryBuilderObject()` (duck-typed via `'property' in obj`), then fall through to `Evaluation` (has `.getWherePath()`). This is safe because `Evaluation` does NOT have a `property` field, and `QueryBuilderObject` does NOT have `getWherePath()`. -**Potential issues:** +2. **TypeScript type for `.minus()` callback**: The `WhereClause` type returns `Evaluation`. We need to accept `(s) => Evaluation | QueryBuilderObject | QueryBuilderObject[]`. Two options: + - **Overloads**: separate signatures for condition vs property existence + - **Union return type**: `(s) => Evaluation | any` (since QBO is internal, accepting `any` return from the callback and detecting at runtime) + - Recommend **overloads** for better IntelliSense: + ```ts + minus(shape: ShapeConstructor): QueryBuilder; + minus(fn: WhereClause): QueryBuilder; + minus(fn: (s: ToQueryBuilderObject) => ToQueryBuilderObject[keyof ToQueryBuilderObject] | ToQueryBuilderObject[keyof ToQueryBuilderObject][]): QueryBuilder; + ``` -1. **Type widening**: The `.minus()` callback type must accept `(p) => QueryBuilderObject | QueryBuilderObject[] | Evaluation`. TypeScript overloads or a union return type handle this. The processing in `toRawInput()` does runtime detection (`instanceof Evaluation` vs `Array.isArray` vs `instanceof QueryBuilderObject`). +3. **Alias collision**: MINUS variables use `ctx.generateAlias()` which auto-increments — same counter as the outer query. No collision possible since the counter is shared. -2. **Nested property paths**: `.minus(p => [p.bestFriend.name])` — what should this mean? The ideation only shows flat properties. For now, we support only direct properties of the root shape. If `p.bestFriend.name` is passed, the `property.path[0].id` gives the property on the last segment. We should instead walk `getPropertyPath()` and only support single-segment paths, throwing for multi-segment. +4. **Existing single-property `.minus(p => p.hobby)` behavior**: Currently this goes through the WHERE clause path (returns `Evaluation` because accessing `.hobby` on the proxy returns a `QueryBuilderObject` which... wait, no — currently `.minus(p => p.hobby)` is treated as a WhereClause, and `p.hobby` returns a `QueryBuilderObject`. The callback result is then passed to `processWhereClause()` which expects `Evaluation`. Need to verify the current behavior of the single-property case. + - **Resolution**: The existing `minusProperty` fixture `Person.select(p => p.name).minus(p => p.hobby)` already works via the WHERE path — `p.hobby` returns a `QueryBuilderObject` which has a `.getWherePath()` method that `processWhereClause` calls. So the single-property case already works. The new array case is strictly additive. No need to change single-property behavior. -3. **Variable naming in MINUS block**: Fresh variables are needed that don't collide with the outer query. Using `m0`, `m1`, ... prefix avoids collision with the `a0` root alias. +5. **Shared traversal aliases across paths**: When multiple paths share a prefix (e.g., `p.bestFriend.name` and `p.bestFriend.hobby`), each path gets independent aliases. This is correct for SPARQL — the joins will naturally unify on the shared prefix through the BGP matching. No deduplication needed. **Validation:** - `npx tsc --noEmit` exits 0 -- `npm test` — all existing tests pass + new minus property golden tests pass -- Verify SPARQL output manually: `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` +- `npm test` — all existing tests pass (especially existing `minusProperty` test unchanged) +- New golden tests pass with correct SPARQL output +- Verify nested path SPARQL manually: `MINUS { ?a0 ?m0 . ?m0 ?m1 . }` From ec99875119a1e84230b41b5482da38b606b597c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 05:15:03 +0000 Subject: [PATCH 17/20] feat: implement MINUS multi-property with nested path support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .minus(p => [p.hobby, p.bestFriend.name]) syntax for property existence exclusion in SPARQL MINUS blocks. Supports: - Multi-property: .minus(p => [p.hobby, p.name]) - Nested paths: .minus(p => [p.bestFriend.name]) - Mixed: .minus(p => [p.hobby, p.bestFriend.name]) - Single property (no array): .minus(p => p.hobby) Reuses existing FieldSet.collectPropertySegments() for path extraction and createProxiedPathBuilder() for proxy creation. Threads PropertyPathSegment[][] through IRDesugar → IRCanonicalize → IRLower, where segments are converted to chained IRTraversePatterns. 4 new golden tests with exact SPARQL string matching. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/IRCanonicalize.ts | 3 ++ src/queries/IRDesugar.ts | 8 +++ src/queries/IRLower.ts | 21 ++++++++ src/queries/QueryBuilder.ts | 35 +++++++++++-- src/test-helpers/query-fixtures.ts | 16 ++++++ src/tests/sparql-select-golden.test.ts | 68 ++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/queries/IRCanonicalize.ts b/src/queries/IRCanonicalize.ts index aaa5893..7dfc475 100644 --- a/src/queries/IRCanonicalize.ts +++ b/src/queries/IRCanonicalize.ts @@ -5,6 +5,7 @@ import { DesugaredWhereArg, DesugaredWhereBoolean, DesugaredWhereComparison, + PropertyPathSegment, } from './IRDesugar.js'; import {WhereMethods} from './SelectQuery.js'; @@ -42,6 +43,7 @@ export type CanonicalWhereExpression = export type CanonicalMinusEntry = { shapeId?: string; where?: CanonicalWhereExpression; + propertyPaths?: PropertyPathSegment[][]; }; export type CanonicalDesugaredSelectQuery = Omit & { @@ -202,6 +204,7 @@ export const canonicalizeDesugaredSelectQuery = ( minusEntries: query.minusEntries?.map((entry) => ({ shapeId: entry.shapeId, where: entry.where ? canonicalizeWhere(entry.where) : undefined, + propertyPaths: entry.propertyPaths, })), }; }; diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index b982916..405ff75 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -18,10 +18,16 @@ import type {PropertyShape} from '../shapes/SHACL.js'; /** * Pipeline input type — accepts FieldSet entries directly. */ +/** A single segment in a property path (used for MINUS property existence). */ +export type PropertyPathSegment = { + propertyShapeId: string; +}; + /** A raw MINUS entry before desugaring. */ export type RawMinusEntry = { shapeId?: string; where?: WherePath; + propertyPaths?: PropertyPathSegment[][]; }; export type RawSelectInput = { @@ -129,6 +135,7 @@ export type DesugaredWhereArg = export type DesugaredMinusEntry = { shapeId?: string; where?: DesugaredWhere; + propertyPaths?: PropertyPathSegment[][]; }; export type DesugaredSelectQuery = { @@ -428,6 +435,7 @@ export const desugarSelectQuery = (query: RawSelectInput): DesugaredSelectQuery minusEntries: query.minusEntries?.map((entry) => ({ shapeId: entry.shapeId, where: entry.where ? toWhere(entry.where) : undefined, + propertyPaths: entry.propertyPaths, })), }; }; diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 62c5caf..6046cd3 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -367,6 +367,27 @@ export const lowerSelectQuery = ( kind: 'minus', pattern: {kind: 'shape_scan', shape: entry.shapeId, alias: ctx.rootAlias}, }); + } else if (entry.propertyPaths && entry.propertyPaths.length > 0) { + // Property existence exclusion: MINUS { ?a0 ?m0 . ?a0 ?m1 . } + // Supports nested paths: ?a0 ?m0 . ?m0 ?m1 . + const traversals: IRTraversePattern[] = []; + for (const path of entry.propertyPaths) { + let currentAlias = ctx.rootAlias; + for (const segment of path) { + const toAlias = ctx.generateAlias(); + traversals.push({ + kind: 'traverse', + from: currentAlias, + to: toAlias, + property: segment.propertyShapeId, + }); + currentAlias = toAlias; + } + } + const innerPattern: IRGraphPattern = traversals.length === 1 + ? traversals[0] + : {kind: 'join', patterns: traversals}; + minusPatterns.push({kind: 'minus', pattern: innerPattern}); } else if (entry.where) { // Condition-based exclusion: MINUS { ?a0 ?val . FILTER(...) } const minusTraversals: IRTraversePattern[] = []; diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index 700b62d..dd6d546 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -12,11 +12,12 @@ import { evaluateSortCallback, } from './SelectQuery.js'; import type {SortByPath, WherePath} from './SelectQuery.js'; -import type {RawSelectInput} from './IRDesugar.js'; +import type {PropertyPathSegment, RawSelectInput} from './IRDesugar.js'; import {buildSelectQuery} from './IRPipeline.js'; import {getQueryDispatch} from './queryDispatch.js'; import type {NodeReferenceValue} from './QueryFactory.js'; import {FieldSet, FieldSetJSON, FieldSetFieldJSON} from './FieldSet.js'; +import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; /** JSON representation of a QueryBuilder. */ export type QueryBuilderJSON = { @@ -188,10 +189,15 @@ export class QueryBuilder /** * Exclude results matching a MINUS pattern. * - * Accepts a shape constructor (exclude by type) or a WHERE callback (exclude by condition). + * Accepts: + * - A shape constructor to exclude by type: `.minus(Employee)` + * - A WHERE callback to exclude by condition: `.minus(p => p.hobby.equals('Chess'))` + * - A callback returning a property or array of properties for existence exclusion: + * `.minus(p => p.hobby)` or `.minus(p => [p.hobby, p.bestFriend.name])` + * * Chainable: `.minus(A).minus(B)` produces two separate `MINUS { }` blocks. */ - minus(shapeOrFn: ShapeConstructor | WhereClause): QueryBuilder { + minus(shapeOrFn: ShapeConstructor | WhereClause | ((s: any) => any)): QueryBuilder { const entry: MinusEntry = {}; if (typeof shapeOrFn === 'function' && 'shape' in shapeOrFn) { // ShapeConstructor — has a static .shape property @@ -463,13 +469,34 @@ export class QueryBuilder input.subjects = this._subjects; } - // Process minus entries → convert callbacks to WherePaths + // Process minus entries → convert callbacks to WherePaths or property paths if (this._minusEntries && this._minusEntries.length > 0) { input.minusEntries = this._minusEntries.map((entry) => { if (entry.shapeId) { return {shapeId: entry.shapeId}; } if (entry.whereFn) { + // Call the callback through the proxy and inspect the result type + const proxy = createProxiedPathBuilder(this._shape); + const result = (entry.whereFn as Function)(proxy); + + // Array of QBOs → property existence paths + if (Array.isArray(result)) { + const propertyPaths = result.map((item: any) => { + const segments = FieldSet.collectPropertySegments(item); + return segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id})); + }); + return {propertyPaths}; + } + + // Single QBO (has .property field) → single property existence path + if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { + const segments = FieldSet.collectPropertySegments(result); + const propertyPaths = [segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id}))]; + return {propertyPaths}; + } + + // Evaluation → existing WHERE-based path return {where: processWhereClause(entry.whereFn, this._shape)}; } return {}; diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index d65eaa1..038c500 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -458,6 +458,22 @@ export const queryFactories = { minusChained: () => Person.select((p) => p.name).minus(Employee).minus((p) => p.hobby.equals('Chess')), + // MINUS multi-property — exclude where ALL listed properties exist + minusMultiProperty: () => + Person.select((p) => p.name).minus((p) => [p.hobby, p.nickNames]), + + // MINUS nested path — exclude where nested property path exists + minusNestedPath: () => + Person.select((p) => p.name).minus((p) => [p.bestFriend.name]), + + // MINUS mixed — flat + nested in one call + minusMixed: () => + Person.select((p) => p.name).minus((p) => [p.hobby, p.bestFriend.name]), + + // MINUS single property existence (no array, returns raw QBO) + minusSingleProperty: () => + Person.select((p) => p.name).minus((p) => p.hobby), + // --- Bulk delete tests --- // Delete all instances of a shape diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index 1e1cd84..f245f21 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -1113,4 +1113,72 @@ describe('SPARQL golden — MINUS patterns', () => { expect(sparql).toContain(`rdf:type <${E}>`); expect(sparql).toContain('"Chess"'); }); + + test('minusMultiProperty — exclude where multiple properties exist', async () => { + const sparql = await goldenSelect(queryFactories.minusMultiProperty); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + MINUS { + ?a0 <${P}/hobby> ?a1 . + ?a0 <${P}/nickNames> ?a2 . + } +}`); + }); + + test('minusNestedPath — exclude where nested property path exists', async () => { + const sparql = await goldenSelect(queryFactories.minusNestedPath); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + MINUS { + ?a0 <${P}/bestFriend> ?a1 . + ?a1 <${P}/name> ?a2 . + } +}`); + }); + + test('minusMixed — flat and nested in one MINUS block', async () => { + const sparql = await goldenSelect(queryFactories.minusMixed); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + MINUS { + ?a0 <${P}/hobby> ?a1 . + ?a0 <${P}/bestFriend> ?a2 . + ?a2 <${P}/name> ?a3 . + } +}`); + }); + + test('minusSingleProperty — single property existence (no array)', async () => { + const sparql = await goldenSelect(queryFactories.minusSingleProperty); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + MINUS { + ?a0 <${P}/hobby> ?a1 . + } +}`); + }); }); From 669df80458bd5139bedc69fb24efbd11cb0bacf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 06:50:29 +0000 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20cleanup=20=E2=80=94=20depreca?= =?UTF-8?q?te=20sortBy,=20require=20update=20data,=20simplify=20delete=20A?= =?UTF-8?q?PI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Deprecate sortBy() on QueryBuilder — use orderBy() instead. Updated all fixtures and tests to use orderBy. 2. Require data in Shape.update() — removed no-arg overload, data is now a required parameter. UpdateBuilder.set() jsdoc updated to "Replace the update data". 3. Simplify delete API — removed .for() from DeleteBuilder. ID-based deletes use DeleteBuilder.from(shape, ids). deleteAll() is strictly no-arg (null cannot trigger it). deleteWhere(fn) for conditional. https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- src/queries/DeleteBuilder.ts | 10 ++------ src/queries/QueryBuilder.ts | 2 +- src/queries/UpdateBuilder.ts | 2 +- src/shapes/Shape.ts | 8 ++----- src/test-helpers/query-fixtures.ts | 4 ++-- src/tests/mutation-builder.test.ts | 34 +++++++-------------------- src/tests/query-builder.test.ts | 6 ++--- src/tests/query-builder.types.test.ts | 4 ++-- 8 files changed, 22 insertions(+), 48 deletions(-) diff --git a/src/queries/DeleteBuilder.ts b/src/queries/DeleteBuilder.ts index 2ff6aa4..755142c 100644 --- a/src/queries/DeleteBuilder.ts +++ b/src/queries/DeleteBuilder.ts @@ -29,7 +29,7 @@ interface DeleteBuilderInit { * * Implements PromiseLike so mutations execute on `await`: * ```ts - * const result = await DeleteBuilder.from(Person).for({id: '...'}); + * const result = await DeleteBuilder.from(Person, {id: '...'}); * await DeleteBuilder.from(Person).all(); // returns void * ``` * @@ -80,12 +80,6 @@ export class DeleteBuilder // Fluent API // --------------------------------------------------------------------------- - /** Specify the target IDs to delete. */ - for(ids: NodeId | NodeId[]): DeleteBuilder { - const idsArray = Array.isArray(ids) ? ids : [ids]; - return this.clone({ids: idsArray, mode: 'ids'}) as DeleteBuilder; - } - /** Delete all instances of this shape type. Returns void. */ all(): DeleteBuilder { return this.clone({mode: 'all', ids: undefined, whereFn: undefined}) as DeleteBuilder; @@ -130,7 +124,7 @@ export class DeleteBuilder // Default: ID-based delete if (!this._ids || this._ids.length === 0) { throw new Error( - 'DeleteBuilder requires at least one ID to delete. Specify targets with .for(ids), .all(), or .where().', + 'DeleteBuilder requires at least one ID to delete. Use DeleteBuilder.from(shape, ids), .all(), or .where().', ); } const factory = new DeleteQueryFactory( diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index dd6d546..c8a11ef 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -216,7 +216,7 @@ export class QueryBuilder } /** - * Alias for orderBy — matches the existing DSL's `sortBy` method name. + * @deprecated Use `orderBy()` instead. */ sortBy(fn: QueryBuildFn, direction: 'ASC' | 'DESC' = 'ASC'): QueryBuilder { return this.orderBy(fn, direction); diff --git a/src/queries/UpdateBuilder.ts b/src/queries/UpdateBuilder.ts index e86db45..847fb87 100644 --- a/src/queries/UpdateBuilder.ts +++ b/src/queries/UpdateBuilder.ts @@ -92,7 +92,7 @@ export class UpdateBuilder = return this.clone({mode: 'where', whereFn: fn, targetId: undefined}) as unknown as UpdateBuilder; } - /** Set the update data. */ + /** Replace the update data. */ set>(data: NewU): UpdateBuilder { return this.clone({data}) as unknown as UpdateBuilder; } diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index a19d6f2..d828f22 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -155,13 +155,9 @@ export abstract class Shape { */ static update>( this: ShapeConstructor, - data?: U, + data: U, ): UpdateBuilder { - let builder = UpdateBuilder.from(this) as UpdateBuilder; - if (data) { - builder = builder.set(data); - } - return builder as unknown as UpdateBuilder; + return UpdateBuilder.from(this).set(data) as unknown as UpdateBuilder; } static create>( diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index 038c500..0791e6a 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -311,9 +311,9 @@ export const queryFactories = { Person.select((p) => p.name) .where((p) => p.name.equals('Semmy').or(p.name.equals('Moa'))) .limit(1), - sortByAsc: () => Person.select((p) => p.name).sortBy((p) => p.name), + sortByAsc: () => Person.select((p) => p.name).orderBy((p) => p.name), sortByDesc: () => - Person.select((p) => p.name).sortBy((p) => p.name, 'DESC'), + Person.select((p) => p.name).orderBy((p) => p.name, 'DESC'), updateSimple: () => Person.update(updateSimple).for(entity('p1')), createSimple: () => Person.create({name: 'Test Create', hobby: 'Chess'}), createWithFriends: () => diff --git a/src/tests/mutation-builder.test.ts b/src/tests/mutation-builder.test.ts index ac3ee08..cc30e16 100644 --- a/src/tests/mutation-builder.test.ts +++ b/src/tests/mutation-builder.test.ts @@ -124,29 +124,13 @@ describe('UpdateBuilder — IR equivalence', () => { // ============================================================================= describe('DeleteBuilder — IR equivalence', () => { - test('delete — single via .for()', async () => { - const dslIR = await captureDslIR(() => Person.delete(entity('to-delete'))); - const builderIR = DeleteBuilder.from(Person).for(entity('to-delete')).build(); - expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); - }); - - test('delete — multiple via .for()', async () => { - const dslIR = await captureDslIR(() => - Person.delete([entity('to-delete-1'), entity('to-delete-2')]), - ); - const builderIR = DeleteBuilder.from(Person) - .for([entity('to-delete-1'), entity('to-delete-2')]) - .build(); - expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); - }); - - test('delete — single via .from() (backwards compat)', async () => { + test('delete — single via .from(shape, id)', async () => { const dslIR = await captureDslIR(() => Person.delete(entity('to-delete'))); const builderIR = DeleteBuilder.from(Person, entity('to-delete')).build(); expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); }); - test('delete — multiple via .from() (backwards compat)', async () => { + test('delete — multiple via .from(shape, ids)', async () => { const dslIR = await captureDslIR(() => Person.delete([entity('to-delete-1'), entity('to-delete-2')]), ); @@ -175,9 +159,9 @@ describe('Mutation builders — immutability', () => { expect(b1).not.toBe(b2); }); - test('DeleteBuilder — .for() returns new instance', () => { + test('DeleteBuilder — .from() with ids returns new instance', () => { const b1 = DeleteBuilder.from(Person); - const b2 = b1.for(entity('to-delete')); + const b2 = DeleteBuilder.from(Person, entity('to-delete')); expect(b1).not.toBe(b2); }); @@ -214,13 +198,13 @@ describe('Mutation builders — guards', () => { expect(() => builder.build()).toThrow(/requires .set/); }); - test('DeleteBuilder — .build() without .for() throws', () => { + test('DeleteBuilder — .build() without ids throws', () => { const builder = DeleteBuilder.from(Person); expect(() => builder.build()).toThrow(/requires at least one ID/); }); - test('DeleteBuilder — .build() with empty .for() throws', () => { - const builder = DeleteBuilder.from(Person).for([] as any); + test('DeleteBuilder — .build() with empty ids throws', () => { + const builder = DeleteBuilder.from(Person, [] as any); expect(() => builder.build()).toThrow(/requires at least one ID/); }); }); @@ -241,7 +225,7 @@ describe('Mutation builders — PromiseLike', () => { }); test('DeleteBuilder has .then()', () => { - const builder = DeleteBuilder.from(Person).for(entity('to-delete')); + const builder = DeleteBuilder.from(Person, entity('to-delete')); expect(typeof builder.then).toBe('function'); }); @@ -251,7 +235,7 @@ describe('Mutation builders — PromiseLike', () => { }); test('DeleteBuilder await triggers execution', async () => { - const result = await DeleteBuilder.from(Person).for(entity('to-delete')); + const result = await DeleteBuilder.from(Person, entity('to-delete')); expect(result).toEqual({deleted: [], count: 0}); }); }); diff --git a/src/tests/query-builder.test.ts b/src/tests/query-builder.test.ts index 7c79777..a8dd646 100644 --- a/src/tests/query-builder.test.ts +++ b/src/tests/query-builder.test.ts @@ -164,7 +164,7 @@ describe('QueryBuilder — IR equivalence with DSL', () => { test('sortByAsc', async () => { const dslIR = await captureDslIR(() => - Person.select((p) => p.name).sortBy((p) => p.name), + Person.select((p) => p.name).orderBy((p) => p.name), ); const builderIR = QueryBuilder.from(Person) .select((p) => p.name) @@ -570,9 +570,9 @@ describe('Person.update(data).for(id) chaining', () => { expect(ir).toBeDefined(); }); - test('Person.update().for(id).set(data) produces same IR as update(data).for(id)', () => { + test('UpdateBuilder.from().for(id).set(data) produces same IR as update(data).for(id)', () => { const ir1 = Person.update({hobby: 'Chess'}).for(entity('p1')).build(); - const ir2 = Person.update().for(entity('p1')).set({hobby: 'Chess'}).build(); + const ir2 = UpdateBuilder.from(Person).for(entity('p1')).set({hobby: 'Chess'}).build(); expect(sanitize(ir1)).toEqual(sanitize(ir2)); }); diff --git a/src/tests/query-builder.types.test.ts b/src/tests/query-builder.types.test.ts index 33db279..3b66ff9 100644 --- a/src/tests/query-builder.types.test.ts +++ b/src/tests/query-builder.types.test.ts @@ -139,10 +139,10 @@ describe.skip('QueryBuilder result type inference (compile only)', () => { expectType(first.name); }); - test('select with sortBy preserves result type', () => { + test('select with orderBy preserves result type', () => { const qb = QueryBuilder.from(Person) .select((p) => p.name) - .sortBy((p) => p.name); + .orderBy((p) => p.name); type Result = Awaited; const first = (null as unknown as Result)[0]; expectType(first.name); From d3c1e918b2a63240ddbf3cb550ec43fa1e019c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 07:03:25 +0000 Subject: [PATCH 19/20] docs: add wrapup report, changeset, and review section for advanced query patterns https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- .changeset/advanced-query-patterns.md | 22 ++ docs/plans/001-advanced-query-patterns.md | 25 ++ docs/reports/009-advanced-query-patterns.md | 269 ++++++++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 .changeset/advanced-query-patterns.md create mode 100644 docs/reports/009-advanced-query-patterns.md diff --git a/.changeset/advanced-query-patterns.md b/.changeset/advanced-query-patterns.md new file mode 100644 index 0000000..b2f6b4c --- /dev/null +++ b/.changeset/advanced-query-patterns.md @@ -0,0 +1,22 @@ +--- +"@_linked/core": patch +--- + +Add MINUS support on QueryBuilder with multiple call styles: +- `.minus(Shape)` — exclude by shape type +- `.minus(p => p.prop.equals(val))` — exclude by condition +- `.minus(p => p.prop)` — exclude by property existence +- `.minus(p => [p.prop1, p.nested.prop2])` — exclude by multi-property existence with nested path support + +Add bulk delete operations: +- `Shape.deleteAll()` / `DeleteBuilder.from(Shape).all()` — delete all instances with schema-aware blank node cleanup +- `Shape.deleteWhere(fn)` / `DeleteBuilder.from(Shape).where(fn)` — conditional delete + +Add conditional update operations: +- `.update(data).where(fn)` — update matching instances +- `.update(data).forAll()` — update all instances + +API cleanup: +- Deprecate `sortBy()` in favor of `orderBy()` +- Remove `DeleteBuilder.for()` — use `DeleteBuilder.from(shape, ids)` instead +- Require `data` parameter in `Shape.update(data)` diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md index c26aa33..6881b66 100644 --- a/docs/plans/001-advanced-query-patterns.md +++ b/docs/plans/001-advanced-query-patterns.md @@ -825,3 +825,28 @@ type PropertyPathSegment = { - `npm test` — all existing tests pass (especially existing `minusProperty` test unchanged) - New golden tests pass with correct SPARQL output - Verify nested path SPARQL manually: `MINUS { ?a0 ?m0 . ?m0 ?m1 . }` + +--- + +## REVIEW + +### Wrapup Outcomes + +All 6 phases completed successfully. 18 commits on branch `claude/setup-and-summarize-GQoTY`. + +**Implemented scope:** +1. **Phase 1**: IR types & contracts for MINUS, bulk delete, conditional update +2. **Phase 2**: `.minus()` on QueryBuilder (shape, property, condition, chained) +3. **Phase 3**: Bulk delete (`.deleteAll()`, `.delete().all()`, `.delete().where()`, `.deleteWhere()`) +4. **Phase 4**: Conditional update (`.update().where()`, `.update().forAll()`) +5. **Phase 5**: Integration verification (full compile + test suite) +6. **Phase 6**: MINUS multi-property with nested path support (`.minus(p => [p.hobby, p.name])`, `.minus(p => [p.bestFriend.name])`) + +**Cleanup tasks (commit `669df80`):** +- Deprecated `sortBy` in favor of `orderBy` +- Made `data` required in `Shape.update()` +- Simplified delete API: removed `.for()` from DeleteBuilder, `Person.delete(id)` / `Person.deleteAll()` / `Person.deleteWhere(fn)` + +**Test results:** 644 tests pass, 0 failures. TypeScript clean. + +**PR-readiness:** Ready for review. Report doc created at `docs/reports/009-advanced-query-patterns.md`. diff --git a/docs/reports/009-advanced-query-patterns.md b/docs/reports/009-advanced-query-patterns.md new file mode 100644 index 0000000..ded46d9 --- /dev/null +++ b/docs/reports/009-advanced-query-patterns.md @@ -0,0 +1,269 @@ +# Report: Advanced Query Patterns + +Implements three core features plus cleanup for the linked data DSL: +1. **MINUS** — `.minus()` on `QueryBuilder` +2. **Bulk Delete** — `.deleteAll()`, `.deleteWhere()`, `.delete().where()` +3. **Conditional Update** — `.update().where()`, `.update().forAll()` +4. **MINUS Multi-Property** — `.minus(p => [p.hobby, p.name])` with nested path support +5. **API Cleanup** — deprecate `sortBy`, require `update()` data, simplify delete API + +Named graphs: deferred (out of scope). See [ideation doc](../ideas/007-advanced-query-patterns.md). + +--- + +## Architecture Overview + +All features follow the same pipeline: + +``` +DSL (Builder) → IR AST → SPARQL Algebra → SPARQL String +``` + +Each feature adds: +- **Builder method(s)** — new chainable methods on existing builders +- **IR type(s)** — new variant(s) in the IR union +- **Algebra conversion** — new case(s) in `irToAlgebra.ts` +- **Serialization** — reuses existing `algebraToString.ts` (no changes needed for any feature) + +--- + +## File Structure + +### Core IR Types + +| File | Responsibility | +|------|---------------| +| `src/queries/IntermediateRepresentation.ts` | `IRMinusPattern`, `IRDeleteAllMutation`, `IRDeleteWhereMutation`, `IRUpdateWhereMutation` types added to existing unions | +| `src/queries/IRMutation.ts` | Canonical IR builder functions: `buildCanonicalDeleteAllMutationIR`, `buildCanonicalDeleteWhereMutationIR`, `buildCanonicalUpdateWhereMutationIR` | +| `src/queries/DeleteQuery.ts` | Widened `DeleteQuery` = `IRDeleteMutation \| IRDeleteAllMutation \| IRDeleteWhereMutation` | +| `src/queries/UpdateQuery.ts` | Widened `UpdateQuery` = `IRUpdateMutation \| IRUpdateWhereMutation` | + +### MINUS Pipeline (Phases 2 + 6) + +| File | Responsibility | +|------|---------------| +| `src/queries/QueryBuilder.ts` | `.minus()` method — accepts `ShapeConstructor`, `WhereClause`, or property-returning callback. Runtime type detection dispatches to shape scan, where clause, or property path extraction | +| `src/queries/IRDesugar.ts` | `PropertyPathSegment` type; `RawMinusEntry` and `DesugaredMinusEntry` with `propertyPaths` field; threads property paths through desugaring untransformed | +| `src/queries/IRCanonicalize.ts` | `CanonicalMinusEntry` with `propertyPaths` field; threads through canonicalization untransformed | +| `src/queries/IRLower.ts` | Converts `propertyPaths` to chained `IRTraversePattern` sequences; handles `shapeId` → shape scan, `propertyPaths` → traverse chains, `where` → filter-based MINUS | +| `src/sparql/irToAlgebra.ts` | `IRMinusPattern` → `SparqlMinus { left, right }` in `selectToAlgebra` | + +### Bulk Delete Pipeline (Phase 3) + +| File | Responsibility | +|------|---------------| +| `src/queries/DeleteBuilder.ts` | `mode` field (`'ids' \| 'all' \| 'where'`), `.all()` and `.where()` methods, `build()` dispatches by mode | +| `src/shapes/Shape.ts` | `Shape.deleteAll()` and `Shape.deleteWhere(fn)` static methods | +| `src/sparql/irToAlgebra.ts` | `deleteAllToAlgebra()` with `walkBlankNodeTree()` for schema-aware blank node cleanup; `deleteWhereToAlgebra()` adds filter conditions | + +### Conditional Update Pipeline (Phase 4) + +| File | Responsibility | +|------|---------------| +| `src/queries/UpdateBuilder.ts` | `mode` field (`'for' \| 'forAll' \| 'where'`), `.forAll()` and `.where()` methods, `buildUpdateWhere()` shared helper | +| `src/sparql/irToAlgebra.ts` | `updateWhereToAlgebra()` — shared field processing helper extracted from `updateToAlgebra()`, parameterized by subject term (IRI vs variable) | + +### Dispatch + +| File | Responsibility | +|------|---------------| +| `src/sparql/SparqlStore.ts` | Routes `deleteQuery()` by `kind`: `'delete'`, `'delete_all'`, `'delete_where'`; routes `updateQuery()` by `kind`: `'update'`, `'update_where'` | + +--- + +## Public API Surface + +### New QueryBuilder method + +```ts +// Exclude by shape type +Person.select(p => p.name).minus(Employee) + +// Exclude by condition +Person.select(p => p.name).minus(p => p.hobby.equals('Chess')) + +// Exclude by property existence (single) +Person.select(p => p.name).minus(p => p.hobby) + +// Exclude by property existence (multi, flat) +Person.select(p => p.name).minus(p => [p.hobby, p.name]) + +// Exclude by property existence (nested path) +Person.select(p => p.name).minus(p => [p.bestFriend.name]) + +// Chained — two separate MINUS blocks +Person.select(p => p.name).minus(Employee).minus(p => p.hobby) +``` + +### New Delete methods + +```ts +// Delete all instances +Person.deleteAll() // static sugar → DeleteBuilder + +// Conditional delete +Person.deleteWhere(p => p.status.equals('inactive')) // static sugar +DeleteBuilder.from(Person).all() // builder equivalent +DeleteBuilder.from(Person).where(p => ...) // builder equivalent + +// ID-based (simplified — .for() removed) +Person.delete('id-1') // single ID +Person.delete(['id-1', 'id-2']) // multiple IDs +DeleteBuilder.from(Person, 'id-1') // builder equivalent +``` + +### New Update methods + +```ts +// Conditional update +Person.update({status: 'archived'}).where(p => p.status.equals('inactive')) + +// Bulk update all +Person.update({verified: true}).forAll() + +// Existing by-ID (unchanged) +Person.update({name: 'Bob'}).for('id-1') +``` + +### Deprecated + +```ts +// sortBy — use orderBy instead +/** @deprecated Use `orderBy()` instead. */ +sortBy(fn, direction) // delegates to orderBy internally +``` + +### Removed + +- `DeleteBuilder.for()` method — use `DeleteBuilder.from(shape, ids)` instead +- `Shape.update()` no-arg overload — `data` parameter is now required + +--- + +## Key Design Decisions + +### 1. MINUS runtime type detection + +The `.minus()` callback can return three different types: `Evaluation` (WHERE condition), `QueryBuilderObject` (single property), or `QueryBuilderObject[]` (multi-property). Detection order in `toRawInput()`: + +1. `Array.isArray(result)` → property paths array +2. `'property' in result && 'subject' in result` → single QBO (property existence) +3. Fallthrough → Evaluation (WHERE clause) + +**Rationale:** These types don't overlap — `Evaluation` has no `property` field, `QBO` has no `getWherePath()`. Runtime detection avoids needing separate method names while keeping a single `.minus()` API. + +### 2. PropertyPathSegment threading through pipeline + +`PropertyPathSegment[][]` passes through Raw → Desugared → Canonical stages **untransformed**. Only `IRLower` converts segments to `IRTraversePattern` chains. + +**Rationale:** Property path segments are already in their canonical form (property shape IDs). No desugaring or canonicalization needed. Converting to traverse patterns requires alias generation, which belongs in the lowering step. + +### 3. Schema-aware blank node cleanup for deleteAll + +`deleteAllToAlgebra()` uses `walkBlankNodeTree()` to recursively walk the shape tree and generate `OPTIONAL` blocks for blank node properties. This ensures blank node children are cleaned up. + +```sparql +DELETE { ?a0 ?p ?o . ?addr ?p1 ?o1 . } +WHERE { + ?a0 a . ?a0 ?p ?o . + OPTIONAL { ?a0
?addr . FILTER(isBlank(?addr)) . ?addr ?p1 ?o1 . } +} +``` + +**Rationale:** Without blank node cleanup, deleting a Person would orphan its blank node Address. The `OPTIONAL` + `isBlank()` pattern safely handles cases where the property value is an IRI (not a blank node) — no false deletions. + +### 4. Shared field processing for updateWhere + +`updateToAlgebra()` was refactored to extract field processing (DELETE/INSERT/WHERE patterns per field) into a shared helper parameterized by subject term. `updateWhereToAlgebra()` calls the same helper with a variable `?a0` instead of a concrete IRI. + +**Rationale:** Avoids duplicating the field-level DELETE/INSERT logic. The only difference between by-ID and conditional updates is whether the subject is `` or `?a0`. + +### 5. Delete API simplification + +Removed `.for()` from `DeleteBuilder`. ID-based deletes use `DeleteBuilder.from(shape, ids)` or `Shape.delete(id)`. This prevents the ambiguous pattern `DeleteBuilder.from(shape).for(id)` which duplicated `.from(shape, id)`. + +**Rationale:** Two ways to do the same thing creates confusion. The `.from()` signature already accepts IDs. `.for()` is reserved for `UpdateBuilder` where it targets an entity for update (semantically different from "which entities to delete"). + +### 6. Typed R parameter on builders + +`DeleteBuilder` uses `R = DeleteResponse` for ID-based and `R = void` for bulk operations. `UpdateBuilder` uses `R = AddId` for by-ID and `R = void` for bulk. + +**Rationale:** Bulk operations don't return individual results — they affect an unknown number of entities. The `void` return type prevents callers from expecting a response object. + +### 7. MINUS multi-property nested path support + +`.minus(p => [p.bestFriend.name])` produces chained traverse patterns: `?a0 ?m0 . ?m0 ?m1`. + +`FieldSet.collectPropertySegments()` walks the `.subject` chain on the QBO backward, collecting `.property` fields into a `PropertyShape[]` in root-to-leaf order. Each segment becomes a `PropertyPathSegment { propertyShapeId }` that gets lowered to an `IRTraversePattern` chain. + +**Rationale:** Reuses the same proxy and path collection infrastructure already used by `.select()` callbacks. No new proxy mechanism needed. + +--- + +## Test Coverage + +| Test file | Total tests | New/changed for this scope | +|-----------|-------------|---------------------------| +| `src/tests/sparql-select-golden.test.ts` | 63 | +11 (4 MINUS basic + 4 MINUS multi-property + 3 MINUS variants) | +| `src/tests/sparql-mutation-golden.test.ts` | 25 | +6 (2 deleteAll, 2 deleteWhere, 2 updateWhere/forAll) | +| `src/tests/mutation-builder.test.ts` | 25 | Updated existing tests for new delete/update API | +| `src/tests/query-builder.test.ts` | 60 | Changed sortBy→orderBy, updated delete/update paths | +| `src/tests/query-builder.types.test.ts` | 2 | Changed sortBy→orderBy | + +**Total:** 644 tests pass, 0 failures. TypeScript clean. + +### Golden test fixtures added + +| Fixture | DSL | Validates | +|---------|-----|-----------| +| `minusShape` | `.minus(Employee)` | Shape type exclusion | +| `minusProperty` | `.minus(p => p.hobby)` | Single property existence | +| `minusCondition` | `.minus(p => p.hobby.equals('Chess'))` | WHERE condition in MINUS | +| `minusChained` | `.minus(Employee).minus(p => p.hobby)` | Two separate MINUS blocks | +| `minusMultiProperty` | `.minus(p => [p.hobby, p.nickNames])` | Multi-property flat | +| `minusNestedPath` | `.minus(p => [p.bestFriend.name])` | Nested path traversal | +| `minusMixed` | `.minus(p => [p.hobby, p.bestFriend.name])` | Mixed flat + nested | +| `minusSingleProperty` | `.minus(p => p.hobby)` via property path | Single QBO (non-array) | +| `deleteAll` | `Person.deleteAll()` | Delete all with blank node cleanup | +| `deleteWhere` | `.where(p => p.hobby.equals('Chess'))` | Conditional delete | +| `updateWhere` | `.where(p => p.hobby.equals('Jogging'))` | Conditional update | +| `updateForAll` | `.forAll()` | Bulk update with OPTIONAL bindings | + +All golden tests use exact `toBe` matching on full SPARQL strings. + +--- + +## Known Limitations + +1. **Named graphs** — Deferred. All queries operate on the default graph. +2. **MINUS with nested WHERE conditions** — `.minus(p => p.bestFriend.name.equals('Bob'))` is NOT supported. Nested paths only support property existence checks, not value conditions. Condition-based MINUS uses the flat WHERE clause path. +3. **Circular shape references in deleteAll** — `walkBlankNodeTree` caps recursion depth to prevent infinite loops, but deeply nested shapes may produce verbose SPARQL. +4. **MINUS multi-property deduplication** — If `.minus(p => [p.bestFriend.name, p.bestFriend.hobby])`, each path gets independent aliases. The SPARQL engine handles deduplication via BGP matching, but the generated SPARQL could be more compact with shared prefixes. + +--- + +## Deferred Work + +- **Named graphs**: Route B from ideation doc. See `docs/ideas/007-advanced-query-patterns.md`. +- **Preload/eager loading**: Discussed but deferred to a separate scope. +- **MINUS with aggregation**: `.minus()` inside subqueries with GROUP BY — not currently supported. + +--- + +## Commits + +All work on branch `claude/setup-and-summarize-GQoTY` (18 commits): + +1. `0a8f851` — Initial plan +2. `e8f8989` – `87803f1` — Ideation decisions and cross-cutting resolutions +3. `291e4dc` — Implementation plan +4. `a39f138` — Phase 1: IR types +5. `c5e5afb` — Phase 2: MINUS on QueryBuilder +6. `08f3335` — Fix: include missed IRDesugar/IRLower changes +7. `15d3182` — Phase 3: Bulk delete +8. `ac395a8` — Phase 4: Conditional update +9. `cd93175` — Mark phases complete in plan +10. `8d8f5e3` — Review gap fixes: shared helper, void returns, equivalence tests +11. `15c0c38` – `5a0121e` — Phase 6 plan + revision for nested paths +12. `ec99875` — Phase 6: MINUS multi-property with nested path support +13. `669df80` — Cleanup: deprecate sortBy, require update data, simplify delete API From b3691a8119e826bc1aae6499457ca88353bb8667 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 07:14:25 +0000 Subject: [PATCH 20/20] docs: update README for current API, remove ideation and plan docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MINUS examples (shape, property, multi-property, nested path, condition) - Update sortBy → orderBy in examples - Update delete section with deleteAll/deleteWhere - Update update section with .where()/.forAll() and required data - Update mutation builders to show DeleteBuilder.from(shape, id) instead of .for() - Remove docs/ideas/007 and docs/plans/001 (work complete, report at docs/reports/009) https://claude.ai/code/session_01TGZykDVZBEimHNvwXiAq2D --- README.md | 74 +- docs/ideas/007-advanced-query-patterns.md | 410 ---------- docs/plans/001-advanced-query-patterns.md | 852 -------------------- docs/reports/009-advanced-query-patterns.md | 4 +- 4 files changed, 52 insertions(+), 1288 deletions(-) delete mode 100644 docs/ideas/007-advanced-query-patterns.md delete mode 100644 docs/plans/001-advanced-query-patterns.md diff --git a/README.md b/README.md index ba0e812..7971f8a 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,7 @@ const allFriends = await Person.select((p) => p.knows.selectAll()); **3) Apply a simple mutation** ```typescript -const updated = await Person.update({ - name: 'Alicia', -}).for({id: 'https://my.app/node1'}); +const updated = await Person.update({name: 'Alicia'}).for({id: 'https://my.app/node1'}); /* updated: {id: string} & UpdatePartial */ ``` @@ -291,10 +289,11 @@ The query DSL is schema-parameterized: you define your own SHACL shapes, and Lin - Counting with `.size()` - Custom result formats (object mapping) - Type casting with `.as(Shape)` +- MINUS exclusion (by shape, property, condition, multi-property, nested path) - Sorting, limiting, and `.one()` - Query context variables - Preloading (`preloadFor`) for component-like queries -- Create / Update / Delete mutations +- Create / Update / Delete mutations (including bulk and conditional) - Dynamic query building with `QueryBuilder` - Composable field sets with `FieldSet` - Mutation builders (`CreateBuilder`, `UpdateBuilder`, `DeleteBuilder`) @@ -410,9 +409,27 @@ And you want to select properties of those pets that are dogs: const guards = await Person.select((p) => p.pets.as(Dog).guardDogLevel); ``` +#### MINUS (exclusion) +```typescript +// Exclude by shape — all Persons that are NOT also Employees +const nonEmployees = await Person.select((p) => p.name).minus(Employee); + +// Exclude by property existence — Persons that do NOT have a hobby +const noHobby = await Person.select((p) => p.name).minus((p) => p.hobby); + +// Exclude by multiple properties — Persons missing BOTH hobby AND nickNames +const sparse = await Person.select((p) => p.name).minus((p) => [p.hobby, p.nickNames]); + +// Exclude by nested path — Persons whose bestFriend does NOT have a name +const unnamed = await Person.select((p) => p.name).minus((p) => [p.bestFriend.name]); + +// Exclude by condition — Persons whose hobby is NOT 'Chess' +const noChess = await Person.select((p) => p.name).minus((p) => p.hobby.equals('Chess')); +``` + #### Sorting, limiting, one ```typescript -const sorted = await Person.select((p) => p.name).sortBy((p) => p.name, 'ASC'); +const sorted = await Person.select((p) => p.name).orderBy((p) => p.name, 'ASC'); const limited = await Person.select((p) => p.name).limit(1); const single = await Person.select((p) => p.name).one(); ``` @@ -447,8 +464,10 @@ Where UpdatePartial reflects the created properties. #### Update -Update will patch any property that you send as payload and leave the rest untouched. Chain `.for(id)` to target the entity: +Update will patch any property that you send as payload and leave the rest untouched. The data to update is required: + ```typescript +// Target a specific entity with .for(id) /* Result: {id: string} & UpdatePartial */ const updated = await Person.update({name: 'Alicia'}).for({id: 'https://my.app/node1'}); ``` @@ -460,6 +479,15 @@ Returns: } ``` +**Conditional and bulk updates:** +```typescript +// Update all matching entities +const archived = await Person.update({status: 'archived'}).where(p => p.status.equals('inactive')); + +// Update all instances of a shape +await Person.update({verified: true}).forAll(); +``` + **Updating multi-value properties** When updating a property that holds multiple values (one that returns an array in the results), you can either overwrite all the values with a new explicit array of values, or delete from/add to the current values. @@ -503,27 +531,19 @@ This returns an object with the added and removed items #### Delete -To delete a node entirely: ```typescript -/* Result: {deleted: Array<{id: string}>, count: number} */ +// Delete a single node const deleted = await Person.delete({id: 'https://my.app/node1'}); -``` -Returns -```json -{ - deleted:[ - {id:"https://my.app/node1"} - ], - count:1 -} -``` -To delete multiple nodes pass an array: +// Delete multiple nodes +const deleted = await Person.delete([{id: 'https://my.app/node1'}, {id: 'https://my.app/node2'}]); -```typescript -/* Result: {deleted: Array<{id: string}>, count: number} */ -const deleted = await Person.delete([{id: 'https://my.app/node1'},{id: 'https://my.app/node2'}]); +// Delete all instances of a shape (with blank node cleanup) +await Person.deleteAll(); + +// Conditional delete +await Person.deleteWhere(p => p.status.equals('inactive')); ``` @@ -716,8 +736,14 @@ const updated = await UpdateBuilder.from(Person) .for({id: 'https://my.app/alice'}) .set({name: 'Alicia'}); -// Delete — equivalent to Person.delete({id: '...'}) -const deleted = await DeleteBuilder.from(Person).for({id: 'https://my.app/alice'}); +// Delete by ID — equivalent to Person.delete({id: '...'}) +const deleted = await DeleteBuilder.from(Person, {id: 'https://my.app/alice'}); + +// Delete all — equivalent to Person.deleteAll() +await DeleteBuilder.from(Person).all(); + +// Conditional update — equivalent to Person.update({...}).where(fn) +await UpdateBuilder.from(Person).set({verified: true}).forAll(); // All builders are PromiseLike — await them or call .build() for the IR const ir = CreateBuilder.from(Person).set({name: 'Alice'}).build(); diff --git a/docs/ideas/007-advanced-query-patterns.md b/docs/ideas/007-advanced-query-patterns.md deleted file mode 100644 index 12fdbdf..0000000 --- a/docs/ideas/007-advanced-query-patterns.md +++ /dev/null @@ -1,410 +0,0 @@ -# Advanced Query Patterns - -## Summary - -Add DSL support for three features: -1. **MINUS / NOT EXISTS (set exclusion)** — Exclude results matching a pattern -2. **Bulk Delete (DELETE WHERE)** — Delete all entities of a type, or matching a condition -3. **Conditional Update (update().where())** — Update entities matching a condition without pre-fetching IDs - -All three build on algebra types already defined in `SparqlAlgebra.ts` and the existing builder pattern from 2.0. - ---- - -## Codebase status (as of 2.0 pending changesets) - -### What's changed since the original 007 draft - -| Area | Original assumption | Actual 2.0 state | -|------|---------------------|-------------------| -| `Shape.delete()` | Accepts `(id: string)` | Now accepts `(id: NodeId \| NodeId[] \| NodeReferenceValue[])`, returns `DeleteBuilder` | -| `Shape.update()` | Accepts `(id, data)` | Now accepts `(data?)`, returns `UpdateBuilder`; `.for(id)` is chained | -| `.for()` / `.forAll()` | Not discussed | `QueryBuilder` has both `.for(id)` and `.forAll(ids?)`. `UpdateBuilder` has `.for(id)` only (single). `DeleteBuilder` has `.for(ids)` (multi). | -| `.where()` | Not discussed | Exists on `QueryBuilder` only. Not on `UpdateBuilder` or `DeleteBuilder`. | -| `.deleteAll()` | Proposed | Does NOT exist yet | -| NOT EXISTS | Mentioned briefly | Fully working via `.every()` in the DSL, through `IRExistsExpression` + `IRNotExpression` | -| MINUS algebra | Proposed | `SparqlMinus` type + serialization exist. No IR node, no DSL method. | -| IR graph patterns | - | Union of: `shape_scan`, `traverse`, `join`, `optional`, `union`, `exists` | - -### Key architecture notes - -- **Builders are immutable** — every method returns a new builder instance via `.clone()` -- **PromiseLike** — all builders implement PromiseLike so `await Person.update({…}).for(id)` works -- **IR → Algebra → String** — three-layer pipeline: DSL → IR AST → SPARQL Algebra → SPARQL string -- **QueryDispatch** — mutations execute via `getQueryDispatch().deleteQuery(ir)` / `.updateQuery(ir)` - ---- - -## Feature 1: MINUS / NOT EXISTS (Set Exclusion) - -### Background: MINUS vs FILTER NOT EXISTS - -In SPARQL, these are *similar but not identical*: - -```sparql --- MINUS: set difference based on shared variables -SELECT ?name WHERE { - ?s a ex:Person . ?s ex:name ?name . - MINUS { ?s a ex:Employee . } -} - --- FILTER NOT EXISTS: filter that checks pattern non-existence -SELECT ?name WHERE { - ?s a ex:Person . ?s ex:name ?name . - FILTER NOT EXISTS { ?s a ex:Employee . } -} -``` - -**Key semantic difference:** MINUS computes set difference — if the MINUS pattern has *no variables in common* with the outer pattern, it excludes nothing (MINUS is a no-op). FILTER NOT EXISTS evaluates the inner pattern with variable bindings from the outer scope, so it always works as expected. - -**In practice:** For the common case (same subject variable), they produce identical results. They diverge only when variable scoping differs. Some SPARQL engines optimize one better than the other (e.g. Virtuoso prefers MINUS; Fuseki handles both well). - -### Current state - -- **NOT EXISTS** is already fully supported via `.every()` on QueryProxy properties → generates `FILTER NOT EXISTS { … }` -- **MINUS** has algebra + serialization support (`SparqlMinus`) but no IR pattern and no DSL method - -### Proposed DSL - -```ts -// People who are NOT employees — exclude by type -Person.select(p => p.name).minus(Employee) - -// Orders without a shippedDate — exclude by property -Order.select(o => o.id).minus(o => o.shippedDate) -``` - -### Route A: Single `.minus()` method, always emit SPARQL MINUS - -Add `.minus()` to `QueryBuilder` that always generates the SPARQL `MINUS { … }` pattern. - -**Pros:** -- Simple 1:1 mapping from DSL to SPARQL -- MINUS is the more intuitive keyword for users ("all X minus Y") -- Straightforward to implement — new `IRMinusPattern`, convert to `SparqlMinus` - -**Cons:** -- MINUS has the variable-scoping gotcha (no shared vars = no exclusion) -- Some engines may optimize FILTER NOT EXISTS better - -**Implementation:** -- Add `IRMinusPattern` to `IRGraphPattern` union -- Add `.minus()` to `QueryBuilder` (accepts `ShapeConstructor` or property lambda) -- `irToAlgebra.ts`: convert `IRMinusPattern` → `SparqlMinus` - -### Route B: Single `.minus()` method, but emit FILTER NOT EXISTS under the hood - -Use the familiar `.minus()` name in the DSL, but generate `FILTER NOT EXISTS` in SPARQL since the semantics are more predictable. - -**Pros:** -- Avoids the variable-scoping pitfall -- NOT EXISTS already has full IR support (`IRExistsExpression` + `IRNotExpression`) -- Less new code — reuses existing pipeline - -**Cons:** -- DSL says "minus" but SPARQL says "NOT EXISTS" — could confuse users debugging SPARQL output -- Doesn't expose the actual MINUS pattern for users who specifically need it - -**Implementation:** -- Add `.minus()` to `QueryBuilder` -- Internally construct an `IRExistsExpression` wrapped in `IRNotExpression` -- No new IR types needed - -### Route C: `.minus()` emits MINUS, and document `.every()` as the NOT EXISTS equivalent - -Keep both patterns available. `.minus()` for SPARQL MINUS, `.every()` (already exists) for NOT EXISTS. Document the difference. - -**Pros:** -- Full SPARQL coverage — both patterns available -- No semantic mismatch between DSL name and generated SPARQL -- Power users can choose the right tool - -**Cons:** -- Two ways to do the same thing — could confuse beginners -- Need to document when to use which - -**Implementation:** -- Same as Route A (new `IRMinusPattern` etc.) -- Add documentation comparing `.minus()` vs `.every()` with negation - -### Route D: Skip `.minus()` entirely, rely on existing NOT EXISTS via `.where()` - -Since NOT EXISTS is already supported and covers the common cases, defer MINUS and instead document how to express exclusions with the existing `.where()` + `.every()` API. - -**Pros:** -- Zero new code -- Avoids API surface bloat - -**Cons:** -- `.every()` for negation isn't obvious — `.minus()` reads more naturally -- Doesn't expose the SPARQL MINUS pattern at all - -### Decision: Route A — emit SPARQL MINUS - -**Chosen:** Route A with extended callback support. - -`.minus()` accepts `ShapeConstructor` or `WhereClause` (same callback types as `.select()`): - -```ts -// By shape -Person.select(p => p.name).minus(Employee) - -// Single property existence -Order.select(o => o.id).minus(o => o.shippedDate) - -// Multi property (AND — both must exist) -Person.select(p => p.name).minus(p => [p.email, p.phone]) - -// Boolean condition -Person.select(p => p.name).minus(p => p.status.equals('inactive')) - -// Nested -Person.select(p => p.name).minus(p => p.friends.some(f => f.name.equals('Moa'))) -``` - -**Implementation:** -- Reuses existing callback processing from `.select()` — only the Shape overload is new -- New `IRMinusPattern` through the pipeline, lands on existing `SparqlMinus` algebra + serialization -- Chainable: `.minus(A).minus(B)` produces two separate `MINUS { }` blocks - ---- - -## Feature 2: Bulk Delete (DELETE WHERE) - -### Current state - -- `Shape.delete(ids)` → `DeleteBuilder.from(shape, ids)` → requires explicit IDs -- `DeleteBuilder.for(ids)` — chainable, but still requires IDs -- No way to delete "all entities of type X" or "entities matching condition Y" -- `SparqlDeleteWherePlan` algebra type exists and serializes correctly -- `deleteToAlgebra()` currently generates per-ID DELETE patterns with wildcard `?p ?o` - -### Proposed DSL - -```ts -// Delete all temporary records -TempRecord.delete().all() -// or: TempRecord.deleteAll() - -// Delete inactive people (conditional) -Person.delete().where(p => p.status.equals('inactive')) -``` - -~~`deleteProperty` is out of scope — property removal should use `.update()` with unset semantics.~~ - -### Route A: Extend `DeleteBuilder` with `.all()` and `.where()` - -Add new chainable methods to the existing `DeleteBuilder`: - -```ts -Person.delete().all() // delete all of type -Person.delete().where(p => p.status.equals('x')) // conditional delete -Person.delete('id-1') // existing by-ID (unchanged) -``` - -**Pros:** -- Consistent with existing builder pattern -- `.all()` and `.where()` are familiar from `QueryBuilder` -- Single entry point (`Shape.delete()`) with different chaining paths - -**Cons:** -- `Shape.delete()` currently requires IDs — making IDs optional is a breaking signature change -- Need to handle mutual exclusivity: `.for(ids)` vs `.all()` vs `.where()` can't be combined arbitrarily -- `.where()` on mutations is a new pattern — may need `WhereClause` type adapted for mutation context - -**Implementation:** -- Make `ids` optional in `DeleteBuilder.from()` -- Add `.all()` method (sets a flag, no IDs needed) -- Add `.where(fn)` method (stores a WhereClause) -- New IR variant: `IRDeleteWhereMutation` (kind: `'delete_where'`, shape, whereFn?) -- `irToAlgebra.ts`: generate `SparqlDeleteWherePlan` for `.all()`, or `SparqlDeleteInsertPlan` with WHERE filters for `.where()` - -### Route B: Separate static methods on Shape - -```ts -TempRecord.deleteAll() -Person.deleteWhere(p => p.status.equals('inactive')) -``` - -**Pros:** -- Clear distinction from ID-based `Shape.delete(id)` -- No need to make `delete()` overloaded -- Explicit naming prevents accidental bulk deletes - -**Cons:** -- Adds more static methods to Shape class -- Less composable than builder pattern -- Inconsistent with the 2.0 builder approach (`.select()`, `.update()`, `.delete()`) - -### Route C: Use `.delete()` + `.where()` only (no `.all()`) - -```ts -Person.delete().where(p => p.status.equals('inactive')) // conditional -Person.delete().where(() => true) // all (explicit) -// or: Person.delete().where() // all (no arg = match all) -``` - -**Pros:** -- Single new method (`.where()`) instead of two -- Forces user to think about what they're deleting -- `.where()` with no arg or always-true is explicit enough for "delete all" - -**Cons:** -- "Delete all" is a common operation and `.where(() => true)` is awkward -- Missing a `.where()` call accidentally could be confusing (should it error or delete all?) - -### Decision: `.deleteAll()` + `.delete().where()`, schema-aware blank node cleanup - -**Chosen:** Hybrid of Route A and B. - -- `Shape.deleteAll()` — explicit bulk delete, no safety gate needed -- `Shape.delete().where(cb)` — conditional delete -- `Shape.deleteWhere(cb)` — optional sugar for `.delete().where(cb)` -- `Shape.delete(id)` — existing by-ID (unchanged) -- Returns `void` - -**SPARQL generation — schema-aware blank node cleanup:** - -Uses explicit property paths from the shape tree to navigate to blank nodes, then wildcards their properties. Recursively walks as deep as blank-node-typed properties nest. `FILTER(isBlank())` always present (essential for `sh:BlankNodeOrIRI`). - -Example for `Person` with `address: BlankNode → Address { street, city, geo: BlankNodeOrIRI → GeoPoint { lat, lon } }`: - -```sparql -DELETE { - ?a0 ?p ?o . - ?addr ?p1 ?o1 . - ?geo ?p2 ?o2 . -} -WHERE { - ?a0 a . - ?a0 ?p ?o . - OPTIONAL { - ?a0
?addr . FILTER(isBlank(?addr)) . - ?addr ?p1 ?o1 . - OPTIONAL { - ?addr ?geo . FILTER(isBlank(?geo)) . - ?geo ?p2 ?o2 . - } - } -} -``` - -- Root: `?a0 ?p ?o` catches everything including `rdf:type` — no need for explicit `?a0 a ` in DELETE -- Blank node traversal: explicit property paths (`
`, ``) — efficient, no scanning -- Blank node cleanup: `?addr ?p1 ?o1` wildcard — catches all properties on the blank node -- Recursion depth: determined at codegen by walking the shape tree - ---- - -## Feature 3: Conditional Update (`update().where()`) - -### Current state - -- `Shape.update(data).for(id)` — updates a single, known entity -- No `.where()` on `UpdateBuilder` -- No `.forAll()` on `UpdateBuilder` (only on `QueryBuilder`) -- The SPARQL layer already generates `DELETE { old } INSERT { new } WHERE { match }` patterns - -### Proposed DSL - -```ts -// Set all inactive people's status to 'archived' -Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) - -// Bulk update all entities of a type -Person.update({ verified: true }).all() -``` - -### Route A: Add `.where()` and `.all()` to `UpdateBuilder` - -```ts -Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) -Person.update({ verified: true }).all() -Person.update({ name: 'Bob' }).for('id-1') // existing (unchanged) -``` - -**Pros:** -- Mirrors the pattern proposed for `DeleteBuilder` — consistent API -- Powerful — enables bulk updates without pre-fetching -- Natural SPARQL mapping to `DELETE/INSERT WHERE { … FILTER(…) }` - -**Cons:** -- **Significantly more complex** than delete — update needs to generate DELETE for old values AND INSERT for new values, all within a WHERE that also filters -- The current `updateToAlgebra()` assumes a single known entity ID — the WHERE pattern generation would need a fundamentally different approach -- `.where()` updates can't use OPTIONAL to handle missing old values the same way ID-based updates do -- Risk of unintended mass updates if `.where()` is too broad - -**Implementation:** -- Make `.for(id)` optional in `UpdateBuilder` -- Add `.where(fn)` and `.all()` methods -- New IR variant: `IRUpdateWhereMutation` (kind: `'update_where'`, shape, data, whereFn?) -- `irToAlgebra.ts`: generate new algebra plan for pattern-matched updates -- Need careful handling: for each field in `data`, generate DELETE for old value + INSERT for new value, within a WHERE that includes the filter condition - -### Decision: `.update().where()` + `.forAll()` — thin layer over existing SPARQL generation - -**Chosen:** Route A — add `.where()` and `.forAll()` to `UpdateBuilder`. No `.updateWhere()` sugar. - -- `Person.update(data).where(cb)` — conditional update -- `Person.update(data).forAll()` — bulk update all instances of type -- `Person.update(data).for(id)` — existing by-ID (unchanged) - -**Key insight:** The existing `updateToAlgebra()` already generates the full `DELETE { old } INSERT { new } WHERE { OPTIONAL { old bindings } }` pattern. Conditional update is a thin addition — swap the hardcoded `` subject for a `?a0` variable, add `?a0 a ` to WHERE, and append the filter conditions from the `.where()` callback. - -**Field-level scoping:** Only touches fields in the update data — surgical. Does NOT delete/reinsert unrelated triples. - -**Example — `.where()`:** - -```ts -Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) -``` - -```sparql -DELETE { ?a0 ?old_status . } -INSERT { ?a0 "archived" . } -WHERE { - ?a0 a . - ?a0 ?old_status . - FILTER(?old_status = "inactive") -} -``` - -**Example — `.forAll()`:** - -```ts -Person.update({ verified: true }).forAll() -``` - -```sparql -DELETE { ?a0 ?old_verified . } -INSERT { ?a0 true . } -WHERE { - ?a0 a . - OPTIONAL { ?a0 ?old_verified . } -} -``` - -Note: `.forAll()` keeps the OPTIONAL for old value bindings (entity may not have the field yet). `.where()` drops the OPTIONAL since the filter condition implies the field exists. - -**Implementation:** -- Add `.where(fn)` and `.forAll()` to `UpdateBuilder` -- Make `.for(id)` optional (require one of `.for()`, `.forAll()`, or `.where()` before `.build()`) -- New IR variant: `IRUpdateWhereMutation` (kind: `'update_where'`, shape, data, whereFn?) -- `updateToAlgebra`: parameterize subject — `iriTerm(id)` for `.for()`, variable for `.where()`/`.forAll()`, add type triple + filter conditions to WHERE - ---- - -## Open questions (resolved) - -### Feature 1: MINUS / NOT EXISTS -- **Chosen:** Route A with extended callback support — `.minus()` emitting SPARQL `MINUS` - -### Feature 2: Bulk Delete -- **Chosen:** `.deleteAll()` + `.delete().where()` — schema-aware blank node cleanup, returns `void` - -### Feature 3: Conditional Update -- **Chosen:** `.update().where()` + `.forAll()` — thin layer over existing SPARQL generation, field-level scoping - -### Cross-cutting (resolved) -1. **Named graphs:** Deferred — not in scope for now. -2. **Priority order:** To be determined during planning. diff --git a/docs/plans/001-advanced-query-patterns.md b/docs/plans/001-advanced-query-patterns.md deleted file mode 100644 index 6881b66..0000000 --- a/docs/plans/001-advanced-query-patterns.md +++ /dev/null @@ -1,852 +0,0 @@ -# Plan: Advanced Query Patterns - -Implements three features from [ideation doc](../ideas/007-advanced-query-patterns.md): -1. **MINUS** — `.minus()` on `QueryBuilder` -2. **Bulk Delete** — `.deleteAll()` + `.delete().where()` -3. **Conditional Update** — `.update().where()` + `.update().forAll()` - -Named graphs: deferred (out of scope). - ---- - -## Architecture Overview - -All three features follow the same pipeline: - -``` -DSL (Builder) → IR AST → SPARQL Algebra → SPARQL String -``` - -Each feature adds: -- **Builder method(s)** — new chainable methods on existing builders -- **IR type(s)** — new variant(s) in the IR union -- **Algebra conversion** — new case(s) in `irToAlgebra.ts` -- **Serialization** — reuses existing `algebraToString.ts` (no changes needed for any feature) - -### Files that change - -| File | F1 | F2 | F3 | -|------|----|----|-----| -| `src/queries/QueryBuilder.ts` | `.minus()` | | | -| `src/queries/DeleteBuilder.ts` | | `.where()`, `.all()` | | -| `src/queries/UpdateBuilder.ts` | | | `.where()`, `.forAll()` | -| `src/shapes/Shape.ts` | | `.deleteAll()`, `.deleteWhere()` | | -| `src/queries/IntermediateRepresentation.ts` | `IRMinusPattern` | `IRDeleteWhereMutation`, `IRDeleteAllMutation` | `IRUpdateWhereMutation` | -| `src/queries/IRMutation.ts` | | `buildCanonicalDeleteWhereMutationIR`, `buildCanonicalDeleteAllMutationIR` | `buildCanonicalUpdateWhereMutationIR` | -| `src/sparql/irToAlgebra.ts` | minus in `selectToAlgebra` | `deleteWhereToAlgebra`, `deleteAllToAlgebra` | `updateWhereToAlgebra` | -| `src/queries/queryDispatch.ts` | | new dispatch methods | new dispatch methods | -| `src/queries/DeleteQuery.ts` | | new factory methods | | -| `src/queries/UpdateQuery.ts` | | | new factory methods | -| `src/tests/sparql-mutation-golden.test.ts` | | golden tests | golden tests | -| `src/tests/sparql-select-golden.test.ts` | golden tests | | | -| `src/tests/mutation-builder.test.ts` | | builder equiv tests | builder equiv tests | - ---- - -## Feature 1: MINUS (`QueryBuilder.minus()`) - -### DSL - -```ts -// By shape — exclude entities that are also of another type -Person.select(p => p.name).minus(Employee) - -// By property existence -Order.select(o => o.id).minus(o => o.shippedDate) - -// By condition -Person.select(p => p.name).minus(p => p.status.equals('inactive')) - -// Chained — produces two separate MINUS { } blocks -Person.select(p => p.name).minus(Employee).minus(Contractor) -``` - -### IR Contract - -```ts -// New pattern added to IRGraphPattern union -export type IRMinusPattern = { - kind: 'minus'; - pattern: IRGraphPattern; // The pattern to subtract - filter?: IRExpression; // Optional filter within the MINUS block -}; - -// Updated union: -export type IRGraphPattern = - | IRShapeScanPattern - | IRTraversePattern - | IRJoinPattern - | IROptionalPattern - | IRUnionPattern - | IRExistsPattern - | IRMinusPattern; // ← new -``` - -### Builder → IR - -`QueryBuilder.minus()` accepts: -- `ShapeConstructor` — creates an `IRShapeScanPattern` for the shape's type triple -- `WhereClause` callback — reuses `processWhereClause()` to produce `IRTraversePattern` + `IRExpression` - -Stored as `_minusPatterns: IRMinusPattern[]` on the builder. Each `.minus()` call appends to the array (immutable clone). - -In `build()`, minus patterns are added to `IRSelectQuery.patterns[]`. - -### Algebra Conversion (`selectToAlgebra`) - -When processing `IRSelectQuery.patterns`, an `IRMinusPattern` converts to: - -```ts -// Wraps current algebra in SparqlMinus -algebra = { - type: 'minus', - left: algebra, // everything so far - right: minusAlgebra, // the MINUS block's content -} satisfies SparqlMinus; -``` - -The right side is built by converting the inner `IRGraphPattern` + optional `IRExpression` to algebra, same as any other pattern. - -### Serialization - -Already exists in `algebraToString.ts`: -```ts -case 'minus': - return `${left}\nMINUS {\n${indent(right)}\n}`; -``` - -No changes needed. - -### SPARQL output - -```sparql -SELECT ?name WHERE { - ?a0 a . - ?a0 ?name . - MINUS { - ?a0 a . - } -} -``` - ---- - -## Feature 2: Bulk Delete - -### DSL - -```ts -// Delete all instances of a type -TempRecord.deleteAll() // static sugar -TempRecord.delete().all() // builder equivalent - -// Conditional delete -Person.delete().where(p => p.status.equals('inactive')) -Person.deleteWhere(p => p.status.equals('inactive')) // static sugar - -// Existing by-ID (unchanged) -Person.delete('id-1') -``` - -### IR Contract - -```ts -// New mutation types -export type IRDeleteAllMutation = { - kind: 'delete_all'; - shape: string; // shape IRI -}; - -export type IRDeleteWhereMutation = { - kind: 'delete_where'; - shape: string; // shape IRI - where: IRExpression; // filter condition from callback - wherePatterns: IRGraphPattern[]; // traverse patterns needed by the filter -}; - -// Note: both need the shape ID to: -// 1. Generate `?a0 a ` in WHERE -// 2. Walk the shape tree for blank node cleanup -``` - -### Builder changes (`DeleteBuilder`) - -New fields on `DeleteBuilderInit`: -```ts -interface DeleteBuilderInit { - shape: ShapeConstructor; - ids?: NodeId[]; - mode?: 'ids' | 'all' | 'where'; // mutual exclusivity - whereFn?: WhereClause; -} -``` - -New methods: -```ts -all(): DeleteBuilder // sets mode = 'all' -where(fn: WhereClause): DeleteBuilder // sets mode = 'where', stores fn -``` - -`build()` dispatches by mode: -- `'ids'` (default) → existing `DeleteQueryFactory` -- `'all'` → `buildCanonicalDeleteAllMutationIR()` -- `'where'` → `buildCanonicalDeleteWhereMutationIR()` - -### Shape static methods - -```ts -// Shape.ts additions -static deleteAll(this: ShapeConstructor): DeleteBuilder { - return DeleteBuilder.from(this).all(); -} - -static deleteWhere( - this: ShapeConstructor, - fn: WhereClause, -): DeleteBuilder { - return DeleteBuilder.from(this).where(fn); -} -``` - -`Shape.delete()` signature unchanged — still requires IDs. `Shape.delete()` with no args is NOT supported (prevents accidental bulk delete). - -### Algebra Conversion - -#### `deleteAllToAlgebra(ir: IRDeleteAllMutation)` → `SparqlDeleteInsertPlan` - -Walks the shape tree to generate schema-aware blank node cleanup: - -```ts -function deleteAllToAlgebra( - ir: IRDeleteAllMutation, - options?: SparqlOptions, -): SparqlDeleteInsertPlan { - const shape = resolveShapeById(ir.shape); - - // 1. Root: ?a0 ?p ?o (catches all triples including rdf:type) - const deletePatterns = [triple(var('a0'), var('p'), var('o'))]; - const whereRequired = [ - triple(var('a0'), iriTerm(rdf.type), iriTerm(ir.shape)), - triple(var('a0'), var('p'), var('o')), - ]; - - // 2. Walk shape tree for blank node properties - const optionalBlocks = walkBlankNodeTree(shape, 'a0', deletePatterns); - - // 3. Build WHERE: required BGP + nested OPTIONALs for blank nodes - let whereAlgebra = buildWhereWithOptionals(whereRequired, optionalBlocks); - - return { - type: 'delete_insert', - deletePatterns, - insertPatterns: [], - whereAlgebra, - }; -} -``` - -#### `walkBlankNodeTree(shape, parentVar, deletePatterns)` — recursive helper - -```ts -// For each property with nodeKind containing BlankNode: -// 1. Add to deletePatterns: ?bN ?pN ?oN -// 2. Create OPTIONAL block: { ?parent ?bN . FILTER(isBlank(?bN)) . ?bN ?pN ?oN } -// 3. If property has valueShape, recurse into that shape -// Returns array of optional blocks to nest -``` - -Uses existing APIs: -- `NodeShape.getPropertyShapes(true)` — all properties including inherited -- `PropertyShape.nodeKind` + `nodeKindToAtomics()` — detect blank node properties -- `PropertyShape.valueShape` + `getShapeClass()` — follow nested shapes - -#### `deleteWhereToAlgebra(ir: IRDeleteWhereMutation)` → `SparqlDeleteInsertPlan` - -Same blank node cleanup as `deleteAllToAlgebra`, plus filter conditions appended to WHERE: - -```ts -function deleteWhereToAlgebra( - ir: IRDeleteWhereMutation, - options?: SparqlOptions, -): SparqlDeleteInsertPlan { - // Same as deleteAll, but wrap whereAlgebra in SparqlFilter - // with the converted IRExpression from ir.where -} -``` - -### SPARQL output — `.deleteAll()` - -```sparql -DELETE { - ?a0 ?p ?o . - ?addr ?p1 ?o1 . -} -WHERE { - ?a0 a . - ?a0 ?p ?o . - OPTIONAL { - ?a0
?addr . FILTER(isBlank(?addr)) . - ?addr ?p1 ?o1 . - } -} -``` - -### SPARQL output — `.delete().where()` - -```sparql -DELETE { - ?a0 ?p ?o . -} -WHERE { - ?a0 a . - ?a0 ?p ?o . - ?a0 ?status . - FILTER(?status = "inactive") -} -``` - -### QueryDispatch - -`deleteQuery()` currently accepts `DeleteQuery` (which is `IRDeleteMutation`). Need to widen the type: - -```ts -export type DeleteQuery = IRDeleteMutation | IRDeleteAllMutation | IRDeleteWhereMutation; -``` - -The dispatch implementation routes by `kind`: -- `'delete'` → existing `deleteToAlgebra` → `deleteInsertPlanToSparql` -- `'delete_all'` → `deleteAllToAlgebra` → `deleteInsertPlanToSparql` -- `'delete_where'` → `deleteWhereToAlgebra` → `deleteInsertPlanToSparql` - ---- - -## Feature 3: Conditional Update - -### DSL - -```ts -// Conditional update -Person.update({ status: 'archived' }).where(p => p.status.equals('inactive')) - -// Bulk update all instances -Person.update({ verified: true }).forAll() - -// Existing by-ID (unchanged) -Person.update({ name: 'Bob' }).for('id-1') -``` - -### IR Contract - -```ts -export type IRUpdateWhereMutation = { - kind: 'update_where'; - shape: string; // shape IRI - data: IRNodeData; // same data format as IRUpdateMutation - where?: IRExpression; // filter condition (absent for forAll) - wherePatterns?: IRGraphPattern[]; // traverse patterns needed by the filter -}; -``` - -### Builder changes (`UpdateBuilder`) - -New fields on `UpdateBuilderInit`: -```ts -interface UpdateBuilderInit { - shape: ShapeConstructor; - data?: UpdatePartial; - targetId?: string; - mode?: 'id' | 'all' | 'where'; // mutual exclusivity - whereFn?: WhereClause; -} -``` - -New methods: -```ts -forAll(): UpdateBuilder // sets mode = 'all' -where(fn: WhereClause): UpdateBuilder // sets mode = 'where', stores fn -``` - -`build()` dispatches by mode: -- `'id'` (default) → existing `UpdateQueryFactory` -- `'all'` or `'where'` → `buildCanonicalUpdateWhereMutationIR()` - -### Algebra Conversion - -#### `updateWhereToAlgebra(ir: IRUpdateWhereMutation)` → `SparqlDeleteInsertPlan` - -Key insight: reuses the same field-level DELETE/INSERT logic from existing `updateToAlgebra()`, but parameterizes the subject. - -```ts -function updateWhereToAlgebra( - ir: IRUpdateWhereMutation, - options?: SparqlOptions, -): SparqlDeleteInsertPlan { - const subjectVar = variable('a0'); - - // 1. Type triple in WHERE: ?a0 a - const typeTriple = triple(subjectVar, iriTerm(rdf.type), iriTerm(ir.shape)); - - // 2. For each field in ir.data, generate: - // DELETE: ?a0 ?old_property . - // INSERT: ?a0 "newValue" . - // WHERE: ?a0 ?old_property . (OPTIONAL for forAll, required for where) - - // 3. If ir.where exists, convert to SparqlFilter wrapping WHERE - // If ir.where absent (forAll), wrap old-value bindings in OPTIONAL - - // 4. Return SparqlDeleteInsertPlan -} -``` - -The field processing logic should be extracted from `updateToAlgebra()` into a shared helper that takes a subject term (either IRI or variable). - -### SPARQL output — `.where()` - -```sparql -DELETE { ?a0 ?old_status . } -INSERT { ?a0 "archived" . } -WHERE { - ?a0 a . - ?a0 ?old_status . - FILTER(?old_status = "inactive") -} -``` - -### SPARQL output — `.forAll()` - -```sparql -DELETE { ?a0 ?old_verified . } -INSERT { ?a0 true . } -WHERE { - ?a0 a . - OPTIONAL { ?a0 ?old_verified . } -} -``` - -### QueryDispatch - -`updateQuery()` type widens: - -```ts -export type UpdateQuery = IRUpdateMutation | IRUpdateWhereMutation; -``` - -Dispatch routes by `kind`: -- `'update'` → existing `updateToAlgebra` → `deleteInsertPlanToSparql` -- `'update_where'` → `updateWhereToAlgebra` → `deleteInsertPlanToSparql` - ---- - -## Inter-Component Contracts - -### IR ↔ Algebra boundary - -New conversion functions exported from `irToAlgebra.ts`: - -```ts -export function deleteAllToAlgebra(ir: IRDeleteAllMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; -export function deleteWhereToAlgebra(ir: IRDeleteWhereMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; -export function updateWhereToAlgebra(ir: IRUpdateWhereMutation, options?: SparqlOptions): SparqlDeleteInsertPlan; -``` - -All return `SparqlDeleteInsertPlan` — reuses existing `deleteInsertPlanToSparql()` serialization. - -### Builder ↔ IR boundary - -Builders produce IR via factory functions in `IRMutation.ts`: - -```ts -export function buildCanonicalDeleteAllMutationIR(input: { shape: NodeShape }): IRDeleteAllMutation; -export function buildCanonicalDeleteWhereMutationIR(input: { shape: NodeShape; where: ... }): IRDeleteWhereMutation; -export function buildCanonicalUpdateWhereMutationIR(input: { shape: NodeShape; data: ...; where?: ... }): IRUpdateWhereMutation; -``` - -### WHERE clause reuse - -Both `DeleteBuilder.where()` and `UpdateBuilder.where()` accept `WhereClause` — the same type used by `QueryBuilder.where()`. Processing uses the existing `processWhereClause()` from `SelectQuery.ts`. - -### Shared blank node tree walker - -New utility for Feature 2, potentially reusable: - -```ts -// irToAlgebra.ts or new utility file -function walkBlankNodeTree( - shape: NodeShape, - parentVar: string, - deletePatterns: SparqlTriple[], - depth?: number, -): OptionalBlock[]; -``` - ---- - -## Potential Pitfalls - -1. **WHERE callback in mutation context**: `processWhereClause()` currently creates a proxy via `createProxiedPathBuilder(shape)`. This works because it only needs the shape definition, not a query context. Should work as-is for mutations, but needs verification. - -2. **Variable naming conflicts**: `updateWhereToAlgebra` uses `?a0` as subject (matching query convention) and `?old_*` for old values (matching existing update convention). These must not collide with variables generated by WHERE filter processing. - -3. **Blank node cleanup depth**: Recursive shape tree walking could theoretically be unbounded if shapes have circular references. Should cap recursion depth (e.g., 10 levels) with a clear error. - -4. **`updateToAlgebra` refactoring**: Extracting field processing into a shared helper is the riskiest change — it touches working code. The existing tests in `sparql-mutation-golden.test.ts` provide a safety net, but should be run after every refactor step. - -5. **DeleteBuilder.from() signature**: Currently `from(shape, ids?)` — making `ids` truly optional means `DeleteBuilder.from(Shape)` returns a builder with no IDs and no mode set. `build()` must validate that exactly one of `ids`/`all`/`where` is specified. - ---- - -## Implementation Phases - -### Dependency graph - -``` -Phase 1 (IR types) - ├── Phase 2 (MINUS) ─┐ - ├── Phase 3 (Bulk Delete) ├── Phase 5 (Integration) - └── Phase 4 (Cond. Update) ─┘ -``` - -Phases 2, 3, 4 can theoretically run in parallel after Phase 1, but all three touch `irToAlgebra.ts` (different sections). To avoid merge conflicts on shared files, execute sequentially: 1 → 2 → 3 → 4 → 5. - ---- - -### Phase 1: IR Types & Contracts — COMPLETED - -**Goal:** Add all new IR types, mutation type unions, and canonical IR builder stubs. No logic — types only. Unblocks all subsequent phases. - -**Status:** Done. `tsc --noEmit` passes. All 633 tests pass. - -**Files:** -- `src/queries/IntermediateRepresentation.ts` — add `IRMinusPattern` to `IRGraphPattern` union -- `src/queries/IRMutation.ts` — add `IRDeleteAllMutation`, `IRDeleteWhereMutation`, `IRUpdateWhereMutation` types + stub `buildCanonical*` functions -- `src/queries/DeleteQuery.ts` — widen `DeleteQuery` type union -- `src/queries/UpdateQuery.ts` — widen `UpdateQuery` type union - -**Tasks:** -1. Add `IRMinusPattern` type and extend `IRGraphPattern` union. -2. Add `IRDeleteAllMutation`, `IRDeleteWhereMutation`, `IRUpdateWhereMutation` types. -3. Add stub `buildCanonicalDeleteAllMutationIR()`, `buildCanonicalDeleteWhereMutationIR()`, `buildCanonicalUpdateWhereMutationIR()` — return correct typed objects from input params. -4. Widen `DeleteQuery` to `IRDeleteMutation | IRDeleteAllMutation | IRDeleteWhereMutation`. -5. Widen `UpdateQuery` to `IRUpdateMutation | IRUpdateWhereMutation`. - -**Validation:** -- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 -- `npm test` — all existing tests still pass (no regressions) - ---- - -### Phase 2: MINUS on QueryBuilder — COMPLETED - -**Goal:** Full `.minus()` support: builder method → IR → algebra → SPARQL string. - -**Status:** Done. 3 new golden tests pass. All 636 tests pass. - -**Files:** -- `src/queries/QueryBuilder.ts` — add `.minus()` method -- `src/sparql/irToAlgebra.ts` — handle `IRMinusPattern` in `selectToAlgebra` -- `src/test-helpers/query-fixtures.ts` — add minus fixture factories -- `src/tests/sparql-select-golden.test.ts` — add golden tests - -**Tasks:** -1. Add `.minus()` method to `QueryBuilder` accepting `ShapeConstructor | WhereClause`. Store as `_minusPatterns` array on builder init. Clone appends. -2. In `build()` / `buildSelectQuery()`, convert minus patterns to `IRMinusPattern` entries in `IRSelectQuery.patterns[]`. -3. In `selectToAlgebra`, add case for `'minus'` pattern kind: wrap current algebra in `SparqlMinus { left, right }`. -4. Add query fixture factories: `minusShape`, `minusProperty`, `minusCondition`, `minusChained`. -5. Add golden tests asserting exact SPARQL output. - -**Fixtures & golden tests:** - -| Fixture | DSL | Expected SPARQL contains | -|---------|-----|--------------------------| -| `minusShape` | `Person.select(p => p.name).minus(Employee)` | `MINUS { ?a0 a . }` | -| `minusProperty` | `Person.select(p => p.name).minus(p => p.hobby)` | `MINUS { ?a0 ?a0_hobby . }` | -| `minusCondition` | `Person.select(p => p.name).minus(p => p.hobby.equals('Chess'))` | `MINUS { ?a0 ?a0_hobby . FILTER(?a0_hobby = "Chess") }` | -| `minusChained` | `Person.select(p => p.name).minus(Employee).minus(p => p.hobby)` | Two separate `MINUS { }` blocks | - -**Validation:** -- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 -- `npm test` — all existing tests pass + 4 new minus golden tests pass -- Assert each golden test uses exact `toBe` matching on full SPARQL string - ---- - -### Phase 3: Bulk Delete — COMPLETED - -**Goal:** `.deleteAll()`, `.delete().all()`, `.delete().where()`, `.deleteWhere()` — full pipeline. - -**Files:** -- `src/queries/DeleteBuilder.ts` — add `mode`, `whereFn`, `.all()`, `.where()` methods, dispatch in `build()` -- `src/shapes/Shape.ts` — add `deleteAll()`, `deleteWhere()` static methods -- `src/queries/IRMutation.ts` — implement `buildCanonicalDeleteAllMutationIR()`, `buildCanonicalDeleteWhereMutationIR()` (replace stubs) -- `src/sparql/irToAlgebra.ts` — add `deleteAllToAlgebra()`, `deleteWhereToAlgebra()`, `walkBlankNodeTree()` helper, export `deleteAllToSparql()`, `deleteWhereToSparql()` convenience wrappers -- `src/test-helpers/query-fixtures.ts` — add delete fixture factories -- `src/tests/sparql-mutation-golden.test.ts` — add golden tests - -**Tasks:** -1. Add `mode` and `whereFn` to `DeleteBuilderInit`. Add `.all()` and `.where(fn)` methods. -2. Update `build()` to dispatch by mode: `'ids'` → existing factory, `'all'` → `buildCanonicalDeleteAllMutationIR`, `'where'` → `buildCanonicalDeleteWhereMutationIR`. Validate mutual exclusivity. -3. Add `Shape.deleteAll()` and `Shape.deleteWhere(fn)` static methods. -4. Implement `buildCanonicalDeleteAllMutationIR()` — returns `{ kind: 'delete_all', shape: shape.id }`. -5. Implement `buildCanonicalDeleteWhereMutationIR()` — processes WHERE callback via `processWhereClause`, converts to IR expressions/patterns. -6. Implement `deleteAllToAlgebra()` with `walkBlankNodeTree()` for schema-aware blank node cleanup. -7. Implement `deleteWhereToAlgebra()` — same base as deleteAll + filter conditions from WHERE. -8. Add convenience wrappers `deleteAllToSparql()`, `deleteWhereToSparql()`. -9. Add fixtures and golden tests. - -**Fixtures & golden tests:** - -| Fixture | DSL | Key assertions | -|---------|-----|----------------| -| `deleteAll` | `Person.deleteAll()` | `DELETE { ?a0 ?p ?o . }` + `WHERE { ?a0 a . ?a0 ?p ?o . }` | -| `deleteAllBuilder` | `Person.delete().all()` | Same SPARQL as `deleteAll` (builder equivalence) | -| `deleteWhere` | `Person.delete().where(p => p.hobby.equals('Chess'))` | `FILTER` with hobby equals in WHERE | -| `deleteWhereSugar` | `Person.deleteWhere(p => p.hobby.equals('Chess'))` | Same SPARQL as `deleteWhere` | - -**Validation:** -- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 -- `npm test` — all existing tests pass + new delete golden tests pass -- Assert `deleteAll` and `deleteAllBuilder` produce identical SPARQL -- Assert `deleteWhere` and `deleteWhereSugar` produce identical SPARQL - ---- - -### Phase 4: Conditional Update — COMPLETED - -**Goal:** `.update().where()` and `.update().forAll()` — full pipeline. - -**Files:** -- `src/queries/UpdateBuilder.ts` — add `mode`, `whereFn`, `.forAll()`, `.where()` methods, dispatch in `build()` -- `src/queries/IRMutation.ts` — implement `buildCanonicalUpdateWhereMutationIR()` (replace stub) -- `src/sparql/irToAlgebra.ts` — add `updateWhereToAlgebra()`, extract shared field processing helper from `updateToAlgebra()`, export `updateWhereToSparql()` convenience wrapper -- `src/test-helpers/query-fixtures.ts` — add update fixture factories -- `src/tests/sparql-mutation-golden.test.ts` — add golden tests - -**Tasks:** -1. Add `mode` and `whereFn` to `UpdateBuilderInit`. Add `.forAll()` and `.where(fn)` methods. -2. Update `build()` to dispatch by mode: `'id'` → existing factory, `'all'`/`'where'` → `buildCanonicalUpdateWhereMutationIR`. Validate: require data via `.set()` before `.forAll()`/`.where()`. -3. Implement `buildCanonicalUpdateWhereMutationIR()` — processes WHERE callback, builds IR. -4. Extract shared field processing from `updateToAlgebra()` into a helper that takes a subject term (IRI or variable). Ensure existing `updateToAlgebra()` calls the helper (refactor, not rewrite). Run existing tests after this step. -5. Implement `updateWhereToAlgebra()` using the shared helper with `?a0` variable subject + type triple + filter. -6. Add convenience wrapper `updateWhereToSparql()`. -7. Add fixtures and golden tests. - -**Fixtures & golden tests:** - -| Fixture | DSL | Key assertions | -|---------|-----|----------------| -| `updateWhere` | `Person.update({hobby: 'Chess'}).where(p => p.hobby.equals('Jogging'))` | `DELETE { ?a0 ?old_hobby . }` + `INSERT { ?a0 "Chess" . }` + `FILTER` in WHERE | -| `updateForAll` | `Person.update({hobby: 'Chess'}).forAll()` | Same DELETE/INSERT + `OPTIONAL` for old binding in WHERE, no FILTER | -| `updateWhereMultiField` | `Person.update({hobby: 'Chess', name: 'Bob'}).where(p => p.hobby.equals('Jogging'))` | Two DELETE + two INSERT + FILTER | - -**Validation:** -- `npx tsc -p tsconfig-esm.json --noEmit` exits 0 -- `npm test` — ALL existing tests pass (critical: refactored `updateToAlgebra` must not regress) + new update golden tests pass -- After step 4 (refactor), run `npm test` before proceeding — this is the safety gate - ---- - -### Phase 5: Integration Verification — COMPLETED - -**Goal:** Full compile, full test suite, verify all features work together. - -**Tasks:** -1. Run `npm run compile` — both CJS and ESM must succeed. -2. Run `npm test` — full test suite, 0 failures. -3. Verify barrel exports: new types and functions are importable from the package entry point if applicable. - -**Validation:** -- `npm run compile` exits 0 -- `npm test` exits 0 with 0 failures -- No TypeScript errors in any configuration - ---- - -### Phase 6: MINUS Multi-Property Existence (with Nested Path Support) - -**Goal:** Support `.minus(p => [p.hobby, p.name])` and `.minus(p => [p.bestFriend.name])` — exclude entities where ALL listed property paths exist. - -**Semantics:** -```ts -// Flat: exclude any Person that has BOTH a hobby AND a name -Person.select(p => p.name).minus(p => [p.hobby, p.name]) - -// Nested: exclude any Person whose bestFriend has a name -Person.select(p => p.name).minus(p => [p.bestFriend.name]) - -// Mixed: exclude Persons with hobby AND whose bestFriend has a name -Person.select(p => p.name).minus(p => [p.hobby, p.bestFriend.name]) - -// Single property (no array): exclude Persons that have a hobby -Person.select(p => p.name).minus(p => p.hobby) -``` - -Generates: -```sparql --- Flat multi-property -MINUS { ?a0 ?m0 . ?a0 ?m1 . } - --- Nested path -MINUS { ?a0 ?m0 . ?m0 ?m1 . } - --- Mixed -MINUS { ?a0 ?m0 . ?a0 ?m1 . ?m1 ?m2 . } -``` - -**Architecture:** - -The `.minus()` callback currently only accepts `WhereClause` (returns `Evaluation`). We add a third return type: `QueryBuilderObject | QueryBuilderObject[]` for property existence. - -Detection in `toRawInput()` is runtime: `Array.isArray(result)` or `isQueryBuilderObject(result)` vs `result instanceof Evaluation`. The callback proxy (`createProxiedPathBuilder`) already chains `QueryBuilderObject` instances for nested access — `p.bestFriend.name` produces a chain with `.subject → .property` links. - -We extract full property paths using `FieldSet.collectPropertySegments()`, which walks the `.subject` chain and returns `PropertyShape[]` in root-to-leaf order. Each path becomes a `PropertyPathSegment[]` that flows through the pipeline as `propertyPaths: PropertyPathSegment[][]`. - -**Key reuse:** -- `createProxiedPathBuilder(shape)` — same proxy used by `.select()` callbacks -- `FieldSet.collectPropertySegments(obj)` — extracts `PropertyShape[]` path from QBO chain -- `isQueryBuilderObject()` from FieldSet — duck-type detection -- Existing `IRTraversePattern` chaining — same mechanism used by select/where for nested paths - -**Data flow:** - -``` -QueryBuilder.minus(p => [p.bestFriend.name, p.hobby]) - ↓ toRawInput(): detect array, extract paths via collectPropertySegments - ↓ Each QBO → PropertyShape[] → map to {propertyShapeId, ...}[] -RawMinusEntry { - propertyPaths: [ - [{propertyShapeId: 'bestFriend-shape-id'}, {propertyShapeId: 'name-shape-id'}], - [{propertyShapeId: 'hobby-shape-id'}] - ] -} - ↓ desugarSelectQuery(): pass through (property IDs, no transformation needed) -DesugaredMinusEntry { propertyPaths: [...] } - ↓ canonicalizeDesugaredSelectQuery(): pass through -CanonicalMinusEntry { propertyPaths: [...] } - ↓ lowerSelectQuery(): convert each path to chained IRTraversePatterns -IRMinusPattern { - kind: 'minus', - pattern: { - kind: 'join', - patterns: [ - {kind: 'traverse', from: rootAlias, to: 'm0', property: 'bestFriend-id'}, - {kind: 'traverse', from: 'm0', to: 'm1', property: 'name-id'}, - {kind: 'traverse', from: rootAlias, to: 'm2', property: 'hobby-id'}, - ] - } -} - ↓ irToAlgebra step 5b: existing code handles it — no filter, just inner pattern -SparqlMinus { - left: ..., - right: { bgp: [?a0 ?m0, ?m0 ?m1, ?a0 ?m2] } -} -``` - -**New type:** - -```ts -// A single segment in a property path (used for MINUS property existence) -type PropertyPathSegment = { - propertyShapeId: string; -}; -``` - -**Files:** - -| File | Change | -|------|--------| -| `src/queries/QueryBuilder.ts` | Widen `.minus()` to accept property-returning callbacks; detect QBO/array in `toRawInput()`, extract paths via `collectPropertySegments()` | -| `src/queries/IRDesugar.ts` | Add `PropertyPathSegment` type; add `propertyPaths?: PropertyPathSegment[][]` to `RawMinusEntry` and `DesugaredMinusEntry`; thread through | -| `src/queries/IRCanonicalize.ts` | Add `propertyPaths?: PropertyPathSegment[][]` to `CanonicalMinusEntry`; thread through | -| `src/queries/IRLower.ts` | Handle `propertyPaths` case: generate chained `IRTraversePattern` sequences per path, wrap in join | -| `src/test-helpers/query-fixtures.ts` | Add `minusMultiProperty`, `minusNestedPath`, `minusMixed` fixtures | -| `src/tests/sparql-select-golden.test.ts` | Add golden tests for all three | - -**Tasks:** - -1. **IRDesugar.ts** — Add `PropertyPathSegment` type and thread `propertyPaths`: - - Add type: `PropertyPathSegment = { propertyShapeId: string }` - - `RawMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` - - `DesugaredMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` - - In `desugarSelectQuery()`: thread `propertyPaths` through (no transformation) - -2. **IRCanonicalize.ts** — Thread `propertyPaths`: - - `CanonicalMinusEntry`: add `propertyPaths?: PropertyPathSegment[][]` - - In `canonicalizeDesugaredSelectQuery()`: thread through - -3. **QueryBuilder.ts** — Widen `.minus()` and add path extraction: - - Change `MinusEntry` to add `propertyPaths?: PropertyPathSegment[][]` - - In `toRawInput()`, change the minus entry processing: - - For `entry.whereFn`: call the callback via `createProxiedPathBuilder`, then inspect the result - - If result is `Evaluation` → existing WHERE-based path (unchanged) - - If result is array → map each element through `FieldSet.collectPropertySegments()` → `PropertyShape[]` → map to `PropertyPathSegment[]` - - If result is single QBO (not array, not Evaluation) → wrap in array, same as above - - Store as `propertyPaths` on the `RawMinusEntry` - - No signature change needed — `.minus()` already accepts `WhereClause` which is `(s) => Evaluation`. The callback type just becomes more permissive at runtime (returns QBO/array instead of Evaluation). TypeScript type can be widened with a union or overloads. - -4. **IRLower.ts** — Handle `propertyPaths` in minus lowering: - - In the `canonical.minusEntries` loop, add a branch: `if (entry.propertyPaths)` - - For each path (array of segments): - - Chain traverse patterns: first segment from `rootAlias`, each subsequent from previous alias - - Use `ctx.generateAlias()` for fresh aliases (`m0`, `m1`, ...) - - Collect all traverse patterns from all paths into one array - - Wrap in `IRJoinPattern` if multiple, push as `IRMinusPattern` with no filter - -5. **Fixtures + golden tests**: - - `minusMultiProperty`: `Person.select(p => p.name).minus(p => [p.hobby, p.name])` - - Expected: `MINUS { ?a0 ?m0 . ?a0 ?m1 . }` - - `minusNestedPath`: `Person.select(p => p.name).minus(p => [p.bestFriend.name])` - - Expected: `MINUS { ?a0 ?m0 . ?m0 ?m1 . }` - - `minusSingleProperty`: `Person.select(p => p.name).minus(p => p.hobby)` (single, no array) - - Expected: `MINUS { ?a0 ?m0 . }` (same as existing `minusProperty` but via property existence path) - -**Potential issues and mitigations:** - -1. **Runtime type detection**: The callback can return `Evaluation | QueryBuilderObject | QueryBuilderObject[]`. Detection order: `Array.isArray()` first, then `isQueryBuilderObject()` (duck-typed via `'property' in obj`), then fall through to `Evaluation` (has `.getWherePath()`). This is safe because `Evaluation` does NOT have a `property` field, and `QueryBuilderObject` does NOT have `getWherePath()`. - -2. **TypeScript type for `.minus()` callback**: The `WhereClause` type returns `Evaluation`. We need to accept `(s) => Evaluation | QueryBuilderObject | QueryBuilderObject[]`. Two options: - - **Overloads**: separate signatures for condition vs property existence - - **Union return type**: `(s) => Evaluation | any` (since QBO is internal, accepting `any` return from the callback and detecting at runtime) - - Recommend **overloads** for better IntelliSense: - ```ts - minus(shape: ShapeConstructor): QueryBuilder; - minus(fn: WhereClause): QueryBuilder; - minus(fn: (s: ToQueryBuilderObject) => ToQueryBuilderObject[keyof ToQueryBuilderObject] | ToQueryBuilderObject[keyof ToQueryBuilderObject][]): QueryBuilder; - ``` - -3. **Alias collision**: MINUS variables use `ctx.generateAlias()` which auto-increments — same counter as the outer query. No collision possible since the counter is shared. - -4. **Existing single-property `.minus(p => p.hobby)` behavior**: Currently this goes through the WHERE clause path (returns `Evaluation` because accessing `.hobby` on the proxy returns a `QueryBuilderObject` which... wait, no — currently `.minus(p => p.hobby)` is treated as a WhereClause, and `p.hobby` returns a `QueryBuilderObject`. The callback result is then passed to `processWhereClause()` which expects `Evaluation`. Need to verify the current behavior of the single-property case. - - **Resolution**: The existing `minusProperty` fixture `Person.select(p => p.name).minus(p => p.hobby)` already works via the WHERE path — `p.hobby` returns a `QueryBuilderObject` which has a `.getWherePath()` method that `processWhereClause` calls. So the single-property case already works. The new array case is strictly additive. No need to change single-property behavior. - -5. **Shared traversal aliases across paths**: When multiple paths share a prefix (e.g., `p.bestFriend.name` and `p.bestFriend.hobby`), each path gets independent aliases. This is correct for SPARQL — the joins will naturally unify on the shared prefix through the BGP matching. No deduplication needed. - -**Validation:** -- `npx tsc --noEmit` exits 0 -- `npm test` — all existing tests pass (especially existing `minusProperty` test unchanged) -- New golden tests pass with correct SPARQL output -- Verify nested path SPARQL manually: `MINUS { ?a0 ?m0 . ?m0 ?m1 . }` - ---- - -## REVIEW - -### Wrapup Outcomes - -All 6 phases completed successfully. 18 commits on branch `claude/setup-and-summarize-GQoTY`. - -**Implemented scope:** -1. **Phase 1**: IR types & contracts for MINUS, bulk delete, conditional update -2. **Phase 2**: `.minus()` on QueryBuilder (shape, property, condition, chained) -3. **Phase 3**: Bulk delete (`.deleteAll()`, `.delete().all()`, `.delete().where()`, `.deleteWhere()`) -4. **Phase 4**: Conditional update (`.update().where()`, `.update().forAll()`) -5. **Phase 5**: Integration verification (full compile + test suite) -6. **Phase 6**: MINUS multi-property with nested path support (`.minus(p => [p.hobby, p.name])`, `.minus(p => [p.bestFriend.name])`) - -**Cleanup tasks (commit `669df80`):** -- Deprecated `sortBy` in favor of `orderBy` -- Made `data` required in `Shape.update()` -- Simplified delete API: removed `.for()` from DeleteBuilder, `Person.delete(id)` / `Person.deleteAll()` / `Person.deleteWhere(fn)` - -**Test results:** 644 tests pass, 0 failures. TypeScript clean. - -**PR-readiness:** Ready for review. Report doc created at `docs/reports/009-advanced-query-patterns.md`. diff --git a/docs/reports/009-advanced-query-patterns.md b/docs/reports/009-advanced-query-patterns.md index ded46d9..245065f 100644 --- a/docs/reports/009-advanced-query-patterns.md +++ b/docs/reports/009-advanced-query-patterns.md @@ -7,7 +7,7 @@ Implements three core features plus cleanup for the linked data DSL: 4. **MINUS Multi-Property** — `.minus(p => [p.hobby, p.name])` with nested path support 5. **API Cleanup** — deprecate `sortBy`, require `update()` data, simplify delete API -Named graphs: deferred (out of scope). See [ideation doc](../ideas/007-advanced-query-patterns.md). +Named graphs: deferred (out of scope). Tracked as a future enhancement. --- @@ -244,7 +244,7 @@ All golden tests use exact `toBe` matching on full SPARQL strings. ## Deferred Work -- **Named graphs**: Route B from ideation doc. See `docs/ideas/007-advanced-query-patterns.md`. +- **Named graphs**: Deferred to a separate scope. - **Preload/eager loading**: Discussed but deferred to a separate scope. - **MINUS with aggregation**: `.minus()` inside subqueries with GROUP BY — not currently supported.