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/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 131603a..0000000 --- a/docs/ideas/007-advanced-query-patterns.md +++ /dev/null @@ -1,174 +0,0 @@ -# Advanced Query Patterns (MINUS & DELETE WHERE) - -## 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 - -Both use algebra types already defined in `SparqlAlgebra.ts`. - ---- - -## Part 1: MINUS (Set Difference) - -### Motivation - -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. - -### DSL examples - -```ts -// People who are NOT employees -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 . } -// } -``` - -```ts -// Orders that haven't been shipped (no shippedDate property) -Order.select(o => o.id).minus(o => o.shippedDate) - -// Generated SPARQL: -// SELECT ?s WHERE { -// ?s rdf:type ex:Order . -// MINUS { ?s ex:shippedDate ?shipped . } -// } -``` - -```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 . } -// } -``` - -### Algebra mapping - -Uses the existing `SparqlMinus` algebra node: - -```ts -type SparqlMinus = { - type: 'minus'; - left: SparqlAlgebraNode; - right: SparqlAlgebraNode; -}; -``` - -Already serialized by `algebraToString.ts` as `left\nMINUS {\n right\n}`. - -### MINUS vs NOT EXISTS - -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. - ---- - -## Part 2: DELETE WHERE (Bulk Delete) - -### Motivation - -`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 - -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. - -### DSL examples - -```ts -// Delete all temporary records -TempRecord.deleteAll() - -// Generated SPARQL: -// DELETE WHERE { -// ?s rdf:type ex:TempRecord . -// ?s ?p ?o . -// } -``` - -```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 . -// } -``` - -```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. -``` - -### Algebra mapping - -Uses the existing `SparqlDeleteWherePlan`: - -```ts -type SparqlDeleteWherePlan = { - type: 'delete_where'; - patterns: SparqlAlgebraNode; - graph?: string; -}; -``` - -Already serialized by `algebraToString.ts` as `DELETE WHERE { ... }`. - -### When DELETE WHERE vs DELETE/INSERT/WHERE - -| 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) | - ---- - -## Implementation considerations - -### 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 - -### 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) - -## Open questions - -- 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? diff --git a/docs/reports/009-advanced-query-patterns.md b/docs/reports/009-advanced-query-patterns.md new file mode 100644 index 0000000..245065f --- /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). Tracked as a future enhancement. + +--- + +## 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**: 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. + +--- + +## 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 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 diff --git a/src/queries/DeleteBuilder.ts b/src/queries/DeleteBuilder.ts index cc84403..755142c 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; } /** @@ -17,26 +29,33 @@ 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 * ``` * - * 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[]; + 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 { + private clone(overrides: Partial> = {}): DeleteBuilder { return new DeleteBuilder({ shape: this._shape, ids: this._ids, + mode: this._mode, + whereFn: this._whereFn, ...overrides, }); } @@ -45,23 +64,14 @@ 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[], - ): DeleteBuilder { + ): 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}); } @@ -70,21 +80,51 @@ 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}); + /** 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. Returns void. */ + where(fn: WhereClause): DeleteBuilder { + return this.clone({mode: 'where', whereFn: fn, ids: undefined}) as DeleteBuilder; } // --------------------------------------------------------------------------- // 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. Use DeleteBuilder.from(shape, ids), .all(), or .where().', ); } const factory = new DeleteQueryFactory( @@ -95,16 +135,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); @@ -112,11 +156,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/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/IRCanonicalize.ts b/src/queries/IRCanonicalize.ts index 759776c..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'; @@ -38,8 +39,16 @@ export type CanonicalWhereExpression = | CanonicalWhereExists | CanonicalWhereNot; -export type CanonicalDesugaredSelectQuery = Omit & { +/** A canonicalized MINUS entry. */ +export type CanonicalMinusEntry = { + shapeId?: string; where?: CanonicalWhereExpression; + propertyPaths?: PropertyPathSegment[][]; +}; + +export type CanonicalDesugaredSelectQuery = Omit & { + where?: CanonicalWhereExpression; + minusEntries?: CanonicalMinusEntry[]; }; const toComparison = ( @@ -192,5 +201,10 @@ 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, + propertyPaths: entry.propertyPaths, + })), }; }; diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index 8f267bc..405ff75 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -18,6 +18,18 @@ 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 = { entries: readonly FieldSetEntry[]; where?: WherePath; @@ -28,6 +40,7 @@ export type RawSelectInput = { limit?: number; offset?: number; singleResult?: boolean; + minusEntries?: RawMinusEntry[]; }; export type DesugaredPropertyStep = { @@ -118,6 +131,13 @@ export type DesugaredWhereArg = } | DesugaredWhere; +/** A desugared MINUS entry. */ +export type DesugaredMinusEntry = { + shapeId?: string; + where?: DesugaredWhere; + propertyPaths?: PropertyPathSegment[][]; +}; + export type DesugaredSelectQuery = { kind: 'desugared_select'; shapeId?: string; @@ -129,6 +149,7 @@ export type DesugaredSelectQuery = { selections: DesugaredSelection[]; sortBy?: DesugaredSortBy; where?: DesugaredWhere; + minusEntries?: DesugaredMinusEntry[]; }; const isShapeRef = (value: unknown): value is ShapeReferenceValue => @@ -345,7 +366,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 { @@ -411,5 +432,10 @@ 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, + propertyPaths: entry.propertyPaths, + })), }; }; diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 9154f92..6046cd3 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -357,10 +357,74 @@ 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.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[] = []; + 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, @@ -372,3 +436,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}; +}; 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/QueryBuilder.ts b/src/queries/QueryBuilder.ts index 3a10739..c8a11ef 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 = { @@ -36,6 +37,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 +58,7 @@ interface QueryBuilderInit { selectAllLabels?: string[]; fieldSet?: FieldSet; preloads?: PreloadEntry[]; + minusEntries?: MinusEntry[]; } /** @@ -82,6 +90,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 +106,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 +125,7 @@ export class QueryBuilder selectAllLabels: this._selectAllLabels, fieldSet: this._fieldSet, preloads: this._preloads, + minusEntries: this._minusEntries, ...overrides, }); } @@ -175,13 +186,37 @@ export class QueryBuilder return this.clone({whereFn: fn}); } + /** + * Exclude results matching a MINUS pattern. + * + * 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 | ((s: any) => any)): 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}); } /** - * 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); @@ -434,6 +469,40 @@ export class QueryBuilder input.subjects = this._subjects; } + // 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 {}; + }); + } + return input; } diff --git a/src/queries/UpdateBuilder.ts b/src/queries/UpdateBuilder.ts index fae7db8..847fb87 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; } /** @@ -21,30 +30,35 @@ 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 * ``` * - * `.for(id)` must be called before `.build()` or `.exec()`. - * - * 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; 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 { + private clone(overrides: Partial> = {}): UpdateBuilder { return new UpdateBuilder({ shape: this._shape, data: this._data, targetId: this._targetId, + mode: this._mode, + whereFn: this._whereFn, ...overrides, }); } @@ -62,31 +76,58 @@ export class UpdateBuilder = // Fluent API // --------------------------------------------------------------------------- - /** Target a specific entity by ID. Required before build/exec. */ - for(id: string | NodeReferenceValue): UpdateBuilder { + /** 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. Returns void. */ + forAll(): UpdateBuilder { + return this.clone({mode: 'forAll', targetId: undefined, whereFn: undefined}) as unknown as UpdateBuilder; } - /** Set the update data. */ - set>(data: NewU): UpdateBuilder { - return this.clone({data}) 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; + } + + /** Replace the update data. */ + set>(data: NewU): UpdateBuilder { + return this.clone({data}) as unknown as 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,17 +138,49 @@ export class UpdateBuilder = return factory.build(); } + private buildUpdateWhere(): UpdateQuery { + 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>; + 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); @@ -115,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/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/shapes/Shape.ts b/src/shapes/Shape.ts index 44b1c87..d828f22 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'; @@ -154,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>( @@ -181,6 +178,21 @@ export abstract class Shape { return DeleteBuilder.from(this, id) as DeleteBuilder; } + /** Delete all instances of this shape type. Returns void. */ + static deleteAll( + this: ShapeConstructor, + ): DeleteBuilder { + return (DeleteBuilder.from(this) as DeleteBuilder).all(); + } + + /** Delete instances matching a condition. Sugar for `.delete().where(fn)`. Returns void. */ + 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 505548f..be798da 100644 --- a/src/sparql/SparqlStore.ts +++ b/src/sparql/SparqlStore.ts @@ -13,7 +13,10 @@ import { selectToSparql, createToSparql, updateToSparql, + updateWhereToSparql, deleteToSparql, + deleteAllToSparql, + deleteWhereToSparql, } from './irToAlgebra.js'; import { mapSparqlSelectResult, @@ -84,12 +87,27 @@ export abstract class SparqlStore implements IQuadStore { } async updateQuery(query: UpdateQuery): Promise { + if (query.kind === 'update_where') { + const sparql = updateWhereToSparql(query, this.options); + await this.executeSparqlUpdate(sparql); + return {id: ''} as UpdateResult; + } const sparql = updateToSparql(query, this.options); await this.executeSparqlUpdate(sparql); return mapSparqlUpdateResult(query); } async deleteQuery(query: DeleteQuery): Promise { + 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); return { diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index e892a70..585eede 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'; @@ -321,6 +345,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 +550,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 +833,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}`); } @@ -937,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); @@ -965,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); @@ -998,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; } @@ -1006,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) { @@ -1027,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))); @@ -1038,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) { @@ -1046,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', @@ -1123,13 +1197,256 @@ 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, + }; +} + +/** + * 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, insertPatterns, oldValueTriples} = processUpdateFields(query.data, subjectTerm, options); + + // 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}; + } + + whereAlgebra = wrapOldValueOptionals(whereAlgebra, oldValueTriples); + + 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, @@ -1165,7 +1482,6 @@ export function updateToSparql( /** * Converts an IRDeleteMutation to a SPARQL string. - * Stub: will be implemented when algebraToString is available. */ export function deleteToSparql( query: IRDeleteMutation, @@ -1174,3 +1490,36 @@ 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); +} + +/** + * 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 ed83f1d..0791e6a 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/'; @@ -310,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: () => @@ -442,4 +443,54 @@ 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')), + + // 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 + deleteAll: () => Person.deleteAll(), + + // 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 + 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/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); diff --git a/src/tests/sparql-mutation-golden.test.ts b/src/tests/sparql-mutation-golden.test.ts index f01bcce..88b1bbe 100644 --- a/src/tests/sparql-mutation-golden.test.ts +++ b/src/tests/sparql-mutation-golden.test.ts @@ -14,12 +14,18 @@ import {captureQuery} from '../test-helpers/query-capture-store'; import { createToSparql, updateToSparql, + updateWhereToSparql, deleteToSparql, + deleteAllToSparql, + deleteWhereToSparql, } from '../sparql/irToAlgebra'; import type { IRCreateMutation, IRUpdateMutation, IRDeleteMutation, + IRDeleteAllMutation, + IRDeleteWhereMutation, + IRUpdateWhereMutation, } from '../queries/IntermediateRepresentation'; import '../ontologies/rdf'; @@ -387,3 +393,75 @@ 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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)); + }); +}); diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index 26eeb6e..f245f21 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -1085,3 +1085,100 @@ 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"'); + }); + + 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 . + } +}`); + }); +});