diff --git a/.changeset/advanced-query-patterns.md b/.changeset/advanced-query-patterns.md deleted file mode 100644 index b2f6b4c..0000000 --- a/.changeset/advanced-query-patterns.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@_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/.changeset/computed-expressions-and-updates.md b/.changeset/computed-expressions-and-updates.md new file mode 100644 index 0000000..6491f4a --- /dev/null +++ b/.changeset/computed-expressions-and-updates.md @@ -0,0 +1,48 @@ +--- +"@_linked/core": minor +--- + +Properties in `select()` and `update()` now support expressions — you can compute values dynamically instead of just reading or writing raw fields. + +### What's new + +- **Computed fields in queries** — chain expression methods on properties to derive new values: string manipulation (`.strlen()`, `.ucase()`, `.concat()`), arithmetic (`.plus()`, `.times()`, `.abs()`), date extraction (`.year()`, `.month()`, `.hours()`), and comparisons (`.gt()`, `.eq()`, `.contains()`). + + ```typescript + await Person.select(p => ({ + name: p.name, + nameLen: p.name.strlen(), + ageInMonths: p.age.times(12), + })); + ``` + +- **Expression-based WHERE filters** — filter using computed conditions, not just equality checks. Works on queries, updates, and deletes. + + ```typescript + await Person.select(p => p.name).where(p => p.name.strlen().gt(5)); + await Person.update({ verified: true }).where(p => p.age.gte(18)); + ``` + +- **Computed updates** — when updating data, calculate new values based on existing ones instead of providing static values. Pass a callback to `update()` to reference current field values. + + ```typescript + await Person.update(p => ({ age: p.age.plus(1) })).for(entity); + await Person.update(p => ({ label: p.firstName.concat(' ').concat(p.lastName) })).for(entity); + ``` + +- **`Expr` module** — for expressions that don't start from a property, like the current timestamp, conditional logic, or coalescing nulls. + + ```typescript + await Person.update({ lastSeen: Expr.now() }).for(entity); + await Person.select(p => ({ + displayName: Expr.firstDefined(p.nickname, p.name), + })); + ``` + +Update expression callbacks are fully typed — `.plus()` only appears on number properties, `.strlen()` only on strings, etc. + +### New exports + +`ExpressionNode`, `Expr`, `ExpressionInput`, `PropertyRefMap`, `ExpressionUpdateProxy`, `ExpressionUpdateResult`, and per-type method interfaces (`NumericExpressionMethods`, `StringExpressionMethods`, `DateExpressionMethods`, `BooleanExpressionMethods`, `BaseExpressionMethods`). + +See the [README](./README.md#computed-expressions) for the full method reference and more examples. diff --git a/.changeset/dispatch-registry-circular-deps.md b/.changeset/dispatch-registry-circular-deps.md deleted file mode 100644 index fc8e32a..0000000 --- a/.changeset/dispatch-registry-circular-deps.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@_linked/core": minor ---- - -**Breaking:** `QueryParser` has been removed. If you imported `QueryParser` directly, replace with `getQueryDispatch()` from `@_linked/core/queries/queryDispatch`. The Shape DSL (`Shape.select()`, `.create()`, `.update()`, `.delete()`) and `SelectQuery.exec()` are unchanged. - -**New:** `getQueryDispatch()` and `setQueryDispatch()` are now exported, allowing custom query dispatch implementations (e.g. for testing or alternative storage backends) without subclassing `LinkedStorage`. diff --git a/.changeset/dynamic-queries-2.0.md b/.changeset/dynamic-queries-2.0.md deleted file mode 100644 index b9b7c95..0000000 --- a/.changeset/dynamic-queries-2.0.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -'@_linked/core': major ---- - -## Breaking Changes - -### `Shape.select()` and `Shape.update()` no longer accept an ID as the first argument - -Use `.for(id)` to target a specific entity instead. - -**Select:** -```typescript -// Before -const result = await Person.select({id: '...'}, p => p.name); - -// After -const result = await Person.select(p => p.name).for({id: '...'}); -``` - -`.for(id)` unwraps the result type from array to single object, matching the old single-subject overload behavior. - -**Update:** -```typescript -// Before -const result = await Person.update({id: '...'}, {name: 'Alice'}); - -// After -const result = await Person.update({name: 'Alice'}).for({id: '...'}); -``` - -`Shape.selectAll(id)` also no longer accepts an id — use `Person.selectAll().for(id)`. - -### `ShapeType` renamed to `ShapeConstructor` - -The type alias for concrete Shape subclass constructors has been renamed. Update any imports or references: - -```typescript -// Before -import type {ShapeType} from '@_linked/core/shapes/Shape'; - -// After -import type {ShapeConstructor} from '@_linked/core/shapes/Shape'; -``` - -### `QueryString`, `QueryNumber`, `QueryBoolean`, `QueryDate` classes removed - -These have been consolidated into a single generic `QueryPrimitive` class. If you were using `instanceof` checks against these classes, use `instanceof QueryPrimitive` instead and check the value's type. - -### Internal IR types removed - -The following types and functions have been removed from `SelectQuery`. These were internal pipeline types — if you were using them for custom store integrations, the replacement is `FieldSetEntry[]` (available from `FieldSet`): - -- Types: `SelectPath`, `QueryPath`, `CustomQueryObject`, `SubQueryPaths`, `ComponentQueryPath` -- Functions: `fieldSetToSelectPath()`, `entryToQueryPath()` -- Methods: `QueryBuilder.getQueryPaths()`, `BoundComponent.getComponentQueryPaths()` -- `RawSelectInput.select` field renamed to `RawSelectInput.entries` (type changed from `SelectPath` to `FieldSetEntry[]`) - -### `getPackageShape()` return type is now nullable - -Returns `ShapeConstructor | undefined` instead of `typeof Shape`. Code that didn't null-check the return value will now get TypeScript errors. - -## New Features - -### `.for(id)` and `.forAll(ids)` chaining - -Consistent API for targeting entities across select and update operations: - -```typescript -// Single entity (result is unwrapped, not an array) -await Person.select(p => p.name).for({id: '...'}); -await Person.select(p => p.name).for('https://...'); - -// Multiple specific entities -await QueryBuilder.from(Person).select(p => p.name).forAll([{id: '...'}, {id: '...'}]); - -// All instances (default — no .for() needed) -await Person.select(p => p.name); -``` - -### Dynamic Query Building with `QueryBuilder` and `FieldSet` - -Build queries programmatically at runtime — for CMS dashboards, API endpoints, configurable reports. See the [Dynamic Query Building](./README.md#dynamic-query-building) section in the README for full documentation and examples. - -Key capabilities: -- `QueryBuilder.from(Person)` or `QueryBuilder.from('https://schema.org/Person')` — fluent, chainable, immutable query construction -- `FieldSet.for(Person, ['name', 'knows'])` — composable field selections with `.add()`, `.remove()`, `.pick()`, `FieldSet.merge()` -- `FieldSet.all(Person, {depth: 2})` — select all decorated properties with optional depth -- JSON serialization: `query.toJSON()` / `QueryBuilder.fromJSON(json)` and `fieldSet.toJSON()` / `FieldSet.fromJSON(json)` -- All builders are `PromiseLike` — `await` them directly or call `.build()` to inspect the IR - -### Mutation Builders - -`CreateBuilder`, `UpdateBuilder`, and `DeleteBuilder` provide the programmatic equivalent of `Person.create()`, `Person.update()`, and `Person.delete()`, accepting Shape classes or shape IRI strings. See the [Mutation Builders](./README.md#mutation-builders) section in the README. - -### `PropertyPath` exported - -The `PropertyPath` value object is now a public export — a type-safe representation of a sequence of property traversals through a shape graph. - -```typescript -import {PropertyPath, walkPropertyPath} from '@_linked/core'; -``` - -### `ShapeConstructor` type - -New concrete constructor type for Shape subclasses. Eliminates ~30 `as any` casts across the codebase and provides better type safety at runtime boundaries (builder `.from()` methods, Shape static methods). diff --git a/.changeset/gold-rats-shout.md b/.changeset/gold-rats-shout.md deleted file mode 100644 index bed8dbe..0000000 --- a/.changeset/gold-rats-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@_linked/core": minor ---- - -Replaced internal query representation with a canonical backend-agnostic IR AST. `SelectQuery`, `CreateQuery`, `UpdateQuery`, and `DeleteQuery` are now typed IR objects with `kind` discriminators, compact shape/property ID references, and expression trees — replacing the previous ad-hoc nested arrays. The public Shape DSL is unchanged; what changed is what `IQuadStore` implementations receive. Store result types (`ResultRow`, `SelectResult`, `CreateResult`, `UpdateResult`) are now exported. All factories expose `build()` as the primary method. See `documentation/intermediate-representation.md` for the full IR reference and migration guidance. diff --git a/.changeset/nested-subselect-ir-completeness.md b/.changeset/nested-subselect-ir-completeness.md deleted file mode 100644 index 2b3459e..0000000 --- a/.changeset/nested-subselect-ir-completeness.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@_linked/core": patch ---- - -Preserve nested array sub-select branches in canonical IR so `build()` emits complete traversals, projection fields, and `resultMap` entries for nested selections. - -This fixes cases where nested branches present in `toRawInput().select` were dropped during desugar/lowering (for example nested `friends.select([name, hobby])` branches under another sub-select). - -Also adds regression coverage for desugar preservation, IR lowering completeness, and updated SPARQL golden output for nested query fixtures. diff --git a/.changeset/sparql-conversion-layer.md b/.changeset/sparql-conversion-layer.md deleted file mode 100644 index fb11999..0000000 --- a/.changeset/sparql-conversion-layer.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@_linked/core": minor ---- - -Add SPARQL conversion layer — compiles Linked IR queries into executable SPARQL and maps results back to typed DSL objects. - -**New exports from `@_linked/core/sparql`:** - -- **`SparqlStore`** — abstract base class for SPARQL-backed stores. Extend it and implement two methods to connect any SPARQL 1.1 endpoint: - ```ts - import {SparqlStore} from '@_linked/core/sparql'; - - class MyStore extends SparqlStore { - protected async executeSparqlSelect(sparql: string): Promise { /* ... */ } - protected async executeSparqlUpdate(sparql: string): Promise { /* ... */ } - } - ``` - -- **IR → SPARQL string** convenience functions (full pipeline in one call): - - `selectToSparql(query, options?)` — SelectQuery → SPARQL string - - `createToSparql(query, options?)` — CreateQuery → SPARQL string - - `updateToSparql(query, options?)` — UpdateQuery → SPARQL string - - `deleteToSparql(query, options?)` — DeleteQuery → SPARQL string - -- **IR → SPARQL algebra** (for stores that want to inspect/optimize the algebra before serialization): - - `selectToAlgebra(query, options?)` — returns `SparqlSelectPlan` - - `createToAlgebra(query, options?)` — returns `SparqlInsertDataPlan` - - `updateToAlgebra(query, options?)` — returns `SparqlDeleteInsertPlan` - - `deleteToAlgebra(query, options?)` — returns `SparqlDeleteInsertPlan` - -- **Algebra → SPARQL string** serialization: - - `selectPlanToSparql(plan, options?)`, `insertDataPlanToSparql(plan, options?)`, `deleteInsertPlanToSparql(plan, options?)`, `deleteWherePlanToSparql(plan, options?)` - - `serializeAlgebraNode(node)`, `serializeExpression(expr)`, `serializeTerm(term)` - -- **Result mapping** (SPARQL JSON results → typed DSL objects): - - `mapSparqlSelectResult(json, query)` — handles flat/nested/aggregated results with XSD type coercion - - `mapSparqlCreateResult(uri, query)` — echoes created fields with generated URI - - `mapSparqlUpdateResult(query)` — echoes updated fields - -- **All algebra types** re-exported: `SparqlTerm`, `SparqlTriple`, `SparqlAlgebraNode`, `SparqlExpression`, `SparqlSelectPlan`, `SparqlInsertDataPlan`, `SparqlDeleteInsertPlan`, `SparqlDeleteWherePlan`, `SparqlPlan`, `SparqlOptions`, etc. - -**Bug fixes included:** -- Fixed `isNodeReference()` in MutationQuery.ts — nested creates with predefined IDs (e.g., `{id: '...', name: 'Bestie'}`) now correctly insert entity data instead of only creating the link. - -See [SPARQL Algebra Layer docs](./documentation/sparql-algebra.md) for the full type reference, conversion rules, and store implementation guide. diff --git a/.gitignore b/.gitignore index 5a50fb0..4da9b6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ lib/ +packages/ .claude/ .agents/ OLD \ No newline at end of file diff --git a/README.md b/README.md index 7971f8a..7fd645a 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,26 @@ Shape class → DSL query → IR (AST) → Target query language → Execute → Shape classes use decorators to generate SHACL metadata. These shapes define the data model, drive the DSL's type safety, and can be synced to a store for runtime data validation. ```typescript +import {createNameSpace} from '@_linked/core/utils/NameSpace'; + +const ns = createNameSpace('https://example.org/'); + +// Example ontology references +const ex = { + Person: ns('Person'), + name: ns('name'), + knows: ns('knows'), + // ... rest of your ontology +}; + @linkedShape export class Person extends Shape { - static targetClass = schema('Person'); + static targetClass = ex.Person; - @literalProperty({path: schema('name'), maxCount: 1}) + @literalProperty({path: ex.name, maxCount: 1}) get name(): string { return ''; } - @objectProperty({path: schema('knows'), shape: Person}) + @objectProperty({path: ex.knows, shape: Person}) get friends(): ShapeSet { return null; } } ``` @@ -202,19 +214,24 @@ import {literalProperty, objectProperty} from '@_linked/core/shapes/SHACL'; import {createNameSpace} from '@_linked/core/utils/NameSpace'; import {linkedShape} from './package'; -const schema = createNameSpace('https://schema.org/'); -const PersonClass = schema('Person'); -const name = schema('name'); -const knows = schema('knows'); +const ns = createNameSpace('https://example.org/'); + +// Example ontology references +const ex = { + Person: ns('Person'), + name: ns('name'), + knows: ns('knows'), + // ... rest of your ontology +}; @linkedShape export class Person extends Shape { - static targetClass = PersonClass; + static targetClass = ex.Person; - @literalProperty({path: name, required: true, maxCount: 1}) + @literalProperty({path: ex.name, required: true, maxCount: 1}) declare name: string; - @objectProperty({path: knows, shape: Person}) + @objectProperty({path: ex.knows, shape: Person}) declare knows: ShapeSet; } ``` @@ -288,12 +305,16 @@ The query DSL is schema-parameterized: you define your own SHACL shapes, and Lin - Outer `where(...)` chaining - Counting with `.size()` - Custom result formats (object mapping) +- Computed values — derive new fields with arithmetic, string, date, and comparison methods +- Expression-based WHERE filters (`p.name.strlen().gt(5)`) +- Standalone expressions with `Expr` — timestamps, conditionals, null coalescing - 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 (including bulk and conditional) +- Expression-based updates (`p => ({age: p.age.plus(1)})`) - Dynamic query building with `QueryBuilder` - Composable field sets with `FieldSet` - Mutation builders (`CreateBuilder`, `UpdateBuilder`, `DeleteBuilder`) @@ -402,9 +423,97 @@ const custom = await Person.select((p) => ({ })); ``` +#### Computed expressions + +You can compute derived values directly in your queries — string manipulation, arithmetic, date extraction, and more. Expression methods chain naturally left-to-right. + +```typescript +// String length as a computed field +const withLen = await Person.select((p) => ({ + name: p.name, + nameLen: p.name.strlen(), +})); + +// Arithmetic chaining (left-to-right, no hidden precedence) +const withAge = await Person.select((p) => ({ + name: p.name, + ageInMonths: p.age.times(12), + agePlusTen: p.age.plus(10).times(2), // (age + 10) * 2 +})); + +// String manipulation +const upper = await Person.select((p) => ({ + shout: p.name.ucase(), + greeting: p.name.concat(' says hello'), +})); + +// Date extraction +const birthYear = await Person.select((p) => ({ + year: p.birthDate.year(), +})); +``` + +**Expression methods by type:** + +| Type | Methods | +|------|---------| +| **Numeric** | `plus`, `minus`, `times`, `divide`, `abs`, `round`, `ceil`, `floor`, `power` | +| **String** | `concat`, `contains`, `startsWith`, `endsWith`, `substr`, `before`, `after`, `replace`, `ucase`, `lcase`, `strlen`, `encodeForUri`, `matches` | +| **Date** | `year`, `month`, `day`, `hours`, `minutes`, `seconds`, `timezone`, `tz` | +| **Boolean** | `and`, `or`, `not` | +| **Comparison** | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` | +| **Null-handling** | `isDefined`, `isNotDefined`, `defaultTo` | +| **Type** | `str`, `iri`, `isIri`, `isLiteral`, `isBlank`, `isNumeric`, `lang`, `datatype` | +| **Hash** | `md5`, `sha256`, `sha512` | + +#### Expression-based WHERE filters + +Expressions can be used in `where()` clauses for computed filtering: + +```typescript +// Filter by string length +const longNames = await Person.select((p) => p.name) + .where((p) => p.name.strlen().gt(5)); + +// Filter by arithmetic +const young = await Person.select((p) => p.name) + .where((p) => p.age.plus(10).lt(100)); + +// Chain expressions with and/or +const filtered = await Person.select((p) => p.name) + .where((p) => p.name.strlen().gt(3).and(p.age.gt(18))); + +// Expression WHERE on nested paths +const deep = await Person.select((p) => p.name) + .where((p) => p.bestFriend.name.strlen().gt(3)); + +// Expression WHERE on mutations +await Person.update({status: 'senior'}).where((p) => p.age.plus(10).gt(65)); +``` + +#### `Expr` module + +Some expressions don't belong to a specific property — like getting the current timestamp, picking the first non-null value, or conditional logic. Use the `Expr` module for these: + +```typescript +import {Expr} from '@_linked/core'; + +// Current timestamp +const withTimestamp = await Person.update({lastSeen: Expr.now()}).for(entity); + +// Conditional expressions +const labeled = await Person.select((p) => ({ + label: Expr.ifThen(p.age.gt(18), 'adult', 'minor'), +})); + +// First non-null value +const display = await Person.select((p) => ({ + display: Expr.firstDefined(p.name, p.nickNames, Expr.str('Unknown')), +})); +``` + #### Query As (type casting to a sub shape) -If person.pets returns an array of Pets. And Dog extends Pet. -And you want to select properties of those pets that are dogs: +Cast to a subtype when you know the concrete shape — for example, selecting dog-specific properties from a pets collection: ```typescript const guards = await Person.select((p) => p.pets.as(Dog).guardDogLevel); ``` @@ -479,6 +588,29 @@ Returns: } ``` +**Expression-based updates:** + +Instead of static values, you can compute new values from existing ones. Pass a callback to reference the entity's current properties: + +```typescript +// Increment age by 1 +await Person.update((p) => ({age: p.age.plus(1)})).for({id: 'https://my.app/node1'}); + +// Uppercase a name +await Person.update((p) => ({name: p.name.ucase()})).for({id: 'https://my.app/node1'}); + +// Reference related entity properties +await Person.update((p) => ({hobby: p.bestFriend.name.ucase()})).for({id: 'https://my.app/node1'}); + +// Mix literals and expressions +await Person.update((p) => ({name: 'Bob', age: p.age.plus(1)})).for({id: 'https://my.app/node1'}); + +// Use Expr module values directly in plain objects +await Person.update({lastSeen: Expr.now()}).for({id: 'https://my.app/node1'}); +``` + +The callback is type-safe — `.plus()` only appears on number properties, `.ucase()` only on strings, etc. + **Conditional and bulk updates:** ```typescript // Update all matching entities @@ -554,23 +686,17 @@ This example assumes `Person` from the `Shapes` section above. ```typescript import {literalProperty} from '@_linked/core/shapes/SHACL'; -import {createNameSpace} from '@_linked/core/utils/NameSpace'; import {linkedShape} from './package'; -const schema = createNameSpace('https://schema.org/'); -const EmployeeClass = schema('Employee'); -const name = schema('name'); -const employeeId = schema('employeeId'); - @linkedShape export class Employee extends Person { - static targetClass = EmployeeClass; + static targetClass = ex.Employee; // Override inherited "name" with stricter constraints (still maxCount: 1) - @literalProperty({path: name, required: true, minLength: 2, maxCount: 1}) + @literalProperty({path: ex.name, required: true, minLength: 2, maxCount: 1}) declare name: string; - @literalProperty({path: employeeId, required: true, maxCount: 1}) + @literalProperty({path: ex.employeeId, required: true, maxCount: 1}) declare employeeId: string; } ``` diff --git a/docs/agents/skills/automatic/SKILL.md b/docs/agents/skills/automatic/SKILL.md deleted file mode 100644 index 40e558e..0000000 --- a/docs/agents/skills/automatic/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: automatic -description: Execute the full workflow cycle (ideation -> plan -> tasks -> implementation -> review) without waiting for user confirmation between modes. Use only when the user explicitly requests automatic mode. Never auto-suggest or auto-enter this mode. Pause after review and ask whether to continue with wrapup or iterate. ---- - -# Instructions - -## Trigger - -Run only when the user explicitly requests `automatic` mode. -Do not auto-enter this mode. -Do not suggest this mode in startup mode prompts. - -## Objective - -Run the standard workflow end-to-end without waiting for user confirmation between internal mode transitions: - -1. `ideation` -2. `plan` -3. `tasks` -4. `implementation` -5. `review` - -Then pause. - -## Core behavior - -1. Execute each mode in sequence using the same requirements and artifact rules as the corresponding individual mode skills. -2. Keep momentum: do not stop between internal mode transitions unless blocked by missing mandatory input, hard failures, or safety constraints. -3. In ideation, always document alternatives (at least two viable routes when applicable), evaluate tradeoffs, propose the best route, and explicitly select it before entering plan mode. -4. After selecting the route, continue immediately into plan mode, then tasks mode, then implementation mode, then review mode. -5. Pause after review and ask the user to choose exactly one: - - `wrapup` - - `iterate` - -## Mandatory transition gates - -Never transition to the next mode until the current mode's required on-disk artifacts are complete. - -Before leaving each mode, enforce all checks below: - -1. Ideation -> Plan gate: - - A concrete ideation doc exists/was updated in `docs/ideas/-.md`. - - The doc contains alternatives, tradeoffs, and a clearly selected route. - -2. Plan -> Tasks gate: - - A plan doc exists/was updated in `docs/plans/-.md`. - - The plan includes architecture decisions, expected file changes, pitfalls, and explicit contracts. - - The plan is focused on the chosen route (not a list of all routes). - -3. Tasks -> Implementation gate: - - The same plan doc includes phased tasks. - - Every phase has explicit validation criteria. - - Dependency graph / parallelization notes are present. - -4. Implementation -> Review gate: - - Implementation work is executed phase by phase against the tasked plan. - - Validation for each completed phase is run and recorded. - - The plan doc is updated after each completed phase before moving on. - -If any gate check fails, stop and fix the missing artifact first. Do not implement code before the tasks gate is satisfied. - -## Artifact rules - -Use the same artifact contract as the normal workflow: - -- `ideation`: create/update `docs/ideas/-.md` -- `plan`: create/update `docs/plans/-.md` -- `tasks`: update the active plan doc with phases/tasks/validation -- `implementation`: execute phases and update the active plan doc after completed phases -- `review`: emit findings first, then update plan/docs according to resolved now-vs-future decisions - -Follow numbering and conversion rules from workflow mode. - -## Iterate loop - -If the user chooses `iterate` after review: - -1. Continue with another cycle focused on the review-identified gaps: - - ideation -> plan -> tasks -> implementation -> review -2. Reuse the same active plan document for this loop. -3. Append/refine phases/tasks in that existing plan document instead of creating a new plan file. -4. After the follow-up review, pause again and ask the same decision: - - `wrapup` - - `iterate` - -Repeat until the user selects `wrapup` or stops. - -## Handoff to wrapup - -Do not enter `wrapup` automatically. -Enter `wrapup` only after the user explicitly chooses `wrapup` at a review pause point. - -## Guardrails - -- Do not bypass required validation in implementation. -- Do not skip required artifact updates. -- Do not silently change the chosen route without documenting why. -- If critical ambiguity appears that blocks correct implementation, ask focused clarification questions, then resume automatic progression. -- If implementation was started prematurely, pause and backfill the missing plan/tasks artifacts before continuing. -- Treat missing `docs/plans` as a hard blocker for implementation mode. - -## Exit criteria - -- Reached review mode and paused with explicit `wrapup` vs `iterate` question, or -- User explicitly selected `wrapup` and control has been handed off to wrapup mode. diff --git a/docs/agents/skills/ideation/SKILL.md b/docs/agents/skills/ideation/SKILL.md deleted file mode 100644 index 4fa6ffe..0000000 --- a/docs/agents/skills/ideation/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: ideation -description: Explore and compare candidate implementation routes before committing to a plan. ---- - -# Instructions - -## Objective - -Explore potential routes and architecture choices before locking in implementation direction. - -## Entry gate - -Run this mode only after the user explicitly confirms ideation mode for the current task. - -## Steps - -1. Read relevant code, tests, and docs first. -2. Create/update `docs/ideas/-.md`. - - For new ideation docs, `` MUST be the next available 3-digit prefix in `docs/ideas`. -3. For each major architecture decision, list multiple viable approaches when applicable. -4. For each approach, document tradeoffs, pros/cons, and potential risks. -5. Capture user feedback and narrow choices. - -## Guardrails - -- Do not write implementation code in this mode. -- Do not convert ideation into a plan unless the user explicitly requests it. - -## Exit criteria - -- Key decisions and route options are documented. -- User feedback has narrowed choices. -- User has explicitly confirmed whether to switch to plan mode or stay in ideation mode. diff --git a/docs/agents/skills/implementation/SKILL.md b/docs/agents/skills/implementation/SKILL.md deleted file mode 100644 index a7b8978..0000000 --- a/docs/agents/skills/implementation/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: implementation -description: Execute planned tasks phase-by-phase with one commit per phase, required validation, and pause-on-deviation reporting. ---- - -# Instructions - -## Trigger - -Run only after explicit user confirmation to enter implementation mode, with an approved plan file in `docs/plans`. - -## Steps - -1. Confirm the approved plan exists on disk at `docs/plans/-.md`. Tool-native plan mode alone is not sufficient. -2. If this plan stems from an ideation doc, remove the originating ideation doc in `docs/ideas` when implementation begins. -3. Implement one planned phase at a time. -4. Run the phase validation criteria and record results. -5. After a phase is completed, update `docs/plans/-.md` to reflect completed work and mark phase status. This update is mandatory before moving to the next phase. -6. Create one commit per phase, including code changes and the phase-completion plan update in the same commit. -7. Continue to next phase without pausing only if there are no deviations and no major problems. -8. If any deviation/blocker/major risk appears, pause and report. - -## Parallel execution - -When the plan marks phases as parallelizable, use the Task tool (or any available sub-agent spawning tool) to run them concurrently: - -- **Spawn one sub-agent per independent phase** using `run_in_background: true`. Give each agent a self-contained prompt with all context it needs (file paths, types, contracts, test specifications, validation criteria). -- **Avoid file conflicts**: If two phases write to the same file, combine them into a single agent or sequence them. Different agents should own different files. -- **Shared files** (barrel exports, test config): Let each agent add its own entries. After all agents complete, verify the shared files have no duplicates or conflicts. -- **Wait and verify**: After all parallel agents finish, run a full integration check (compile + full test suite) before committing. This catches cross-agent conflicts in shared files. -- **Single commit for parallel group**: All work from a parallel group goes into one commit after integration verification passes. - -## Required pause report content - -- What was done -- Deviations from plan -- Problems encountered -- Validation results (pass/fail counts and checks run) -- Proposed next step and any decision question for the user - -## Documentation - -- Update `docs/reports` when pausing for deviations/problems. - -## Guardrails - -- If the originating ideation doc is ambiguous, pause and ask the user which ideation file to remove. -- Do not skip plan updates between completed phases. -- Do not switch to review/wrapup implicitly; ask the user to explicitly confirm the next mode. - -## Exit criteria - -- All planned implementation phases are complete and validated, or -- Work is paused with explicit questions for the user. diff --git a/docs/agents/skills/plan/SKILL.md b/docs/agents/skills/plan/SKILL.md deleted file mode 100644 index 57c1bd4..0000000 --- a/docs/agents/skills/plan/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: plan -description: Convert chosen ideation decisions into a concrete architecture plan focused on selected routes. ---- - -# Instructions - -## Trigger - -Run only when the user explicitly confirms plan mode (for example: converting ideation into a plan or updating the plan). - -## Steps - -1. Create/update `docs/plans/-.md`. This on-disk plan file is mandatory. - - When creating a new plan doc (including ideation -> plan conversion), `` MUST be the next available 3-digit prefix in `docs/plans`. -2. Focus on chosen route(s), not all explored options. -3. **Carry forward all decided features and details from the ideation doc.** Every feature, API surface, design detail, and example that was explored and not explicitly rejected must appear in the plan. No idea can be silently dropped. If unsure whether something was tentatively discussed or firmly decided, ask the user for clarification rather than omitting it. -4. Include: - - Main architecture decisions - - Files expected to change - - Small code examples - - Potential pitfalls - - Remaining unclear areas/decisions - - **Inter-component contracts**: When the architecture has separable parts (layers, modules, packages), make the contracts between them explicit — type definitions, function signatures, shared data structures. These contracts enable parallel implementation in tasks mode. -5. Mention tradeoffs only to explain why chosen paths were selected. -5. Continuously refine the plan with user feedback until it is explicitly approved for implementation. - -## Guardrails - -- Do not add task breakdown in this mode. -- Do not start implementation. -- Do not rely on tool-native planning state alone; all plan content MUST be persisted to `docs/plans/-.md`. -- Do not remove the ideation doc in this mode. - -## Exit criteria - -- Plan clearly reflects chosen decisions. -- Risks and open questions are explicit. -- User has explicitly approved the plan and explicitly confirmed whether to switch to tasks mode or remain in plan mode. diff --git a/docs/agents/skills/review/SKILL.md b/docs/agents/skills/review/SKILL.md deleted file mode 100644 index 8a8ff70..0000000 --- a/docs/agents/skills/review/SKILL.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -name: review -description: Review implemented work against original intent, readiness for external use, remaining gaps, and future completeness work. ---- - -# Instructions - -## Trigger - -Run only when the user explicitly confirms review mode. - -## Review focus - -1. Compare current implementation against the original intent and agreed plan. -2. Assess whether the result is ready for others to use. -3. Identify what is still missing to make this work more complete. -4. Identify gaps or risks in the current implementation. -5. Identify likely future work required for fuller completeness. - -## Output - -Review findings must be emitted in chat first. -Do not write findings to plan or report files until decisions are clarified with the user. - -For each identified gap, ask clarifying questions until both are explicit: - -- whether this gap should be handled now in this session or deferred for future work -- how to approach the gap when multiple implementation routes exist - -After decisions are clear: - -- If handled now: update the active plan file `docs/plans/-.md` with newly added not-yet-completed phases/tasks. -- If deferred: create ideation docs in `docs/ideas`: - - group related deferred items into one ideation doc - - create separate ideation docs only for very different, large deferred tasks - - assign the next available 3-digit prefix in `docs/ideas` for each new ideation doc - -Then report in chat what was updated. -Do not create a separate review report file in this mode. - -## Follow-up questions before switching modes - -**After updating the plan with new phases, always ask the user implementation-specific follow-up questions before offering to switch modes.** New phases added during review are often under-specified because they came from gap analysis rather than upfront design. Proactively ask about: - -- **Placement decisions**: Where should new files/configs live? (e.g. project root vs subfolder) -- **Tool/dependency choices**: Which specific library, image, or tool version to use? -- **Configuration details**: Ports, environment variables, naming conventions -- **Scope boundaries**: How thorough should tests/error messages be? What's worth the maintenance cost vs what's overkill? -- **Anything the agent is unsure about** that would affect the implementation - -Only offer to switch to tasks mode after these questions are answered. This prevents wasted implementation effort from under-specified phases. - - -## Guardrails - -- Do not perform cleanup/release tasks in this mode; use wrapup mode for that. -- Do not remove `docs/plans/-.md` in review mode; plan removal happens in wrapup after report approval. -- If big remaining work is identified, discuss tradeoffs/solutions in chat first. -- Only convert review findings into new not-yet-completed phases/tasks after the user confirms scope and approach. -- After adding new phases/tasks, ask the user to review the updated plan and ask whether to switch to tasks mode to refine them. -- For newly uncovered work, **always switch to tasks mode first** — never directly to implementation. Tasks mode validates that phases have proper validation criteria, dependency graphs, and parallel opportunities before implementation begins. -- If the user's response to review findings involves clarifying approach or scope (e.g. "do X but not Y", "let's use approach A"), treat this as still in the clarification loop — ask follow-up questions for any remaining ambiguity before switching modes. - -## Exit criteria - -- Gaps are clarified with explicit user decisions (now vs future, and chosen approach where needed). -- If now-work exists, plan was updated with new phases/tasks and user was asked whether to switch to tasks mode. -- If future-work exists, ideation docs were created according to grouping rules and user was informed. -- User has explicitly confirmed whether to: - - stay in review mode, - - switch to tasks mode (required path for any new implementation work), - - switch to ideation mode for future work, - - or move to wrapup mode (only when no new implementation work remains). diff --git a/docs/agents/skills/tasks/SKILL.md b/docs/agents/skills/tasks/SKILL.md deleted file mode 100644 index 6caa833..0000000 --- a/docs/agents/skills/tasks/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: tasks -description: Break an approved plan into ordered phases and concrete tasks with explicit validation criteria. ---- - -# Instructions - -## Trigger - -Run only when the user explicitly confirms tasks mode. - -## Steps - -1. Update the active plan doc in `docs/plans/-.md`. Task breakdown MUST be persisted in this same on-disk plan file. -2. Define implementation phases. -3. Define concrete tasks under each phase. -4. Add explicit validation criteria per phase (for example: unit tests, integration tests, build/typecheck commands, targeted runtime checks). -5. Write detailed test specifications for every phase (see **Test specification** below). -6. Ensure phases are commit-friendly (one commit per phase). - -## Parallel execution - -Phases should be designed for maximum parallelism — different agents may implement different phases or tasks concurrently. - -- **Identify the dependency graph**: Which phases depend on which? Which can run in parallel? Mark this explicitly in the task breakdown. -- **Contracts first**: If the plan defines inter-component contracts (types, interfaces, shared data structures), schedule the contract/types phase first. Once contracts are established, phases that only depend on those contracts can run in parallel. -- **Stub boundaries**: When a phase depends on another phase's output, note what stubs or mocks are needed so it can proceed independently. For example: "Agent B can stub `irToAlgebra()` with hand-crafted algebra objects to test `algebraToString()` independently." -- **Mark parallel groups**: Use explicit notation in the task breakdown to indicate which phases can run simultaneously. For example: "Phase 2a, 2b, 2c can run in parallel after Phase 1." -- **Integration phase last**: After all parallel phases complete, include an explicit integration phase that: (1) replaces stubs with real wiring between components, (2) verifies all parts compile and work together, (3) runs end-to-end / golden tests that exercise the full pipeline. This phase must be planned even when stubs seem trivial — it catches type mismatches, import issues, and cross-component edge cases that unit tests miss. - -## Validation specification - -Every phase must include a **Validation** section that describes the checks an implementing agent must perform and pass before considering the phase complete. Validation is not limited to coded tests — it includes any check that truly proves the work is correct. - -**Types of validation checks** (use whichever are appropriate for the phase): -- **Unit/integration tests**: Coded test files with named test cases and concrete assertions. -- **Compilation/type-check**: e.g. `npm run compile` passes with no errors. -- **Runtime checks**: e.g. "execute the generated SPARQL against a running store and verify results". -- **Manual structural checks**: e.g. "assert the exported function is importable from the barrel", "assert the generated file exists and contains expected content". -- **HTTP/network checks**: e.g. "POST to the endpoint and verify 200 response with expected payload". - -**When describing coded tests:** -- **Name each test case** with the fixture or scenario it covers (e.g. `` `selectName` — `Person.select(p => p.name)` ``). -- **State concrete assertions** — not just "test that it works" but what specifically must be true. Use "assert" language: "assert result is array of length 4", "assert field `name` equals `'Semmy'`", "assert plan type is `'select'`". -- **Include input and expected output** where practical — hand-crafted input objects, specific field values, structural expectations (e.g. "assert algebra contains a LeftJoin wrapping the property triple"). -- **Cover edge cases explicitly** — null handling, missing values, type coercion, empty inputs. -- **Specify test file paths** — e.g. `src/tests/sparql-algebra.test.ts`. - -**When describing non-test validation:** -- **State the exact command or check** to run and what a passing result looks like. -- **Be specific about success criteria** — "compiles" is too vague; "`npx tsc --noEmit` exits 0 with no errors" is clear. - -The validation specifications serve as the phase's acceptance criteria: a phase is only complete when all described checks pass. - -## Guardrails - -- Do not start implementation unless user explicitly requests implementation mode. - -## Exit criteria - -- Every phase has tasks and validation criteria. -- Dependency graph and parallel opportunities are explicit. -- Stubs needed for parallel execution are noted. -- User has explicitly confirmed whether to switch to implementation mode or remain in tasks mode. diff --git a/docs/agents/skills/workflow/SKILL.md b/docs/agents/skills/workflow/SKILL.md deleted file mode 100644 index 086c3f6..0000000 --- a/docs/agents/skills/workflow/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: workflow -description: Enforce the explicit mode cadence (ideation -> plan -> tasks -> implementation -> review -> wrapup) with transition gates and optional single-mode overrides, and route explicit automatic-mode requests to the automatic meta workflow. ---- - -# Instructions - -## When to use - -- Default for any task that touches code or modifies planning/docs. - -## Mode selection at task start - -- If the user has already explicitly chosen a mode (or explicitly called a mode skill), enter that mode directly. -- `automatic` is a special meta mode and can only be entered when the user explicitly requests it. -- Never auto-suggest `automatic` in startup prompts. -- **Auto-enter ideation**: If the user is clearly brainstorming — weighing trade-offs, thinking out loud, exploring options — enter `ideation` mode directly without asking. This exception applies **only to ideation** (the first mode in the sequence). For all other starting modes (plan, implementation, etc.), always ask first. -- If the user is not clearly ideating, ask using this prompt pattern: - `Should we start with exploring the options in ideation mode? Or do you want to go straight to planning the details in plan mode?` - If the user is already asking to DO specific things, you can also add: `Or should I just go ahead and jump to implementation mode?` -- If the user asks to create/open/update a PR, or asks for PR title/body/message, treat that as an explicit request to enter `wrapup` mode. - -## Mode sequence - -1. `ideation` -2. `plan` -3. `tasks` -4. `implementation` -5. `review` -6. `wrapup` - -Meta mode: - -- `automatic` (explicit-only): run the full workflow without user confirmations between internal mode transitions; handoff behavior is defined in `docs/agents/skills/automatic/SKILL.md`. - -## Transition gates -Only switch to the next sequential mode with explicit user confirmation. When completing any mode, the agent must ask: `Shall we switch to {name of next mode}?`. -Never skip a mode unless explicitly told to. -If user seems to suggest skipping a mode but is not explicitly saying which mode to use, then the agent must ask the user `Do you want to continue with {name of next mode} or continue straight to {user suggested mode}?` -When review identifies remaining work, the agent may loop `review -> tasks -> implementation -> review`, but every switch still requires explicit user confirmation. -When review defers work for future scope, the agent may switch `review -> ideation`, with explicit user confirmation. -Requests related to PR preparation/publishing are an explicit exception and should route directly to `wrapup` mode. -These transition gates apply to standard modes. `automatic` mode is an explicit exception and manages internal transitions autonomously until its review pause point. - -## Required artifacts by mode - -- `ideation`: update/create `docs/ideas/-.md` -- `plan`: update/create `docs/plans/-.md` -- `tasks`: update the same plan doc with phased tasks and validation criteria -- `implementation`: update the plan doc after every completed phase; remove the originating ideation doc once implementation starts -- `review`: emit findings in chat first; after user decisions, update plan with now-work tasks and/or create ideation docs for deferred future work -- `wrapup`: convert plan into a final report doc in `docs/reports`, then remove the plan doc after report approval - -## Global constraints - -- Tool-native plan modes do NOT replace the on-disk plan file requirement. -- After each completed implementation phase, the on-disk plan file MUST be updated before moving to the next phase. -- Mode changes are never implicit; every mode switch requires explicit user confirmation. -- Numbering rule: when creating a new doc in `docs/ideas`, `docs/plans`, or `docs/reports`, `` MUST be the next available 3-digit prefix in the destination folder. -- Conversion rule: when converting/moving docs across folders (for example `ideas -> plans` or `plans -> reports`), do not reuse the old prefix; assign the next available prefix in the destination folder and update references accordingly. diff --git a/docs/agents/skills/wrapup/SKILL.md b/docs/agents/skills/wrapup/SKILL.md deleted file mode 100644 index 66ea2f2..0000000 --- a/docs/agents/skills/wrapup/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: wrapup -description: Finalize cleanup and release prep by removing dead code, improving comments/docs, running changesets, and drafting PR title/message. ---- - -# Instructions - -## Trigger - -Run only when the user explicitly confirms wrapup mode. -Also treat any user request to prepare/open/update a PR, or draft PR title/body/message, as an explicit wrapup request. - -## Steps - -1. Update `docs/plans/-.md` by appending a `## REVIEW` section at the end with wrapup outcomes and PR-readiness status. -2. Convert `docs/plans/-.md` into a report doc in `docs/reports/-.md`. - - For this conversion, the report `` MUST be the next available 3-digit prefix in `docs/reports` (do not reuse the plan prefix when it conflicts). - - Update any references to the report path after conversion. -3. **Report quality** — see the dedicated section below. The report is a condensed but comprehensive record of everything that was done. It is NOT a brief summary. -4. Consolidate remaining tradeoffs/choices into final decisions made and rationale. -5. Remove dead code related to the implemented scope. -6. Add clarifying comments in changed code where needed. -7. Verify documentation coverage for what changed. -8. Run a PR-readiness checklist and identify anything missing (for example: docs updates, tests/validation evidence, plan/report consistency, release notes). -9. If anything is missing, notify the user with a concrete checklist and ask whether to add/fix the missing items now. -10. Changeset handling — see the dedicated section below. -11. Draft a PR title and PR message/body summarizing changes, validation, and follow-up notes. -12. After the report is reviewed and approved by the user, remove `docs/plans/-.md`. - -## Report quality - -The report replaces the plan as the permanent record. Any agent working on code that interacts with the changed code must be able to understand from this document what was built, how it works, and what decisions were made. The report should be proportional to the scope of the plan — a large plan produces a substantial report. - -**What to keep from the plan (condense but do NOT drop):** -- Architecture overview and pipeline description -- All key design decisions with rationale (e.g., why OPTIONAL wrapping, why VariableRegistry, why DELETE/INSERT/WHERE over DELETE WHERE) -- File structure with each file's responsibility -- Public API surface (exported functions, classes, types) with usage examples -- Conversion/mapping rules (e.g., IR node → algebra node mapping tables) -- All resolved gaps, bugs, and edge cases with their chosen approach -- Test coverage summary (which test files, what they cover, total counts) -- Known limitations and remaining test gaps -- Deferred work with pointers to ideation docs -- Links to relevant documentation files (e.g., `documentation/sparql-algebra.md`) -- PR reference (number and URL) when a PR was created during this scope -- Anything that affects future work or that future agents need to know - -**What to remove from the plan:** -- Alternative approaches that were NOT chosen (unless the rationale for rejection informs future decisions) -- Per-phase task checklists and status markers (the work is done) -- Detailed per-test assertions (keep test file names and what they cover, drop individual assertion lists) -- Inline code snippets that duplicate what's in the actual source files (keep small illustrative examples) -- Validation command logs - -**Sizing guideline:** If the plan was 500+ lines, the report should be at least 150-300 lines. A 10-line report for a 3000-line plan means critical information was lost. - -## Changeset handling - -**Always create a changeset** when package code changed, even if other changesets already exist. Each changeset becomes a separate entry in the public changelog via CI/CD, so it should describe what users of the library need to know about THIS set of changes. - -A changeset is only skippable when the scope is purely internal (docs, CI config, dev tooling) with zero impact on the published package. - -**Changeset content must be user-facing and actionable:** -- List new exports, classes, functions, and types that users can now import -- Describe new capabilities and how to use them (brief code examples) -- Note any breaking changes or behavioral differences -- Reference documentation files where users can learn more -- Do NOT write vague summaries like "added SPARQL support" — be specific about what was added and how to use it - -**Process:** -- Ask the user for the version bump level (patch/minor/major) unless they already specified it. -- The `npx @changesets/cli add` command requires interactive TTY input, which is not available in agent environments. Instead, write the changeset file directly to `.changeset/` — this is the standard approach for CI/automation and produces identical results. Use the standard format: YAML frontmatter with package name and bump level, followed by markdown description. Use a descriptive kebab-case filename (e.g., `sparql-conversion-layer.md`). - -## Output - -- Updated `docs/plans/-.md` with `## REVIEW` section at end. -- Final report at `docs/reports/-.md`. -- Changeset file in `.changeset/` (when applicable). - -## Exit criteria - -- Cleanup and documentation checks are complete. -- PR readiness gaps (if any) were surfaced to the user and a decision was collected. -- Changeset requirement is resolved (prepared, or explicitly skipped for docs-only/no-code-change scope). -- PR title and message are ready. -- Final report is approved and the corresponding plan doc has been removed. diff --git a/docs/ideas/006-computed-expressions-and-update-functions.md b/docs/ideas/006-computed-expressions-and-update-functions.md deleted file mode 100644 index 783b664..0000000 --- a/docs/ideas/006-computed-expressions-and-update-functions.md +++ /dev/null @@ -1,224 +0,0 @@ -# Computed Expressions & Update Functions - -## Summary - -Add support for computed/derived fields in SELECT projections and expression-based updates in mutations. This covers two related capabilities: -1. **Computed fields in queries** — BIND expressions that produce derived values in SELECT results -2. **Update functions** — Expression-based mutations that compute new values from existing data (the existing TODO at `MutationQuery.ts:33`) - -Both use the `SparqlExtend` (BIND) algebra type already defined in `SparqlAlgebra.ts`. - -## Motivation - -Currently the DSL only supports projecting stored properties and updating with literal values. Real applications need: -- Derived fields (full name from first + last, age in months from age in years) -- Conditional values (if/else logic in projections) -- Relative updates (increment a counter, apply a discount) -- Timestamp injection (set `lastModified` to current time) - -## Proxy limitation - -JavaScript proxies cannot intercept operators (`+`, `-`, `*`, `/`, `>`, `<`, `===`). This means: - -```ts -// WILL NOT WORK — proxy can't intercept * operator -Person.select(p => ({ ageInMonths: p.age * 12 })) - -// WILL NOT WORK — proxy can't intercept > operator -Person.select(p => p.name).where(p => p.age > 18) -``` - -All computed expressions must use **function-call syntax** through an expression builder module. - -## Expression builder module design - -A short-named module (e.g., `L` for Linked expressions) that provides typed builder functions: - -```ts -import { L } from '@_linked/core'; - -// Arithmetic -L.plus(a, b) // a + b -L.minus(a, b) // a - b -L.times(a, b) // a * b -L.divide(a, b) // a / b - -// Comparison (for WHERE/FILTER) -L.eq(a, b) // a = b -L.neq(a, b) // a != b -L.gt(a, b) // a > b -L.gte(a, b) // a >= b -L.lt(a, b) // a < b -L.lte(a, b) // a <= b - -// String -L.concat(a, b, c) // CONCAT(a, b, c) -L.strlen(a) // STRLEN(a) -L.substr(a, start, len) // SUBSTR(a, start, len) -L.ucase(a) // UCASE(a) -L.lcase(a) // LCASE(a) -L.contains(a, b) // CONTAINS(a, b) - -// Date/time -L.now() // NOW() -L.year(a) // YEAR(a) -L.month(a) // MONTH(a) - -// Conditional -L.ifThen(cond, thenVal, elseVal) // IF(cond, then, else) -L.coalesce(a, b) // COALESCE(a, b) -L.bound(a) // BOUND(a) -``` - -Each function returns an `IRExpression` node that the IR pipeline can process. - -### Type safety - -The `L` module functions should be generically typed to preserve type information: - -```ts -// L.times knows it returns a numeric expression -L.times(p.age, 12) // type: NumericExpression - -// L.concat knows it returns a string expression -L.concat(p.firstName, " ", p.lastName) // type: StringExpression -``` - -## DSL examples - -### Computed fields in SELECT - -```ts -// Derived field using BIND -Person.select(p => ({ - name: p.name, - fullName: L.concat(p.firstName, " ", p.lastName), - ageInMonths: L.times(p.age, 12), -})) - -// Generated SPARQL: -// SELECT ?name (CONCAT(?firstName, " ", ?lastName) AS ?fullName) -// ((?age * 12) AS ?ageInMonths) -// WHERE { -// ?s rdf:type ex:Person . -// OPTIONAL { ?s ex:name ?name . } -// OPTIONAL { ?s ex:firstName ?firstName . } -// OPTIONAL { ?s ex:lastName ?lastName . } -// OPTIONAL { ?s ex:age ?age . } -// } -``` - -### Computed filters in WHERE - -```ts -// Filter with expression function -Person.select(p => p.name).where(p => L.gt(p.age, 18)) - -// Generated SPARQL: -// SELECT ?name WHERE { -// ?s rdf:type ex:Person . -// OPTIONAL { ?s ex:name ?name . } -// OPTIONAL { ?s ex:age ?age . } -// FILTER(?age > 18) -// } -``` - -### Expression-based mutations (update functions) - -```ts -// Increment age and set lastModified to now -Person.update(p1, p => ({ - age: L.plus(p.age, 1), - lastModified: L.now(), -})) - -// Generated SPARQL: -// DELETE { ?s ex:age ?old_age . ?s ex:lastModified ?old_lastModified . } -// INSERT { ?s ex:age ?new_age . ?s ex:lastModified ?now . } -// WHERE { -// ?s rdf:type ex:Person . FILTER(?s = ) -// OPTIONAL { ?s ex:age ?old_age . } -// OPTIONAL { ?s ex:lastModified ?old_lastModified . } -// BIND((?old_age + 1) AS ?new_age) -// BIND(NOW() AS ?now) -// } -``` - -```ts -// Apply 10% discount to products over $100 -Product.updateAll(p => ({ - price: L.times(p.price, 0.9), -})).where(p => L.gt(p.price, 100)) - -// Generated SPARQL: -// DELETE { ?s ex:price ?old_price . } -// INSERT { ?s ex:price ?new_price . } -// WHERE { -// ?s rdf:type ex:Product . -// ?s ex:price ?old_price . -// FILTER(?old_price > 100) -// BIND((?old_price * 0.9) AS ?new_price) -// } -``` - -## Algebra mapping - -Uses the existing `SparqlExtend` algebra node: - -```ts -type SparqlExtend = { - type: 'extend'; - inner: SparqlAlgebraNode; - variable: string; - expression: SparqlExpression; -}; -``` - -Already serialized by `algebraToString.ts` as `BIND(expr AS ?var)`. - -## Implementation considerations - -- The `L` module needs to produce `IRExpression` nodes that flow through the existing IR pipeline -- `irToAlgebra.ts` needs to convert `IRExpression` in projection items to `SparqlExtend` nodes -- For mutations: `IRUpdateMutation` currently expects `IRFieldValue` (literal data); needs to also accept `IRExpression` for computed values -- The callback form `Person.update(id, p => ...)` needs proxy tracking (already exists for selects) -- `MutationQuery.ts:33` TODO can be resolved by this feature -- Expression builder functions should validate argument types at build time where possible - -## Callback-style mutation updates - -Currently `UpdateBuilder` only supports object-style updates (pass a plain object with new values). The TODO at `MutationQuery.ts:33` also envisions a **callback-style** API where a proxy lets you assign properties imperatively: - -```ts -// Object-style (already works via UpdateBuilder) -Person.update(entity, { name: 'Bob', age: 30 }) - -// Callback-style (not yet implemented) -Person.update(entity, p => { - p.name = 'Bob'; - p.age = L.plus(p.age, 1); // combine with expressions -}) -``` - -### Why callback-style matters - -- **Reads + writes in one callback** — the proxy can trace which properties are read (for DELETE old values) and which are written (for INSERT new values), generating correct DELETE/INSERT WHERE in one pass -- **Natural fit with expressions** — `p.age = L.plus(p.age, 1)` reads the current value and writes a computed new value, which is awkward to express in a plain object -- **Consistency with select** — `Person.select(p => ...)` already uses proxy callbacks; mutations should follow the same pattern - -### Implementation approach - -The callback needs a **write-tracing proxy** (unlike the read-only proxy used in `select()`): -- Property **reads** (`p.age`) produce the same `QueryPrimitive` / `QueryShape` proxies as in select, which can be passed to `L.*` functions -- Property **writes** (`p.name = 'Bob'`) are intercepted via the proxy `set` trap and recorded as mutation entries -- After the callback executes, the recorded writes are converted to `IRFieldValue` or `IRExpression` entries in the mutation IR - -This reuses the `ProxiedPathBuilder` infrastructure from the query cleanup — the main new work is the `set` trap and wiring mutations into `UpdateBuilder`. - -## Open questions - -- Should `L` be the module name, or something more descriptive? (`Expr`, `Fn`, `Q`?) -- Should comparison functions be usable both in `.where()` and in HAVING clauses? -- How should null/undefined handling work for computed expressions (COALESCE automatically)? -- Should there be a `.updateAll()` method for bulk expression-based updates, separate from `.update(id, ...)`? -- For callback-style updates: should the proxy support deleting properties (`delete p.name`) to generate triple removal? diff --git a/docs/ideas/012-aggregate-group-filtering.md b/docs/ideas/012-aggregate-group-filtering.md new file mode 100644 index 0000000..19eb9e3 --- /dev/null +++ b/docs/ideas/012-aggregate-group-filtering.md @@ -0,0 +1,61 @@ +--- +summary: Scope and API options for aggregate group filtering (HAVING semantics) separate from computed expression core work. +packages: [core] +--- + +# Aggregate Group Filtering (HAVING Semantics) + +## Why this is split from 006 + +Idea `006` focuses on computed expressions and expression-based updates. +Aggregate group filtering introduces a separate design branch: +- grouping semantics +- aggregate result filtering semantics +- potential public API additions (`groupBy`, aggregate-local filter methods, or both) + +Keeping this in a dedicated ideation doc avoids expanding 006 scope. + +## Confirmed current state in codebase + +- There is no public query-builder `.having()` method currently exposed. +- There is no public query-builder `.groupBy()` method currently exposed. +- Aggregate filtering can already occur implicitly via existing DSL patterns: + - `Person.select().where((p) => p.friends.size().equals(2))` + - This currently lowers to SPARQL with `GROUP BY` + `HAVING(...)` in tests. +- The SPARQL algebra and serializer already support `groupBy` and `having` fields internally. + +## Current example and interpretation + +Proposed syntax: + +```ts +Person + .select(p => ({ city: p.city, n: p.id.count().where(c => c.gt(10)) })) + .groupBy(p => p.city) +``` + +Interpretation to validate: +- `count().where(...)` is a post-aggregate group filter (HAVING-like), not a row-level pre-aggregate filter. +- If accepted, this can be treated as an aggregate-local filter API. + +## Options to evaluate next + +1. No new public `.having()`; use aggregate-local filtering only. +2. Introduce explicit public `.having()` callback for grouped queries. +3. Support both, where aggregate-local filtering is sugar that compiles to HAVING. +4. Defer all new aggregate filtering API work to a later phase and keep existing implicit behavior only. + +## Evaluation criteria + +- Fits fluent default style from idea 006. +- Avoids ambiguous semantics between row filtering and group filtering. +- Type-safety and error clarity. +- Minimal API surface increase for v1. +- Keeps query intent readable at call-site. + +## Pending decisions + +- Whether aggregate-local filter should be named `.where(...)` or `.filter(...)`. +- Whether public `.groupBy(...)` is needed, or grouping remains implicit from aggregate usage. +- Whether aggregate filters should be accepted only in grouped contexts. +- How multiple aggregate filters combine (expected default: logical AND). diff --git a/docs/reports/010-computed-expressions-and-update-functions.md b/docs/reports/010-computed-expressions-and-update-functions.md new file mode 100644 index 0000000..cabcda0 --- /dev/null +++ b/docs/reports/010-computed-expressions-and-update-functions.md @@ -0,0 +1,207 @@ +--- +source: docs/plans/001-computed-expressions-and-update-functions.md +summary: Expression support for computed query fields and mutation updates via fluent property methods and Expr module. +packages: [core] +--- + +# Report: Computed Expressions & Update Functions + +## Overview + +Added two capabilities to the DSL: + +1. **Computed fields in queries** — fluent expression methods on property proxies (`.plus()`, `.concat()`, etc.) that produce `IRExpression` nodes, usable in `select()` projections and `where()` filters. +2. **Expression-based mutations** — accept `IRExpression` values in update payloads, plus functional callback form `Shape.update(entity, p => ({ ... }))` for deriving values from existing fields. + +## Architecture + +### Pipeline + +``` +DSL (user-facing) + │ fluent: p.age.plus(1) module: Expr.plus(p.age, 1) + ▼ +Expression proxy methods + Expr module + │ both produce IRExpression nodes via ExpressionNode wrapper + ▼ +IR layer (IRExpression, IRProjectionItem, IRFieldValue) + │ irToAlgebra.ts converts to SparqlExtend / SparqlExpression + ▼ +SPARQL algebra (SparqlExtend, SparqlExpression) + │ algebraToString.ts serializes + ▼ +SPARQL string (BIND, FILTER, inline expressions) +``` + +The bottom two layers (algebra types + serialization) were pre-existing. This work built the top three layers and wired them into the existing query/mutation pipelines. + +### Expression proxy mechanism + +`QueryPrimitive` in `SelectQuery.ts` uses `wrapWithExpressionProxy()` — a Proxy that intercepts expression method names (from the `EXPRESSION_METHODS` set). When called, it: +1. Converts the current property path segments to placeholder `IRPropertyExpression` nodes via `tracedPropertyExpression()` +2. Stores the real PropertyShape ID segments in a `PropertyRefMap` (`_refs`) +3. Delegates to `ExpressionNode` methods which build the IR tree +4. Placeholder aliases are resolved to real aliases during lowering via `resolveExpressionRefs()` + +This keeps `QueryPrimitive` thin — all expression IR logic lives in `ExpressionNode`. + +### WHERE expression pipeline + +Expression-based WHERE filters follow a dual-track design: + +``` +WhereClause callback → ExpressionNode detected → WhereExpressionPath + → DesugaredExpressionWhere (kind: 'where_expression') + → passthrough in canonicalize + → lowerWhere() resolves refs → IRExpression (FILTER) +``` + +Standard `Evaluation`-based WHERE continues to work unchanged. Mixed `Evaluation.and(ExpressionNode)` chaining works because `processWhereClause` detects ExpressionNode results at the funnel point, and recursive `toWhere()` handles `WhereExpressionPath` in `andOr` entries. + +### Mutation expression pipeline + +Expression values in update payloads flow through: + +``` +Shape.update(p => ({age: p.age.plus(1)})) → MutationQuery.convertUpdateObject() + → proxy invocation → ExpressionNode values detected + → IRMutation.toSingleFieldValue() → resolveExpressionRefs() → IRExpression in IRFieldValue + → irToAlgebra: BIND(expr AS ?computed_field) in WHERE, ?computed_field in INSERT +``` + +Multi-segment refs (e.g. `p.bestFriend.name.ucase()`) use a `TraversalCollector` that generates OPTIONAL join patterns for intermediate entities. + +### Shared traversal resolver + +`createTraversalResolver

()` in `IRLower.ts` is a generic factory that encapsulates the pattern of memoized `(fromAlias, propertyShapeId) → alias` resolution with pattern accumulation. Used by: +- Select query lowering (exists, MINUS, standalone WHERE) +- Mutation traversal collection (`createTraversalCollector()` in `IRMutation.ts`) + +## Key design decisions + +| Decision | Rationale | +|----------|-----------| +| **Fluent methods as default API** | Natural chaining from property proxies (`p.age.plus(1)`) matches the existing DSL style | +| **`Expr` module for non-property-first** | `Expr.now()`, `Expr.ifThen()`, `Expr.firstDefined()` have no natural fluent host | +| **Left-to-right chaining, no precedence** | `p.a.plus(1).times(2)` = `(a + 1) * 2`. Simple mental model, matches method call order | +| **`power(n)` via repeated multiplication** | SPARQL has no native power function. Exponent must be positive integer ≤ 20 (validated at build time) | +| **Regex flags limited to `i`, `m`, `s`** | Portable subset across SPARQL implementations | +| **Callback updates use read-only proxy** | Same tracing as `select()`, no write-trapping needed | +| **OPTIONAL wrapping for mutation traversals** | Update still executes when related entity doesn't exist (expression evaluates to unbound) | +| **`DesugaredExpressionWhere` reused in canonical layer** | Consolidated — no need for separate `CanonicalExpressionWhere` since the type passes through unchanged | +| **ExpressionMethods interfaces separate from ExpressionNode** | Interfaces describe type-safe projections for `ExpressionUpdateProxy`. ExpressionNode is the runtime IR builder. Different concerns. | + +## Files + +### New files + +| File | Responsibility | +|------|---------------| +| `src/expressions/ExpressionNode.ts` | `ExpressionNode` class — fluent IR builder with all expression methods. `tracedPropertyExpression()`, `PropertyRefMap`, `resolveExpressionRefs()`, `isExpressionNode()` | +| `src/expressions/Expr.ts` | `Expr` module — static builder functions for non-property-first expressions | +| `src/expressions/ExpressionMethods.ts` | TypeScript interfaces per property type (`NumericExpressionMethods`, `StringExpressionMethods`, etc.) and mapped proxy types (`ExpressionUpdateProxy`, `ExpressionUpdateResult`) | +| `src/tests/expression-node.test.ts` | ExpressionNode unit tests (IR node structure, chaining, validation) | +| `src/tests/expr-module.test.ts` | Expr module unit tests (equivalence with fluent forms) | +| `src/tests/expression-types.test.ts` | Type-level tests for update expression proxy typing | + +### Changed files + +| File | Changes | +|------|---------| +| `src/queries/IntermediateRepresentation.ts` | Extended `IRBinaryOperator` with arithmetic ops. Added `IRExpression` to `IRFieldValue`. Added `IRTraversalPattern` type. Added `traversalPatterns` to `IRUpdateMutation` and `IRUpdateWhereMutation`. | +| `src/queries/SelectQuery.ts` | `wrapWithExpressionProxy()` on QueryPrimitive. Widened `WhereClause` to accept `ExpressionNode`. Added `WhereExpressionPath`. Updated `processWhereClause` to detect ExpressionNode. | +| `src/queries/MutationQuery.ts` | `convertUpdateObject()` handles function callbacks via proxy. `convertUpdateValue()` detects ExpressionNode. | +| `src/queries/IRMutation.ts` | `TraversalCollector` type and `createTraversalCollector()` (delegates to shared `createTraversalResolver`). Threaded collector through `toNodeData` → `toFieldUpdate` → `toFieldValue` → `toSingleFieldValue`. | +| `src/queries/IRDesugar.ts` | `DesugaredExpressionWhere` type. `DesugaredExpressionSelect` type. Handler in `toWhere()` for `WhereExpressionPath`. | +| `src/queries/IRCanonicalize.ts` | Added `DesugaredExpressionWhere` to `CanonicalWhereExpression` union. Passthrough in `canonicalizeWhere()`. | +| `src/queries/IRLower.ts` | `createTraversalResolver

()` shared factory. `AliasGenerator` interface. `where_expression` case in `lowerWhere()`. Replaced 3 inlined traversal resolvers with shared factory. Removed `as any` casts. | +| `src/queries/UpdateBuilder.ts` | Function-callback overload on `.set()` with `ExpressionUpdateProxy` typing. | +| `src/queries/QueryFactory.ts` | Added `\| ExpressionNode` to `ShapePropValueToUpdatePartial`. | +| `src/shapes/Shape.ts` | Function-callback overload on `Shape.update()`. | +| `src/queries/FieldSet.ts` | `expressionNode` field on `FieldSetEntry`. Detection in `convertTraceResult()`. | +| `src/queries/IRProjection.ts` | `expression_select` handling in projection seed collection. | +| `src/sparql/irToAlgebra.ts` | `isIRExpression()` type guard. Expression field values in mutations produce BIND + computed variable. Traversal OPTIONAL patterns emitted from `traversalPatterns`. | +| `src/index.ts` | Exported `ExpressionNode`, `ExpressionInput`, `PropertyRefMap`, `Expr`, and all `ExpressionMethods` types. | + +## Public API surface + +### Exports from `@_linked/core` + +```typescript +// Classes +export {ExpressionNode} from './expressions/ExpressionNode.js'; + +// Module +export {Expr} from './expressions/Expr.js'; + +// Types +export type {ExpressionInput, PropertyRefMap} from './expressions/ExpressionNode.js'; +export type { + ExpressionUpdateProxy, + ExpressionUpdateResult, + BaseExpressionMethods, + NumericExpressionMethods, + StringExpressionMethods, + DateExpressionMethods, + BooleanExpressionMethods, +} from './expressions/ExpressionMethods.js'; +``` + +### Usage examples + +```typescript +// Computed SELECT projection +const result = await Person.select(p => ({ + name: p.name, + nameLen: p.name.strlen(), + ageInMonths: p.age.times(12), +})); + +// Expression-based WHERE +const adults = await Person.select(p => p.name) + .where(p => p.age.gt(18)); + +// Expression-based mutation with callback +await Person.update(p => ({age: p.age.plus(1)})).for(entity); + +// Expr module +await Person.update({lastSeen: Expr.now()}).for(entity); +``` + +## Test coverage + +| Test file | Count | What it covers | +|-----------|-------|---------------| +| `expression-node.test.ts` | ~65 | ExpressionNode IR structure, chaining, validation (power, regex flags) | +| `expr-module.test.ts` | ~64 | Expr.* equivalence with fluent, Expr.now/ifThen/firstDefined/bound | +| `expression-types.test.ts` | 14 | Type-level: update proxy typing, method availability per property type | +| `sparql-select-golden.test.ts` | +10 | Expression projections (strlen, custom key, nested, mixed) + expression WHERE (strlen, arithmetic, AND chain, mixed, nested, with projection) | +| `sparql-mutation-golden.test.ts` | +6 | Expression mutations (callback, Expr.now) + traversal (single, shared) + WHERE (update, delete) | +| `query-builder.test.ts` | +4 | QueryBuilder equivalence for expression SELECT, WHERE, mixed, combined | +| `ir-select-golden.test.ts` | +4 | IR-level expression projection tests | + +**Total: 821 tests passing**, 0 failures, 3 skipped suites (pre-existing compile-only type tests). + +## Post-implementation cleanup + +- **Shared traversal registry**: Extracted `createTraversalResolver

()`, eliminating 4 duplicated implementations. +- **`as any` removal**: 3 of 4 production casts removed. Remaining one in `UpdateBuilder.set()` is idiomatic for TS overloaded methods. +- **Separator standardization**: All traversal deduplication keys use `:` (was mixed `:` and `|`). +- **Where-expression type consolidation**: `CanonicalExpressionWhere` removed; reuses `DesugaredExpressionWhere` directly. + +## Known limitations + +- Expression proxy methods are runtime-only on `QueryPrimitive`. Static types for WHERE callbacks return `Evaluation | ExpressionNode`, but the specific expression methods (`.strlen()`, `.plus()`) are not statically typed on `QueryPrimitive`. They work at runtime via Proxy. +- Update expression proxy (`ExpressionUpdateProxy`) IS fully typed — `.plus()` only appears on `number` properties, `.strlen()` only on `string`, etc. +- `power(n)` is limited to positive integer exponents ≤ 20 (emits repeated multiplication). +- Regex flags limited to `i`, `m`, `s`. + +## Deferred work + +- **Aggregate/GROUP filtering**: Tracked in `docs/ideas/012-aggregate-group-filtering.md`. Expressions like `Expr.sum()`, `Expr.avg()` with HAVING/GROUP BY are out of scope for this work. + +## Documentation + +- README updated with computed expressions, expression-based WHERE, `Expr` module, and expression-based updates sections. +- [Intermediate Representation docs](../documentation/intermediate-representation.md) — existing, covers base IR types. +- [SPARQL Algebra docs](../documentation/sparql-algebra.md) — existing, covers algebra layer. diff --git a/jest.config.js b/jest.config.js index 2952103..9bc28b3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,6 +31,9 @@ module.exports = { '**/field-set.test.ts', '**/mutation-builder.test.ts', '**/serialization.test.ts', + '**/expression-node.test.ts', + '**/expr-module.test.ts', + '**/expression-types.test.ts', ], testPathIgnorePatterns: ['/old/'], transform: { diff --git a/package.json b/package.json index d27e9bf..e9b65e3 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "build": "npx rimraf ./lib && npx tsc -p tsconfig-cjs.json && npx tsc -p tsconfig-esm.json && node ./scripts/dual-package.js", "compile": "echo '💫 Compiling CJS' && npx tsc -p tsconfig-cjs.json && echo '💫 Compiling ESM' && npx tsc -p tsconfig-esm.json", "dual-package": "node ./scripts/dual-package.js", - "sync:agents": "node ./scripts/sync-agents.mjs", - "setup": "npm run sync:agents", + "sync:agents": "node packages/skills/sync.mjs", + "setup": "node scripts/setup.mjs", "test": "npx jest --config jest.config.js", "test:fuseki": "docker compose -f src/tests/docker-compose.test.yml up -d --wait && npx jest --config jest.config.js --testPathPattern='sparql-fuseki' --verbose; EXIT=$?; docker compose -f src/tests/docker-compose.test.yml down; exit $EXIT" }, diff --git a/plan.md b/plan.md deleted file mode 100644 index 6a0ee00..0000000 --- a/plan.md +++ /dev/null @@ -1,98 +0,0 @@ -# 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/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..2baf33d --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import {execSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import path from 'node:path'; + +const projectRoot = process.cwd(); +const skillsDir = path.join(projectRoot, 'packages', 'skills'); +const skillsRepo = 'git@github.com:Semantu/agents.git'; + +function run(cmd, opts) { + return execSync(cmd, {stdio: 'inherit', ...opts}); +} + +function setupSkills() { + if (existsSync(path.join(skillsDir, '.git'))) { + console.log('Updating skills repo...'); + run('git pull --ff-only', {cwd: skillsDir}); + } else { + console.log('Cloning skills repo...'); + run(`git clone ${skillsRepo} ${skillsDir}`); + } + + console.log('Syncing skills...'); + run('node sync.mjs', {cwd: skillsDir}); +} + +try { + setupSkills(); +} catch { + console.log('\nSkipping skills setup — could not access the skills repo.'); + console.log('This is fine if you don\'t have access to github.com:Semantu/agents.'); +} diff --git a/scripts/sync-agents.mjs b/scripts/sync-agents.mjs deleted file mode 100644 index 828d05e..0000000 --- a/scripts/sync-agents.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import {cpSync, existsSync, mkdirSync, rmSync} from 'node:fs'; -import path from 'node:path'; - -const projectRoot = process.cwd(); -const sourceDir = path.join(projectRoot, 'docs', 'agents'); -const skillsSourceDir = path.join(sourceDir, 'skills'); -const targets = [ - {source: skillsSourceDir, dest: path.join(projectRoot, '.claude', 'skills')}, - {source: skillsSourceDir, dest: path.join(projectRoot, '.agents', 'skills')}, -]; - -if (!existsSync(sourceDir)) { - console.error(`Source directory not found: ${sourceDir}`); - process.exit(1); -} - -for (const {source, dest} of targets) { - rmSync(dest, {recursive: true, force: true}); - mkdirSync(path.dirname(dest), {recursive: true}); - cpSync(source, dest, {recursive: true}); - console.log(`Synced ${source} -> ${dest}`); -} diff --git a/src/expressions/Expr.ts b/src/expressions/Expr.ts new file mode 100644 index 0000000..949afa3 --- /dev/null +++ b/src/expressions/Expr.ts @@ -0,0 +1,248 @@ +import {ExpressionNode, toIRExpression} from './ExpressionNode.js'; +import type {ExpressionInput} from './ExpressionNode.js'; + +function wrap(input: ExpressionInput): ExpressionNode { + return input instanceof ExpressionNode + ? input + : new ExpressionNode(toIRExpression(input)); +} + +export const Expr = { + // --------------------------------------------------------------------------- + // Arithmetic + // --------------------------------------------------------------------------- + + plus(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).plus(b); + }, + minus(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).minus(b); + }, + times(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).times(b); + }, + divide(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).divide(b); + }, + abs(a: ExpressionInput): ExpressionNode { + return wrap(a).abs(); + }, + round(a: ExpressionInput): ExpressionNode { + return wrap(a).round(); + }, + ceil(a: ExpressionInput): ExpressionNode { + return wrap(a).ceil(); + }, + floor(a: ExpressionInput): ExpressionNode { + return wrap(a).floor(); + }, + power(a: ExpressionInput, b: number): ExpressionNode { + return wrap(a).power(b); + }, + + // --------------------------------------------------------------------------- + // Comparison (short names only) + // --------------------------------------------------------------------------- + + eq(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).eq(b); + }, + neq(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).neq(b); + }, + gt(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).gt(b); + }, + gte(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).gte(b); + }, + lt(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).lt(b); + }, + lte(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).lte(b); + }, + + // --------------------------------------------------------------------------- + // String + // --------------------------------------------------------------------------- + + concat(...parts: ExpressionInput[]): ExpressionNode { + if (parts.length < 2) { + throw new Error('Expr.concat() requires at least 2 arguments'); + } + const [first, ...rest] = parts; + return new ExpressionNode({ + kind: 'function_expr', + name: 'CONCAT', + args: parts.map(toIRExpression), + }); + }, + contains(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).contains(b); + }, + startsWith(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).startsWith(b); + }, + endsWith(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).endsWith(b); + }, + substr( + a: ExpressionInput, + start: number, + len?: number, + ): ExpressionNode { + return wrap(a).substr(start, len); + }, + before(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).before(b); + }, + after(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).after(b); + }, + replace( + a: ExpressionInput, + pat: string, + rep: string, + flags?: string, + ): ExpressionNode { + return wrap(a).replace(pat, rep, flags); + }, + ucase(a: ExpressionInput): ExpressionNode { + return wrap(a).ucase(); + }, + lcase(a: ExpressionInput): ExpressionNode { + return wrap(a).lcase(); + }, + strlen(a: ExpressionInput): ExpressionNode { + return wrap(a).strlen(); + }, + encodeForUri(a: ExpressionInput): ExpressionNode { + return wrap(a).encodeForUri(); + }, + regex( + a: ExpressionInput, + pat: string, + flags?: string, + ): ExpressionNode { + return wrap(a).matches(pat, flags); + }, + + // --------------------------------------------------------------------------- + // Date/Time + // --------------------------------------------------------------------------- + + now(): ExpressionNode { + return new ExpressionNode({kind: 'function_expr', name: 'NOW', args: []}); + }, + year(a: ExpressionInput): ExpressionNode { + return wrap(a).year(); + }, + month(a: ExpressionInput): ExpressionNode { + return wrap(a).month(); + }, + day(a: ExpressionInput): ExpressionNode { + return wrap(a).day(); + }, + hours(a: ExpressionInput): ExpressionNode { + return wrap(a).hours(); + }, + minutes(a: ExpressionInput): ExpressionNode { + return wrap(a).minutes(); + }, + seconds(a: ExpressionInput): ExpressionNode { + return wrap(a).seconds(); + }, + timezone(a: ExpressionInput): ExpressionNode { + return wrap(a).timezone(); + }, + tz(a: ExpressionInput): ExpressionNode { + return wrap(a).tz(); + }, + + // --------------------------------------------------------------------------- + // Logical + // --------------------------------------------------------------------------- + + and(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).and(b); + }, + or(a: ExpressionInput, b: ExpressionInput): ExpressionNode { + return wrap(a).or(b); + }, + not(a: ExpressionInput): ExpressionNode { + return wrap(a).not(); + }, + + // --------------------------------------------------------------------------- + // Null-handling / Conditional + // --------------------------------------------------------------------------- + + firstDefined(...args: ExpressionInput[]): ExpressionNode { + if (args.length < 2) { + throw new Error('Expr.firstDefined() requires at least 2 arguments'); + } + return new ExpressionNode({ + kind: 'function_expr', + name: 'COALESCE', + args: args.map(toIRExpression), + }); + }, + ifThen( + cond: ExpressionInput, + thenVal: ExpressionInput, + elseVal: ExpressionInput, + ): ExpressionNode { + return new ExpressionNode({ + kind: 'function_expr', + name: 'IF', + args: [toIRExpression(cond), toIRExpression(thenVal), toIRExpression(elseVal)], + }); + }, + bound(a: ExpressionInput): ExpressionNode { + return wrap(a).isDefined(); + }, + + // --------------------------------------------------------------------------- + // RDF introspection + // --------------------------------------------------------------------------- + + lang(a: ExpressionInput): ExpressionNode { + return wrap(a).lang(); + }, + datatype(a: ExpressionInput): ExpressionNode { + return wrap(a).datatype(); + }, + str(a: ExpressionInput): ExpressionNode { + return wrap(a).str(); + }, + iri(a: ExpressionInput): ExpressionNode { + return wrap(a).iri(); + }, + isIri(a: ExpressionInput): ExpressionNode { + return wrap(a).isIri(); + }, + isLiteral(a: ExpressionInput): ExpressionNode { + return wrap(a).isLiteral(); + }, + isBlank(a: ExpressionInput): ExpressionNode { + return wrap(a).isBlank(); + }, + isNumeric(a: ExpressionInput): ExpressionNode { + return wrap(a).isNumeric(); + }, + + // --------------------------------------------------------------------------- + // Hash + // --------------------------------------------------------------------------- + + md5(a: ExpressionInput): ExpressionNode { + return wrap(a).md5(); + }, + sha256(a: ExpressionInput): ExpressionNode { + return wrap(a).sha256(); + }, + sha512(a: ExpressionInput): ExpressionNode { + return wrap(a).sha512(); + }, +} as const; diff --git a/src/expressions/ExpressionMethods.ts b/src/expressions/ExpressionMethods.ts new file mode 100644 index 0000000..aa84ea8 --- /dev/null +++ b/src/expressions/ExpressionMethods.ts @@ -0,0 +1,104 @@ +import {ExpressionInput, ExpressionNode} from './ExpressionNode.js'; +import {Shape} from '../shapes/Shape.js'; +import {ShapeSet} from '../collections/ShapeSet.js'; + +// Shared base — methods available on ALL expression types +export interface BaseExpressionMethods { + eq(v: ExpressionInput): ExpressionNode; + equals(v: ExpressionInput): ExpressionNode; + neq(v: ExpressionInput): ExpressionNode; + notEquals(v: ExpressionInput): ExpressionNode; + isDefined(): ExpressionNode; + isNotDefined(): ExpressionNode; + defaultTo(fallback: ExpressionInput): ExpressionNode; + str(): ExpressionNode; +} + +export interface NumericExpressionMethods extends BaseExpressionMethods { + plus(n: ExpressionInput): ExpressionNode; + minus(n: ExpressionInput): ExpressionNode; + times(n: ExpressionInput): ExpressionNode; + divide(n: ExpressionInput): ExpressionNode; + abs(): ExpressionNode; + round(): ExpressionNode; + ceil(): ExpressionNode; + floor(): ExpressionNode; + power(n: number): ExpressionNode; + gt(v: ExpressionInput): ExpressionNode; + greaterThan(v: ExpressionInput): ExpressionNode; + gte(v: ExpressionInput): ExpressionNode; + greaterThanOrEqual(v: ExpressionInput): ExpressionNode; + lt(v: ExpressionInput): ExpressionNode; + lessThan(v: ExpressionInput): ExpressionNode; + lte(v: ExpressionInput): ExpressionNode; + lessThanOrEqual(v: ExpressionInput): ExpressionNode; +} + +export interface StringExpressionMethods extends BaseExpressionMethods { + concat(...parts: ExpressionInput[]): ExpressionNode; + contains(s: ExpressionInput): ExpressionNode; + startsWith(s: ExpressionInput): ExpressionNode; + endsWith(s: ExpressionInput): ExpressionNode; + substr(start: number, len?: number): ExpressionNode; + before(s: ExpressionInput): ExpressionNode; + after(s: ExpressionInput): ExpressionNode; + replace(pat: string, rep: string, flags?: string): ExpressionNode; + ucase(): ExpressionNode; + lcase(): ExpressionNode; + strlen(): ExpressionNode; + encodeForUri(): ExpressionNode; + matches(pat: string, flags?: string): ExpressionNode; + gt(v: ExpressionInput): ExpressionNode; + lt(v: ExpressionInput): ExpressionNode; + gte(v: ExpressionInput): ExpressionNode; + lte(v: ExpressionInput): ExpressionNode; +} + +export interface DateExpressionMethods extends BaseExpressionMethods { + year(): ExpressionNode; + month(): ExpressionNode; + day(): ExpressionNode; + hours(): ExpressionNode; + minutes(): ExpressionNode; + seconds(): ExpressionNode; + timezone(): ExpressionNode; + tz(): ExpressionNode; + gt(v: ExpressionInput): ExpressionNode; + lt(v: ExpressionInput): ExpressionNode; + gte(v: ExpressionInput): ExpressionNode; + lte(v: ExpressionInput): ExpressionNode; +} + +export interface BooleanExpressionMethods extends BaseExpressionMethods { + and(expr: ExpressionInput): ExpressionNode; + or(expr: ExpressionInput): ExpressionNode; + not(): ExpressionNode; +} + +// Filter helper to remove Shape's internal keys +type DataKeys = { + [K in keyof S]: K extends 'node' | 'nodeShape' | 'namedNode' | 'targetClass' | 'toString' | 'id' | 'uri' | '__queryContextId' + ? never + : S[K] extends (...args: any[]) => any ? never + : K; +}[keyof S]; + +type ToExpressionProxy = + T extends number ? number & NumericExpressionMethods : + T extends string ? string & StringExpressionMethods : + T extends Date ? Date & DateExpressionMethods : + T extends boolean ? boolean & BooleanExpressionMethods : + T extends Shape ? ExpressionUpdateProxy : + T; + +export type ExpressionUpdateProxy = { + readonly [P in DataKeys]: S[P] extends ShapeSet + ? never + : ToExpressionProxy; +}; + +export type ExpressionUpdateResult = { + [P in DataKeys]?: S[P] extends Shape ? never + : S[P] extends ShapeSet ? never + : S[P] | ExpressionNode; +}; diff --git a/src/expressions/ExpressionNode.ts b/src/expressions/ExpressionNode.ts new file mode 100644 index 0000000..9e1dd3a --- /dev/null +++ b/src/expressions/ExpressionNode.ts @@ -0,0 +1,438 @@ +import type { + IRBinaryOperator, + IRExpression, +} from '../queries/IntermediateRepresentation.js'; + +export type ExpressionInput = ExpressionNode | string | number | boolean | Date; + +/** + * Map from placeholder sourceAlias → PropertyShape ID segments. + * Used to track unresolved property references from proxy tracing + * that need to be resolved during IR lowering. + */ +export type PropertyRefMap = ReadonlyMap; + +const VALID_REGEX_FLAGS = new Set(['i', 'm', 's']); + +function validateRegexFlags(flags: string | undefined): void { + if (!flags) return; + for (const ch of flags) { + if (!VALID_REGEX_FLAGS.has(ch)) { + throw new Error( + `Unsupported regex flag "${ch}". Only "i", "m", "s" are supported.`, + ); + } + } +} + +export function toIRExpression(input: ExpressionInput): IRExpression { + if (input instanceof ExpressionNode) return input.ir; + if (typeof input === 'string') + return {kind: 'literal_expr', value: input}; + if (typeof input === 'number') + return {kind: 'literal_expr', value: input}; + if (typeof input === 'boolean') + return {kind: 'literal_expr', value: input}; + if (input instanceof Date) + return {kind: 'literal_expr', value: input.toISOString()}; + throw new Error(`Invalid expression input: ${input}`); +} + +/** Collect property refs from an ExpressionInput (only ExpressionNode has refs). */ +function collectRefs(...inputs: ExpressionInput[]): Map { + const merged = new Map(); + for (const input of inputs) { + if (input instanceof ExpressionNode) { + for (const [k, v] of input._refs) merged.set(k, v); + } + } + return merged; +} + +function binary( + op: IRBinaryOperator, + left: IRExpression, + right: ExpressionInput, +): IRExpression { + return {kind: 'binary_expr', operator: op, left, right: toIRExpression(right)}; +} + +function fnExpr(name: string, ...args: IRExpression[]): IRExpression { + return {kind: 'function_expr', name, args}; +} + +export class ExpressionNode { + /** Property reference map for unresolved proxy-traced property references. */ + readonly _refs: PropertyRefMap; + + constructor( + public readonly ir: IRExpression, + refs?: PropertyRefMap, + ) { + this._refs = refs ?? new Map(); + } + + /** Create a derived node that merges refs from this and other inputs. */ + private _derive(ir: IRExpression, ...others: ExpressionInput[]): ExpressionNode { + const merged = new Map(this._refs); + for (const other of others) { + if (other instanceof ExpressionNode) { + for (const [k, v] of other._refs) merged.set(k, v); + } + } + return new ExpressionNode(ir, merged); + } + + /** Create a derived node with no additional inputs. */ + private _wrap(ir: IRExpression): ExpressionNode { + return new ExpressionNode(ir, this._refs); + } + + // --------------------------------------------------------------------------- + // Arithmetic + // --------------------------------------------------------------------------- + + plus(n: ExpressionInput): ExpressionNode { + return this._derive(binary('+', this.ir, n), n); + } + + minus(n: ExpressionInput): ExpressionNode { + return this._derive(binary('-', this.ir, n), n); + } + + times(n: ExpressionInput): ExpressionNode { + return this._derive(binary('*', this.ir, n), n); + } + + divide(n: ExpressionInput): ExpressionNode { + return this._derive(binary('/', this.ir, n), n); + } + + abs(): ExpressionNode { + return this._wrap(fnExpr('ABS', this.ir)); + } + + round(): ExpressionNode { + return this._wrap(fnExpr('ROUND', this.ir)); + } + + ceil(): ExpressionNode { + return this._wrap(fnExpr('CEIL', this.ir)); + } + + floor(): ExpressionNode { + return this._wrap(fnExpr('FLOOR', this.ir)); + } + + power(n: number): ExpressionNode { + if (!Number.isInteger(n) || n < 1) { + throw new Error( + `power() exponent must be a positive integer, got ${n}`, + ); + } + if (n > 20) { + throw new Error( + `power() exponent must be ≤ 20 to avoid query bloat, got ${n}`, + ); + } + if (n === 1) return new ExpressionNode(this.ir, this._refs); + let result: IRExpression = this.ir; + for (let i = 1; i < n; i++) { + result = {kind: 'binary_expr', operator: '*', left: result, right: this.ir}; + } + return new ExpressionNode(result, this._refs); + } + + // --------------------------------------------------------------------------- + // Comparison (short + long aliases) + // --------------------------------------------------------------------------- + + eq(v: ExpressionInput): ExpressionNode { + return this._derive(binary('=', this.ir, v), v); + } + equals(v: ExpressionInput): ExpressionNode { + return this.eq(v); + } + + neq(v: ExpressionInput): ExpressionNode { + return this._derive(binary('!=', this.ir, v), v); + } + notEquals(v: ExpressionInput): ExpressionNode { + return this.neq(v); + } + + gt(v: ExpressionInput): ExpressionNode { + return this._derive(binary('>', this.ir, v), v); + } + greaterThan(v: ExpressionInput): ExpressionNode { + return this.gt(v); + } + + gte(v: ExpressionInput): ExpressionNode { + return this._derive(binary('>=', this.ir, v), v); + } + greaterThanOrEqual(v: ExpressionInput): ExpressionNode { + return this.gte(v); + } + + lt(v: ExpressionInput): ExpressionNode { + return this._derive(binary('<', this.ir, v), v); + } + lessThan(v: ExpressionInput): ExpressionNode { + return this.lt(v); + } + + lte(v: ExpressionInput): ExpressionNode { + return this._derive(binary('<=', this.ir, v), v); + } + lessThanOrEqual(v: ExpressionInput): ExpressionNode { + return this.lte(v); + } + + // --------------------------------------------------------------------------- + // String + // --------------------------------------------------------------------------- + + concat(...parts: ExpressionInput[]): ExpressionNode { + return this._derive( + fnExpr('CONCAT', this.ir, ...parts.map(toIRExpression)), + ...parts, + ); + } + + contains(s: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('CONTAINS', this.ir, toIRExpression(s)), s); + } + + startsWith(s: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('STRSTARTS', this.ir, toIRExpression(s)), s); + } + + endsWith(s: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('STRENDS', this.ir, toIRExpression(s)), s); + } + + substr(start: number, len?: number): ExpressionNode { + const args: IRExpression[] = [ + this.ir, + {kind: 'literal_expr', value: start}, + ]; + if (len !== undefined) { + args.push({kind: 'literal_expr', value: len}); + } + return this._wrap(fnExpr('SUBSTR', ...args)); + } + + before(s: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('STRBEFORE', this.ir, toIRExpression(s)), s); + } + + after(s: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('STRAFTER', this.ir, toIRExpression(s)), s); + } + + replace(pat: string, rep: string, flags?: string): ExpressionNode { + validateRegexFlags(flags); + const args: IRExpression[] = [ + this.ir, + {kind: 'literal_expr', value: pat}, + {kind: 'literal_expr', value: rep}, + ]; + if (flags) { + args.push({kind: 'literal_expr', value: flags}); + } + return this._wrap(fnExpr('REPLACE', ...args)); + } + + ucase(): ExpressionNode { + return this._wrap(fnExpr('UCASE', this.ir)); + } + + lcase(): ExpressionNode { + return this._wrap(fnExpr('LCASE', this.ir)); + } + + strlen(): ExpressionNode { + return this._wrap(fnExpr('STRLEN', this.ir)); + } + + encodeForUri(): ExpressionNode { + return this._wrap(fnExpr('ENCODE_FOR_URI', this.ir)); + } + + matches(pat: string, flags?: string): ExpressionNode { + validateRegexFlags(flags); + const args: IRExpression[] = [ + this.ir, + {kind: 'literal_expr', value: pat}, + ]; + if (flags) { + args.push({kind: 'literal_expr', value: flags}); + } + return this._wrap(fnExpr('REGEX', ...args)); + } + + // --------------------------------------------------------------------------- + // Date/Time + // --------------------------------------------------------------------------- + + year(): ExpressionNode { return this._wrap(fnExpr('YEAR', this.ir)); } + month(): ExpressionNode { return this._wrap(fnExpr('MONTH', this.ir)); } + day(): ExpressionNode { return this._wrap(fnExpr('DAY', this.ir)); } + hours(): ExpressionNode { return this._wrap(fnExpr('HOURS', this.ir)); } + minutes(): ExpressionNode { return this._wrap(fnExpr('MINUTES', this.ir)); } + seconds(): ExpressionNode { return this._wrap(fnExpr('SECONDS', this.ir)); } + timezone(): ExpressionNode { return this._wrap(fnExpr('TIMEZONE', this.ir)); } + tz(): ExpressionNode { return this._wrap(fnExpr('TZ', this.ir)); } + + // --------------------------------------------------------------------------- + // Logical + // --------------------------------------------------------------------------- + + and(expr: ExpressionInput): ExpressionNode { + return this._derive({ + kind: 'logical_expr', + operator: 'and', + expressions: [this.ir, toIRExpression(expr)], + }, expr); + } + + or(expr: ExpressionInput): ExpressionNode { + return this._derive({ + kind: 'logical_expr', + operator: 'or', + expressions: [this.ir, toIRExpression(expr)], + }, expr); + } + + not(): ExpressionNode { + return this._wrap({kind: 'not_expr', expression: this.ir}); + } + + // --------------------------------------------------------------------------- + // Null-handling + // --------------------------------------------------------------------------- + + isDefined(): ExpressionNode { + return this._wrap(fnExpr('BOUND', this.ir)); + } + + isNotDefined(): ExpressionNode { + return this._wrap({ + kind: 'not_expr', + expression: fnExpr('BOUND', this.ir), + }); + } + + defaultTo(fallback: ExpressionInput): ExpressionNode { + return this._derive(fnExpr('COALESCE', this.ir, toIRExpression(fallback)), fallback); + } + + // --------------------------------------------------------------------------- + // RDF introspection + // --------------------------------------------------------------------------- + + lang(): ExpressionNode { return this._wrap(fnExpr('LANG', this.ir)); } + datatype(): ExpressionNode { return this._wrap(fnExpr('DATATYPE', this.ir)); } + + // --------------------------------------------------------------------------- + // Type casting / checking + // --------------------------------------------------------------------------- + + str(): ExpressionNode { return this._wrap(fnExpr('STR', this.ir)); } + iri(): ExpressionNode { return this._wrap(fnExpr('IRI', this.ir)); } + isIri(): ExpressionNode { return this._wrap(fnExpr('isIRI', this.ir)); } + isLiteral(): ExpressionNode { return this._wrap(fnExpr('isLiteral', this.ir)); } + isBlank(): ExpressionNode { return this._wrap(fnExpr('isBlank', this.ir)); } + isNumeric(): ExpressionNode { return this._wrap(fnExpr('isNumeric', this.ir)); } + + // --------------------------------------------------------------------------- + // Hash + // --------------------------------------------------------------------------- + + md5(): ExpressionNode { return this._wrap(fnExpr('MD5', this.ir)); } + sha256(): ExpressionNode { return this._wrap(fnExpr('SHA256', this.ir)); } + sha512(): ExpressionNode { return this._wrap(fnExpr('SHA512', this.ir)); } +} + +// --------------------------------------------------------------------------- +// Proxy tracing helpers +// --------------------------------------------------------------------------- + +let _refCounter = 0; + +/** + * Create an ExpressionNode from a proxy-traced property access. + * Uses a placeholder sourceAlias that gets resolved during IR lowering. + * + * The segments array maps to a property path (e.g. ['bestFriend', 'name']). + * Only the last segment becomes the property_expr's .property; earlier segments + * are stored in the refs map and resolved as traversals during lowering. + */ +export function tracedPropertyExpression( + segmentIds: readonly string[], +): ExpressionNode { + const placeholder = `__ref_${_refCounter++}__`; + const lastSegment = segmentIds[segmentIds.length - 1]; + const ir: IRExpression = { + kind: 'property_expr', + sourceAlias: placeholder, + property: lastSegment, + }; + const refs = new Map([[placeholder, segmentIds]]); + return new ExpressionNode(ir, refs); +} + +/** + * Resolve unresolved property references in an IRExpression tree. + * Walks the tree and replaces placeholder sourceAlias values with + * real aliases resolved via pathOptions. + */ +export function resolveExpressionRefs( + expr: IRExpression, + refs: PropertyRefMap, + rootAlias: string, + resolveTraversal: (fromAlias: string, propertyShapeId: string) => string, +): IRExpression { + if (refs.size === 0) return expr; + + const resolve = (e: IRExpression): IRExpression => { + switch (e.kind) { + case 'property_expr': { + const segments = refs.get(e.sourceAlias); + if (!segments) return e; + // Resolve: first N-1 segments are traversals, last is property + let currentAlias = rootAlias; + for (let i = 0; i < segments.length - 1; i++) { + currentAlias = resolveTraversal(currentAlias, segments[i]); + } + return { + kind: 'property_expr', + sourceAlias: currentAlias, + property: segments[segments.length - 1], + }; + } + case 'binary_expr': + return { + ...e, + left: resolve(e.left), + right: resolve(e.right), + }; + case 'function_expr': + return {...e, args: e.args.map(resolve)}; + case 'logical_expr': + return {...e, expressions: e.expressions.map(resolve)}; + case 'not_expr': + return {...e, expression: resolve(e.expression)}; + default: + return e; + } + }; + + return resolve(expr); +} + +/** Check if a value is an ExpressionNode. */ +export function isExpressionNode(value: unknown): value is ExpressionNode { + return value instanceof ExpressionNode; +} diff --git a/src/index.ts b/src/index.ts index a1f8ca5..6ce1ad5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,20 @@ export {CreateBuilder} from './queries/CreateBuilder.js'; export {UpdateBuilder} from './queries/UpdateBuilder.js'; export {DeleteBuilder} from './queries/DeleteBuilder.js'; +// Expressions — computed fields and functions +export {ExpressionNode} from './expressions/ExpressionNode.js'; +export type {ExpressionInput, PropertyRefMap} from './expressions/ExpressionNode.js'; +export {Expr} from './expressions/Expr.js'; +export type { + ExpressionUpdateProxy, + ExpressionUpdateResult, + BaseExpressionMethods, + NumericExpressionMethods, + StringExpressionMethods, + DateExpressionMethods, + BooleanExpressionMethods, +} from './expressions/ExpressionMethods.js'; + // Phase 5 — Component query integration export type {LinkedComponentInterface, QueryComponentLike} from './queries/SelectQuery.js'; diff --git a/src/queries/FieldSet.ts b/src/queries/FieldSet.ts index 3419a32..315ce91 100644 --- a/src/queries/FieldSet.ts +++ b/src/queries/FieldSet.ts @@ -4,6 +4,8 @@ import {PropertyPath, walkPropertyPath} from './PropertyPath.js'; import {getShapeClass} from '../utils/ShapeClass.js'; import type {WherePath} from './SelectQuery.js'; import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; +import {isExpressionNode} from '../expressions/ExpressionNode.js'; +import type {ExpressionNode} from '../expressions/ExpressionNode.js'; // Duck-type helpers for runtime detection. // These check structural shape since the classes live in SelectQuery.ts (runtime circular dep). @@ -61,6 +63,8 @@ export type FieldSetEntry = { /** Component preload composition — the FieldSet comes from a linked component's own query, * merged in via `preloadFor()`. Distinct from subSelect which is a user-authored nested query. */ preloadSubSelect?: FieldSet; + /** Computed expression from proxy tracing (e.g. `p.age.times(12)`) */ + expressionNode?: ExpressionNode; }; /** @@ -525,6 +529,10 @@ export class FieldSet { if (isBoundComponent(result)) { return [FieldSet.convertTraceResult(nodeShape, result)]; } + // Single ExpressionNode (e.g. p.age.times(12)) + if (isExpressionNode(result)) { + return [FieldSet.convertTraceResult(nodeShape, result)]; + } if (typeof result === 'object' && result !== null) { // Custom object form: {name: p.name, hobby: p.hobby} const entries: FieldSetEntry[] = []; @@ -582,6 +590,14 @@ export class FieldSet { }; } + // ExpressionNode → computed expression (e.g. p.age.times(12)) + if (isExpressionNode(obj)) { + return { + path: new PropertyPath(rootShape, []), + expressionNode: obj, + }; + } + // QueryBuilderObject → walk the chain to collect PropertyPath segments if (isQueryBuilderObject(obj)) { const segments = FieldSet.collectPropertySegments(obj); @@ -699,6 +715,10 @@ export class FieldSet { if (isEvaluation(traceResponse)) { return [FieldSet.convertTraceResult(rootShape, traceResponse)]; } + // Single ExpressionNode + if (isExpressionNode(traceResponse)) { + return [FieldSet.convertTraceResult(rootShape, traceResponse)]; + } if (typeof traceResponse === 'object' && traceResponse !== null) { // Custom object form: {name: p.name, hobby: p.hobby} const entries: FieldSetEntry[] = []; diff --git a/src/queries/IRCanonicalize.ts b/src/queries/IRCanonicalize.ts index 7dfc475..58f251d 100644 --- a/src/queries/IRCanonicalize.ts +++ b/src/queries/IRCanonicalize.ts @@ -1,4 +1,5 @@ import { + DesugaredExpressionWhere, DesugaredSelectionPath, DesugaredSelectQuery, DesugaredWhere, @@ -37,7 +38,8 @@ export type CanonicalWhereExpression = | CanonicalWhereComparison | CanonicalWhereLogical | CanonicalWhereExists - | CanonicalWhereNot; + | CanonicalWhereNot + | DesugaredExpressionWhere; /** A canonicalized MINUS entry. */ export type CanonicalMinusEntry = { @@ -152,6 +154,10 @@ const flattenLogical = ( export const canonicalizeWhere = ( where: DesugaredWhere, ): CanonicalWhereExpression => { + // ExpressionNode-based WHERE — passthrough (already canonical) + if (where.kind === 'where_expression') { + return where; + } if (where.kind === 'where_comparison') { if (where.operator === WhereMethods.EQUALS) { const nestedQuantifier = where.right.find( diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index 405ff75..977390d 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -13,6 +13,7 @@ import { } from './SelectQuery.js'; import {NodeReferenceValue, ShapeReferenceValue} from './QueryFactory.js'; import type {FieldSetEntry} from './FieldSet.js'; +import {ExpressionNode} from '../expressions/ExpressionNode.js'; import type {PropertyShape} from '../shapes/SHACL.js'; /** @@ -88,6 +89,11 @@ export type DesugaredEvaluationSelect = { where: DesugaredWhere; }; +export type DesugaredExpressionSelect = { + kind: 'expression_select'; + expressionNode: import('../expressions/ExpressionNode.js').ExpressionNode; +}; + export type DesugaredMultiSelection = { kind: 'multi_selection'; selections: DesugaredSelection[]; @@ -98,6 +104,7 @@ export type DesugaredSelection = | DesugaredSubSelect | DesugaredCustomObjectSelect | DesugaredEvaluationSelect + | DesugaredExpressionSelect | DesugaredMultiSelection; export type DesugaredWhereComparison = { @@ -113,7 +120,12 @@ export type DesugaredWhereBoolean = { andOr: Array<{and?: DesugaredWhere; or?: DesugaredWhere}>; }; -export type DesugaredWhere = DesugaredWhereComparison | DesugaredWhereBoolean; +export type DesugaredExpressionWhere = { + kind: 'where_expression'; + expressionNode: ExpressionNode; +}; + +export type DesugaredWhere = DesugaredWhereComparison | DesugaredWhereBoolean | DesugaredExpressionWhere; export type DesugaredSortBy = { direction: 'ASC' | 'DESC'; @@ -173,6 +185,14 @@ const segmentsToSteps = (segments: PropertyShape[]): DesugaredPropertyStep[] => const desugarEntry = (entry: FieldSetEntry): DesugaredSelection => { const segments = entry.path.segments; + // ExpressionNode → expression-as-selection (e.g. p.age.times(12)) + if (entry.expressionNode) { + return { + kind: 'expression_select', + expressionNode: entry.expressionNode, + }; + } + // Evaluation → where-as-selection (e.g. p.bestFriend.equals(...) used as select) if (entry.evaluation) { return { @@ -367,6 +387,13 @@ const toWhereComparison = (path: WherePath): DesugaredWhereComparison => { }; export const toWhere = (path: WherePath): DesugaredWhere => { + // ExpressionNode-based WHERE — passthrough to lowering + if ('expressionNode' in path) { + return { + kind: 'where_expression', + expressionNode: (path as {expressionNode: ExpressionNode}).expressionNode, + }; + } if ((path as WhereAndOr).firstPath) { const grouped = path as WhereAndOr; return { diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 6046cd3..02db069 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -7,12 +7,15 @@ import { CanonicalWhereNot, } from './IRCanonicalize.js'; import { + DesugaredExpressionSelect, + DesugaredExpressionWhere, DesugaredSelection, DesugaredSelectionPath, DesugaredStep, DesugaredWhere, DesugaredWhereArg, } from './IRDesugar.js'; +import {resolveExpressionRefs} from '../expressions/ExpressionNode.js'; import { IRExpression, IRGraphPattern, @@ -28,6 +31,30 @@ import {lowerSelectionPathExpression, projectionKeyFromPath} from './IRProjectio import {IRAliasScope} from './IRAliasScope.js'; import {NodeReferenceValue, ShapeReferenceValue} from './QueryFactory.js'; +/** + * Creates a memoized traversal resolver that deduplicates (fromAlias, propertyShapeId) + * pairs, generates unique aliases, and accumulates the resulting patterns. + * Used by both select-query lowering and mutation expression resolution. + */ +export function createTraversalResolver

( + generateAlias: () => string, + createPattern: (from: string, to: string, property: string) => P, +): {resolve: (fromAlias: string, propertyShapeId: string) => string; patterns: P[]} { + const patterns: P[] = []; + const seen = new Map(); + + const resolve = (fromAlias: string, propertyShapeId: string): string => { + const key = `${fromAlias}:${propertyShapeId}`; + if (seen.has(key)) return seen.get(key)!; + const toAlias = generateAlias(); + seen.set(key, toAlias); + patterns.push(createPattern(fromAlias, toAlias, propertyShapeId)); + return toAlias; + }; + + return {resolve, patterns}; +} + class LoweringContext { private counter = 0; private patterns: IRGraphPattern[] = []; @@ -81,6 +108,11 @@ class LoweringContext { } } +/** Minimal interface for alias generation used by lowerWhere and traversal resolvers. */ +type AliasGenerator = { + generateAlias(): string; +}; + type PathLoweringOptions = { rootAlias: string; resolveTraversal: (fromAlias: string, propertyShapeId: string) => string; @@ -99,7 +131,7 @@ const lowerPath = ( const lowerWhereArg = ( arg: DesugaredWhereArg, - ctx: LoweringContext, + ctx: AliasGenerator, options: PathLoweringOptions, ): IRExpression => { if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { @@ -136,7 +168,7 @@ const lowerWhereArg = ( const lowerWhere = ( where: CanonicalWhereExpression, - ctx: LoweringContext, + ctx: AliasGenerator, options: PathLoweringOptions, ): IRExpression => { switch (where.kind) { @@ -161,24 +193,10 @@ const lowerWhere = ( } case 'where_exists': { const exists = where as CanonicalWhereExists; - const traversals: IRTraversePattern[] = []; - const localTraversalMap = new Map(); - - const existsResolveTraversal = (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 {resolve: existsResolveTraversal, patterns: traversals} = createTraversalResolver( + () => ctx.generateAlias(), + (from, to, property): IRTraversePattern => ({kind: 'traverse', from, to, property}), + ); let existsRootAlias = options.rootAlias; for (const step of exists.path.steps) { @@ -207,8 +225,19 @@ const lowerWhere = ( expression: lowerWhere(not.expression, ctx, options), }; } + case 'where_expression': { + // ExpressionNode-based WHERE — resolve refs and return IRExpression directly + const exprWhere = where as DesugaredExpressionWhere; + return resolveExpressionRefs( + exprWhere.expressionNode.ir, + exprWhere.expressionNode._refs, + options.rootAlias, + options.resolveTraversal, + ); + } default: - throw new Error(`Unknown canonical where kind: ${(where as any).kind}`); + const _exhaustive: never = where; + throw new Error(`Unknown canonical where kind: ${(_exhaustive as {kind: string}).kind}`); } }; @@ -308,6 +337,21 @@ export const lowerSelectQuery = ( }]; } + if (selection.kind === 'expression_select') { + const exprSelect = selection as DesugaredExpressionSelect; + const resolved = resolveExpressionRefs( + exprSelect.expressionNode.ir, + exprSelect.expressionNode._refs, + aliasAfterPath(parentPath), + pathOptions.resolveTraversal, + ); + return [{ + kind: 'expression', + key: key || 'expr', + expression: resolved, + }]; + } + return []; }; @@ -390,22 +434,10 @@ export const lowerSelectQuery = ( 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 {resolve: minusResolveTraversal, patterns: minusTraversals} = createTraversalResolver( + () => ctx.generateAlias(), + (from, to, property): IRTraversePattern => ({kind: 'traverse', from, to, property}), + ); const minusOptions: PathLoweringOptions = { rootAlias: ctx.rootAlias, resolveTraversal: minusResolveTraversal, @@ -446,35 +478,15 @@ export const lowerWhereToIR = ( 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 ctx: AliasGenerator = { + generateAlias: () => `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 {resolve, patterns: traversals} = createTraversalResolver( + () => ctx.generateAlias(), + (from, to, property): IRTraversePattern => ({kind: 'traverse', from, to, property}), + ); - const expr = lowerWhere(where, ctx as any, options); + const expr = lowerWhere(where, ctx, {rootAlias, resolveTraversal: resolve}); return {where: expr, wherePatterns: traversals}; }; diff --git a/src/queries/IRMutation.ts b/src/queries/IRMutation.ts index 7089704..0da3e23 100644 --- a/src/queries/IRMutation.ts +++ b/src/queries/IRMutation.ts @@ -7,6 +7,7 @@ import { SinglePropertyUpdateValue, isSetModificationValue, } from './QueryFactory.js'; +import {isExpressionNode, resolveExpressionRefs} from '../expressions/ExpressionNode.js'; import { IRCreateMutation, IRDeleteMutation, @@ -20,7 +21,9 @@ import { IRUpdateMutation, IRExpression, IRGraphPattern, + IRTraversalPattern, } from './IntermediateRepresentation.js'; +import {createTraversalResolver} from './IRLower.js'; type CreateMutationInput = { shape: NodeShape; @@ -51,11 +54,53 @@ const toSetModification = (value: SetModificationValue): IRSetModificationValue }; }; -const toSingleFieldValue = (value: SinglePropertyUpdateValue): IRFieldValue => { +/** Alias used as the mutation subject for expression ref resolution. */ +const MUTATION_SUBJECT_ALIAS = '__mutation_subject__'; + +export type TraversalCollector = { + resolve: (fromAlias: string, propertyShapeId: string) => string; + patterns: IRTraversalPattern[]; +}; + +/** + * Create a traversal collector for mutation expressions. Uses `__trav_N__` alias + * prefixes to avoid collision with query aliases (a0, a1...) and the mutation + * subject placeholder. Delegates to the shared createTraversalResolver factory. + */ +export function createTraversalCollector(): TraversalCollector { + let counter = 0; + return createTraversalResolver( + () => `__trav_${counter++}__`, + (from, to, property): IRTraversalPattern => ({from, property, to}), + ); +} + +const toSingleFieldValue = ( + value: SinglePropertyUpdateValue, + collector?: TraversalCollector, +): IRFieldValue => { if (value === undefined) { return undefined; } + // ExpressionNode → resolve refs and extract IRExpression + if (isExpressionNode(value)) { + return resolveExpressionRefs( + value.ir, + value._refs, + MUTATION_SUBJECT_ALIAS, + // In mutation context, all property segments are on the subject entity. + // For single-segment refs (e.g. p.age), the property_expr already has the property as its .property. + // For multi-segment refs (e.g. p.bestFriend.name), this creates intermediate traversals. + collector + ? collector.resolve + : (_fromAlias, _propertyShapeId) => { + // No collector provided — multi-segment traversals cannot be resolved. + return MUTATION_SUBJECT_ALIAS; + }, + ); + } + if ( typeof value === 'string' || typeof value === 'number' || @@ -72,29 +117,38 @@ const toSingleFieldValue = (value: SinglePropertyUpdateValue): IRFieldValue => { return toNodeData(value as NodeDescriptionValue); }; -const toFieldValue = (value: PropUpdateValue): IRFieldValue => { +const toFieldValue = ( + value: PropUpdateValue, + collector?: TraversalCollector, +): IRFieldValue => { if (Array.isArray(value)) { - return value.map((item) => toSingleFieldValue(item)); + return value.map((item) => toSingleFieldValue(item, collector)); } if (isSetModificationValue(value)) { return toSetModification(value); } - return toSingleFieldValue(value); + return toSingleFieldValue(value, collector); }; -const toFieldUpdate = (field: NodeDescriptionValue['fields'][number]): IRFieldUpdate => { +const toFieldUpdate = ( + field: NodeDescriptionValue['fields'][number], + collector?: TraversalCollector, +): IRFieldUpdate => { return { property: field.prop.id, - value: toFieldValue(field.val), + value: toFieldValue(field.val, collector), }; }; -const toNodeData = (description: NodeDescriptionValue): IRNodeData => { +const toNodeData = ( + description: NodeDescriptionValue, + collector?: TraversalCollector, +): IRNodeData => { return { shape: description.shape.id, - fields: description.fields.map(toFieldUpdate), + fields: description.fields.map((f) => toFieldUpdate(f, collector)), ...(description.__id ? {id: description.__id} : {}), }; }; @@ -114,11 +168,16 @@ export const buildCanonicalCreateMutationIR = ( export const buildCanonicalUpdateMutationIR = ( query: UpdateMutationInput, ): IRUpdateMutation => { + const collector = createTraversalCollector(); + const data = toNodeData(query.updates, collector); return { kind: 'update', shape: query.shape.id, id: query.id, - data: toNodeData(query.updates), + data, + ...(collector.patterns.length > 0 + ? {traversalPatterns: collector.patterns} + : {}), }; }; @@ -176,11 +235,16 @@ type UpdateWhereMutationInput = { export const buildCanonicalUpdateWhereMutationIR = ( query: UpdateWhereMutationInput, ): IRUpdateWhereMutation => { + const collector = createTraversalCollector(); + const data = toNodeData(query.updates, collector); return { kind: 'update_where', shape: query.shape.id, - data: toNodeData(query.updates), + data, where: query.where, wherePatterns: query.wherePatterns, + ...(collector.patterns.length > 0 + ? {traversalPatterns: collector.patterns} + : {}), }; }; diff --git a/src/queries/IntermediateRepresentation.ts b/src/queries/IntermediateRepresentation.ts index 4f7be8f..a0f5175 100644 --- a/src/queries/IntermediateRepresentation.ts +++ b/src/queries/IntermediateRepresentation.ts @@ -139,7 +139,11 @@ export type IRBinaryOperator = | '>' | '>=' | '<' - | '<='; + | '<=' + | '+' + | '-' + | '*' + | '/'; export type IRBinaryExpression = { kind: 'binary_expr'; @@ -185,11 +189,18 @@ export type IRCreateMutation = { data: IRNodeData; }; +export type IRTraversalPattern = { + from: string; // source alias + property: string; // property IRI + to: string; // target alias +}; + export type IRUpdateMutation = { kind: 'update'; shape: string; id: string; data: IRNodeData; + traversalPatterns?: IRTraversalPattern[]; }; export type IRDeleteMutation = { @@ -216,6 +227,7 @@ export type IRUpdateWhereMutation = { data: IRNodeData; where?: IRExpression; wherePatterns?: IRGraphPattern[]; + traversalPatterns?: IRTraversalPattern[]; }; export type IRNodeData = { @@ -241,6 +253,7 @@ export type IRFieldValue = | IRNodeData | IRSetModificationValue | IRFieldValue[] + | IRExpression | undefined; // --------------------------------------------------------------------------- diff --git a/src/queries/MutationQuery.ts b/src/queries/MutationQuery.ts index c6c3ad1..d939aa0 100644 --- a/src/queries/MutationQuery.ts +++ b/src/queries/MutationQuery.ts @@ -13,6 +13,8 @@ import { import {NodeShape, PropertyShape} from '../shapes/SHACL.js'; import {Shape} from '../shapes/Shape.js'; import {getShapeClass} from '../utils/ShapeClass.js'; +import {isExpressionNode, ExpressionNode} from '../expressions/ExpressionNode.js'; +import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; export type NodeId = {id: string} | string; @@ -30,8 +32,16 @@ export class MutationQueryFactory extends QueryFactory { } return this.convertNodeDescription(obj, shape); } else if (typeof obj === 'function') { - //TODO - throw new Error('Update functions are not implemented yet'); + const shapeClass = shape.id ? getShapeClass(shape.id) : undefined; + if (!shapeClass) { + throw new Error(`Shape class not found for ${shape.id || 'unknown'}`); + } + const proxy = createProxiedPathBuilder(shapeClass); + const result = obj(proxy); + if (typeof result !== 'object' || result === null) { + throw new Error('Update function must return an object'); + } + return this.convertNodeDescription(result, shape); } else { throw new Error('Invalid update object'); } @@ -154,6 +164,11 @@ export class MutationQueryFactory extends QueryFactory { propShape?: PropertyShape, allowArrays: boolean = true, ): PropUpdateValue { + // ExpressionNode → pass through as-is (will be converted to IRExpression by IRMutation) + if (isExpressionNode(value)) { + return value as unknown as PropUpdateValue; + } + //single value which will if ( typeof value === 'string' || diff --git a/src/queries/QueryFactory.ts b/src/queries/QueryFactory.ts index c87e1a2..43c4a55 100644 --- a/src/queries/QueryFactory.ts +++ b/src/queries/QueryFactory.ts @@ -2,6 +2,7 @@ import {NodeShape, PropertyShape} from '../shapes/SHACL.js'; import {Shape} from '../shapes/Shape.js'; import {ShapeSet} from '../collections/ShapeSet.js'; import {NodeReferenceValue} from '../utils/NodeReference.js'; +import {ExpressionNode} from '../expressions/ExpressionNode.js'; export type Prettify = T extends infer R ? { @@ -121,7 +122,7 @@ type ShapePropValueToUpdatePartial = ShapeProperty extends Shape ? UpdatePartial : ShapeProperty extends ShapeSet ? SetUpdateValue - : ShapeProperty; + : ShapeProperty | ExpressionNode; type SetUpdateValue = UpdatePartial[] | SetModification; diff --git a/src/queries/SelectQuery.ts b/src/queries/SelectQuery.ts index 0447769..6e69724 100644 --- a/src/queries/SelectQuery.ts +++ b/src/queries/SelectQuery.ts @@ -12,6 +12,7 @@ import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; import {FieldSet} from './FieldSet.js'; import {PropertyPath} from './PropertyPath.js'; import type {QueryBuilder} from './QueryBuilder.js'; +import {ExpressionNode, isExpressionNode, tracedPropertyExpression} from '../expressions/ExpressionNode.js'; /** * The canonical SelectQuery type — an IR AST node representing a select query. @@ -50,7 +51,8 @@ export type AccessorReturnValue = export type WhereClause = | Evaluation - | ((s: ToQueryBuilderObject) => Evaluation); + | ExpressionNode + | ((s: ToQueryBuilderObject) => Evaluation | ExpressionNode); export type QueryBuildFn = ( p: ToQueryBuilderObject, @@ -196,7 +198,11 @@ export type ToQueryPrimitive< ? QueryPrimitive : never & {__error: 'ToQueryPrimitive: no matching primitive type'}; -export type WherePath = WhereEvaluationPath | WhereAndOr; +export type WhereExpressionPath = { + expressionNode: ExpressionNode; +}; + +export type WherePath = WhereEvaluationPath | WhereAndOr | WhereExpressionPath; export type WhereEvaluationPath = { path: QueryPropertyPath; @@ -658,16 +664,16 @@ export class QueryBuilderObject< if (datatype) { if (singleValue) { if (isSameRef(datatype, xsd.integer)) { - return new QueryPrimitive(0, property, subject); + return wrapWithExpressionProxy(new QueryPrimitive(0, property, subject)); } else if (isSameRef(datatype, xsd.boolean)) { - return new QueryPrimitive(false, property, subject); + return wrapWithExpressionProxy(new QueryPrimitive(false, property, subject)); } else if ( isSameRef(datatype, xsd.dateTime) || isSameRef(datatype, xsd.date) ) { - return new QueryPrimitive(new Date(), property, subject); + return wrapWithExpressionProxy(new QueryPrimitive(new Date(), property, subject)); } else if (isSameRef(datatype, xsd.string)) { - return new QueryPrimitive('', property, subject); + return wrapWithExpressionProxy(new QueryPrimitive('', property, subject)); } } else { //TODO review this, do we need property & subject in both of these? currently yes, but why @@ -705,7 +711,7 @@ export class QueryBuilderObject< ) { if (singleValue) { //default to string if no datatype is set - return new QueryPrimitive('', property, subject); + return wrapWithExpressionProxy(new QueryPrimitive('', property, subject)); } else { //TODO review this, do we need property & subject in both of these? currently yes, but why return new QueryPrimitiveSet([''], property, subject, [ @@ -850,13 +856,58 @@ export const processWhereClause = ( throw new Error('Cannot process where clause without shape'); } const proxy = createProxiedPathBuilder(shape); - const evaluation = validation(proxy); - return evaluation.getWherePath(); + const result = validation(proxy); + if (isExpressionNode(result)) { + return {expressionNode: result}; + } + return result.getWherePath(); + } else if (isExpressionNode(validation)) { + return {expressionNode: validation}; } else { return (validation as Evaluation).getWherePath(); } }; +// --------------------------------------------------------------------------- +// Expression method proxy for QueryPrimitive +// --------------------------------------------------------------------------- + +const EXPRESSION_METHODS = new Set([ + 'plus', 'minus', 'times', 'divide', 'abs', 'round', 'ceil', 'floor', 'power', + 'eq', 'neq', 'notEquals', 'gt', 'greaterThan', 'gte', 'greaterThanOrEqual', + 'lt', 'lessThan', 'lte', 'lessThanOrEqual', + 'concat', 'contains', 'startsWith', 'endsWith', 'substr', 'before', 'after', + 'replace', 'ucase', 'lcase', 'strlen', 'encodeForUri', 'matches', + 'year', 'month', 'day', 'hours', 'minutes', 'seconds', 'timezone', 'tz', + 'and', 'or', 'not', + 'isDefined', 'isNotDefined', 'defaultTo', + 'lang', 'datatype', 'str', 'iri', 'isIri', 'isLiteral', 'isBlank', 'isNumeric', + 'md5', 'sha256', 'sha512', +]); + +/** + * Wrap a QueryPrimitive in a Proxy that intercepts expression method calls. + * When an expression method (e.g., `.plus()`, `.gt()`) is accessed, creates a + * traced ExpressionNode based on the QueryPrimitive's property path. + * + * Note: `.equals()` is intentionally excluded — it's an existing QueryPrimitive + * method that returns an Evaluation (for WHERE clauses). Use `.eq()` for the + * expression form. + */ +function wrapWithExpressionProxy(qp: QueryPrimitive): QueryPrimitive { + return new Proxy(qp, { + get(target, key, receiver) { + if (typeof key === 'string' && EXPRESSION_METHODS.has(key)) { + const segments = FieldSet.collectPropertySegments(target); + const segmentIds = segments.map((s) => s.id); + const baseNode = tracedPropertyExpression(segmentIds); + return (...args: any[]) => (baseNode as any)[key](...args); + } + return Reflect.get(target, key, receiver); + }, + }) as QueryPrimitive; +} + /** * Evaluate a sort callback through the proxy and extract a SortByPath. * This is a standalone helper that replaces the need for the former SelectQueryFactory.sortBy(). diff --git a/src/queries/UpdateBuilder.ts b/src/queries/UpdateBuilder.ts index 847fb87..2086072 100644 --- a/src/queries/UpdateBuilder.ts +++ b/src/queries/UpdateBuilder.ts @@ -8,6 +8,7 @@ import {buildCanonicalUpdateWhereMutationIR} from './IRMutation.js'; import {toWhere} from './IRDesugar.js'; import {canonicalizeWhere} from './IRCanonicalize.js'; import {lowerWhereToIR} from './IRLower.js'; +import type {ExpressionUpdateProxy, ExpressionUpdateResult} from '../expressions/ExpressionMethods.js'; type UpdateMode = 'for' | 'forAll' | 'where'; @@ -93,8 +94,10 @@ export class UpdateBuilder = } /** Replace the update data. */ - set>(data: NewU): UpdateBuilder { - return this.clone({data}) as unknown as UpdateBuilder; + set(fn: (p: ExpressionUpdateProxy) => ExpressionUpdateResult): UpdateBuilder; + set>(data: NewU): UpdateBuilder; + set(data: any): any { + return this.clone({data}) as any; } // --------------------------------------------------------------------------- diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index d828f22..6298952 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -17,6 +17,7 @@ import {QueryBuilder} from '../queries/QueryBuilder.js'; import {CreateBuilder} from '../queries/CreateBuilder.js'; import {UpdateBuilder} from '../queries/UpdateBuilder.js'; import {DeleteBuilder} from '../queries/DeleteBuilder.js'; +import type {ExpressionUpdateProxy, ExpressionUpdateResult} from '../expressions/ExpressionMethods.js'; import {getPropertyShapeByLabel} from '../utils/ShapeClass.js'; import {ShapeSet} from '../collections/ShapeSet.js'; @@ -153,11 +154,19 @@ export abstract class Shape { * await Person.update({name: 'Alice'}).for({id: '...'}); * ``` */ + static update( + this: ShapeConstructor, + data: (p: ExpressionUpdateProxy) => ExpressionUpdateResult, + ): UpdateBuilder; static update>( this: ShapeConstructor, data: U, - ): UpdateBuilder { - return UpdateBuilder.from(this).set(data) as unknown as UpdateBuilder; + ): UpdateBuilder; + static update( + this: ShapeConstructor, + data: any, + ): UpdateBuilder { + return UpdateBuilder.from(this).set(data) as unknown as UpdateBuilder; } static create>( diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 585eede..eca32f4 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -111,6 +111,18 @@ function sanitizeVarName(name: string): string { return name.replace(/[^A-Za-z0-9_]/g, '_'); } +const IR_EXPRESSION_KINDS = new Set([ + 'literal_expr', 'property_expr', 'binary_expr', 'logical_expr', + 'not_expr', 'function_expr', 'aggregate_expr', 'reference_expr', + 'alias_expr', 'context_property_expr', 'exists_expr', +]); + +function isIRExpression(value: unknown): value is IRExpression { + return !!value && typeof value === 'object' && 'kind' in value && + typeof (value as {kind: unknown}).kind === 'string' && + IR_EXPRESSION_KINDS.has((value as {kind: string}).kind); +} + /** * Wrap a single node in a LeftJoin, making `right` optional relative to `left`. */ @@ -1004,10 +1016,12 @@ function processUpdateFields( deletePatterns: SparqlTriple[]; insertPatterns: SparqlTriple[]; oldValueTriples: SparqlTriple[]; + extends: Array<{variable: string; expression: SparqlExpression}>; } { const deletePatterns: SparqlTriple[] = []; const insertPatterns: SparqlTriple[] = []; const oldValueTriples: SparqlTriple[] = []; + const extends_: Array<{variable: string; expression: SparqlExpression}> = []; for (const field of data.fields) { const propertyTerm = iriTerm(field.property); @@ -1092,6 +1106,49 @@ function processUpdateFields( continue; } + // IRExpression — computed value update (e.g. p.age.plus(1)) + if (isIRExpression(field.value)) { + const expr = field.value as IRExpression; + const oldVar = varTerm(`old_${suffix}`); + const computedVarName = `computed_${suffix}`; + const computedVar = varTerm(computedVarName); + + // DELETE old value + deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + + // WHERE: OPTIONAL for old value + oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, oldVar)); + + // Discover additional property references in the expression and add OPTIONAL triples + const registry = new VariableRegistry(); + const mutationSubjectAlias = '__mutation_subject__'; + // Pre-register the subject variable mapping for the field being updated + registry.set(mutationSubjectAlias, field.property, `old_${suffix}`); + + const additionalOptionals: SparqlTriple[] = []; + processExpressionForProperties(expr, registry, additionalOptionals); + + // Add any additional property OPTIONAL triples (for refs to other properties) + for (const triple of additionalOptionals) { + // Rewrite the subject from the placeholder variable to the actual subject term + if (triple.subject.kind === 'variable' && triple.subject.name === mutationSubjectAlias) { + oldValueTriples.push(tripleOf(subjectTerm, triple.predicate, triple.object)); + } else { + oldValueTriples.push(triple); + } + } + + // Convert IRExpression to SparqlExpression + const sparqlExpr = convertExpression(expr, registry, additionalOptionals); + + // BIND computed expression + extends_.push({variable: computedVarName, expression: sparqlExpr}); + + // INSERT computed value + insertPatterns.push(tripleOf(subjectTerm, propertyTerm, computedVar)); + continue; + } + // Simple value update — delete old + insert new const oldVar = varTerm(`old_${suffix}`); deletePatterns.push(tripleOf(subjectTerm, propertyTerm, oldVar)); @@ -1103,7 +1160,7 @@ function processUpdateFields( } } - return {deletePatterns, insertPatterns, oldValueTriples}; + return {deletePatterns, insertPatterns, oldValueTriples, extends: extends_}; } /** @@ -1136,17 +1193,46 @@ export function updateToAlgebra( options?: SparqlOptions, ): SparqlDeleteInsertPlan { const subjectTerm = iriTerm(query.id); - const {deletePatterns, insertPatterns, oldValueTriples} = processUpdateFields(query.data, subjectTerm, options); + const result = processUpdateFields(query.data, subjectTerm, options); - const whereAlgebra = wrapOldValueOptionals( + let whereAlgebra = wrapOldValueOptionals( {type: 'bgp', triples: []}, - oldValueTriples, + result.oldValueTriples, ); + // Add traversal OPTIONAL patterns (for multi-segment expression refs) + // These must come BEFORE expression BINDs since the BINDs reference traversal variables. + if (query.traversalPatterns) { + for (const trav of query.traversalPatterns) { + const fromTerm = + trav.from === '__mutation_subject__' ? subjectTerm : varTerm(trav.from); + const traversalTriple = tripleOf( + fromTerm, + iriTerm(trav.property), + varTerm(trav.to), + ); + whereAlgebra = { + type: 'left_join', + left: whereAlgebra, + right: {type: 'bgp', triples: [traversalTriple]}, + }; + } + } + + // Add BIND expressions for computed fields + for (const ext of result.extends) { + whereAlgebra = { + type: 'extend', + inner: whereAlgebra, + variable: ext.variable, + expression: ext.expression, + }; + } + return { type: 'delete_insert', - deletePatterns, - insertPatterns, + deletePatterns: result.deletePatterns, + insertPatterns: result.insertPatterns, whereAlgebra, }; } @@ -1401,7 +1487,7 @@ export function updateWhereToAlgebra( options?: SparqlOptions, ): SparqlDeleteInsertPlan { const subjectTerm = varTerm('a0'); - const {deletePatterns, insertPatterns, oldValueTriples} = processUpdateFields(query.data, subjectTerm, options); + const result = processUpdateFields(query.data, subjectTerm, options); // WHERE: type triple is always required const typeTriple = tripleOf(subjectTerm, iriTerm(RDF_TYPE), iriTerm(query.data.shape)); @@ -1431,12 +1517,41 @@ export function updateWhereToAlgebra( whereAlgebra = {type: 'filter', expression: filterExpr, inner: whereAlgebra}; } - whereAlgebra = wrapOldValueOptionals(whereAlgebra, oldValueTriples); + whereAlgebra = wrapOldValueOptionals(whereAlgebra, result.oldValueTriples); + + // Add traversal OPTIONAL patterns (for multi-segment expression refs) + // These must come BEFORE expression BINDs since the BINDs reference traversal variables. + if (query.traversalPatterns) { + for (const trav of query.traversalPatterns) { + const fromTerm = + trav.from === '__mutation_subject__' ? varTerm('a0') : varTerm(trav.from); + const traversalTriple = tripleOf( + fromTerm, + iriTerm(trav.property), + varTerm(trav.to), + ); + whereAlgebra = { + type: 'left_join', + left: whereAlgebra, + right: {type: 'bgp', triples: [traversalTriple]}, + }; + } + } + + // Add BIND expressions for computed fields + for (const ext of result.extends) { + whereAlgebra = { + type: 'extend', + inner: whereAlgebra, + variable: ext.variable, + expression: ext.expression, + }; + } return { type: 'delete_insert', - deletePatterns, - insertPatterns, + deletePatterns: result.deletePatterns, + insertPatterns: result.insertPatterns, whereAlgebra, }; } diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index 0791e6a..ca3953e 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -314,43 +314,43 @@ export const queryFactories = { sortByAsc: () => Person.select((p) => p.name).orderBy((p) => p.name), sortByDesc: () => 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: () => + updateSimple: (() => Person.update(updateSimple).for(entity('p1'))) as () => any, + createSimple: (() => Person.create({name: 'Test Create', hobby: 'Chess'})) as () => any, + createWithFriends: (() => Person.create({ name: 'Test Create', friends: [entity('p2'), {name: 'New Friend'}], - }), - createWithFixedId: () => + })) as () => any, + createWithFixedId: (() => Person.create({ __id: `${tmpEntityBase}fixed-id`, name: 'Fixed', bestFriend: entity('fixed-id-2'), - } as any), + } as any)) as () => any, deleteSingle: () => Person.delete(entity('to-delete')), deleteSingleRef: () => Person.delete(entity('to-delete')), deleteMultiple: () => Person.delete([entity('to-delete-1'), entity('to-delete-2')]), deleteMultipleFull: () => Person.delete([entity('to-delete-1'), entity('to-delete-2')]), - updateOverwriteSet: () => Person.update(updateOverwriteSet).for(entity('p1')), - updateUnsetSingleUndefined: () => - Person.update(updateUnsetSingleUndefined).for(entity('p1')), - updateUnsetSingleNull: () => - Person.update(updateUnsetSingleNull).for(entity('p1')), - updateOverwriteNested: () => - Person.update(updateOverwriteNested).for(entity('p1')), - updatePassIdReferences: () => - Person.update(updatePassIdReferences).for(entity('p1')), - updateAddRemoveMulti: () => - Person.update(updateAddRemoveMulti).for(entity('p1')), - updateRemoveMulti: () => Person.update(updateRemoveMulti).for(entity('p1')), - updateAddRemoveSame: () => Person.update(updateAddRemoveSame).for(entity('p1')), - updateUnsetMultiUndefined: () => - Person.update(updateUnsetMultiUndefined).for(entity('p1')), - updateNestedWithPredefinedId: () => - Person.update(updateNestedWithPredefinedId).for(entity('p1')), - updateBirthDate: () => Person.update(updateBirthDate).for(entity('p1')), + updateOverwriteSet: (() => Person.update(updateOverwriteSet).for(entity('p1'))) as () => any, + updateUnsetSingleUndefined: (() => + Person.update(updateUnsetSingleUndefined).for(entity('p1'))) as () => any, + updateUnsetSingleNull: (() => + Person.update(updateUnsetSingleNull).for(entity('p1'))) as () => any, + updateOverwriteNested: (() => + Person.update(updateOverwriteNested).for(entity('p1'))) as () => any, + updatePassIdReferences: (() => + Person.update(updatePassIdReferences).for(entity('p1'))) as () => any, + updateAddRemoveMulti: (() => + Person.update(updateAddRemoveMulti).for(entity('p1'))) as () => any, + updateRemoveMulti: (() => Person.update(updateRemoveMulti).for(entity('p1'))) as () => any, + updateAddRemoveSame: (() => Person.update(updateAddRemoveSame).for(entity('p1'))) as () => any, + updateUnsetMultiUndefined: (() => + Person.update(updateUnsetMultiUndefined).for(entity('p1'))) as () => any, + updateNestedWithPredefinedId: (() => + Person.update(updateNestedWithPredefinedId).for(entity('p1'))) as () => any, + updateBirthDate: (() => Person.update(updateBirthDate).for(entity('p1'))) as () => any, preloadBestFriend: () => Person.select((p) => p.bestFriend.preloadFor(componentLike)), preloadBestFriendWithFieldSet: () => @@ -489,8 +489,97 @@ export const queryFactories = { // --- Conditional update tests --- // Update all instances - updateForAll: () => Person.update({hobby: 'Chess'}).forAll(), + updateForAll: (): any => Person.update({hobby: 'Chess'}).forAll(), // Update with where condition - updateWhere: () => Person.update({hobby: 'Archived'}).where((p) => p.hobby.equals('Chess')), + updateWhere: (): any => Person.update({hobby: 'Archived'}).where((p) => p.hobby.equals('Chess')), + + // --- Computed expression tests --- + + // Simple expression: string length + exprStrlen: () => + Person.select((p) => (p.name as any).strlen()), + + // Expression with custom key + exprCustomKey: () => + Person.select((p) => ({nameLen: (p.name as any).strlen()})), + + // Expression on nested path + exprNestedPath: () => + Person.select((p) => (p.bestFriend.name as any).ucase()), + + // Multiple expressions in array + exprMultiple: () => + Person.select((p) => [ + p.name, + (p.name as any).strlen(), + ]), + + // --- Mutation expression tests --- + + // Functional callback update with expression + updateExprCallback: (): any => + Dog.update((p) => ({guardDogLevel: p.guardDogLevel.plus(1)})).for(entity('d1')), + + // Expression in update with Expr.now() + updateExprNow: (): any => { + const {Expr} = require('../expressions/Expr'); + return Person.update({birthDate: Expr.now()}).for(entity('p1')); + }, + + // --- Traversal expression mutation tests --- + + // Multi-segment expression ref: p.bestFriend.name.ucase() + updateExprTraversal: (): any => + Person.update((p) => ({hobby: p.bestFriend.name.ucase()})).for(entity('p1')), + + // Shared traversal: two fields referencing same intermediate (p.bestFriend) + updateExprSharedTraversal: (): any => + Person.update((p) => ({ + name: p.bestFriend.name.ucase(), + hobby: p.bestFriend.hobby.lcase(), + })).for(entity('p1')), + + // --- Expression-based WHERE filter tests (Phase 8) --- + + // Expression WHERE: STRLEN filter + whereExprStrlen: () => + Person.select((p) => ({name: p.name})).where(((p: any) => p.name.strlen().gt(5)) as any), + + // Expression WHERE: arithmetic + whereExprArithmetic: () => + Person.select((p) => ({name: p.name})).where(((p: any) => p.name.strlen().plus(10).lt(100)) as any), + + // Expression WHERE: AND chaining on ExpressionNode + whereExprAndChain: () => + Person.select((p) => ({name: p.name})).where(((p: any) => + p.name.strlen().gt(5).and(p.name.strlen().lt(20)) + ) as any), + + // Expression WHERE: mixed Evaluation .and() with ExpressionNode + whereExprMixed: () => + Person.select((p) => ({name: p.name})).where((p) => + p.name.equals('Bob').and((p.name as any).strlen().gt(3)), + ), + + // Expression WHERE on UpdateBuilder + whereExprUpdateBuilder: () => + Person.update({hobby: 'Archived'}).where(((p: any) => p.name.strlen().gt(3)) as any), + + // Expression WHERE on DeleteBuilder + whereExprDeleteBuilder: () => + DeleteBuilder.from(Person).where(((p: any) => p.name.strlen().gt(3)) as any), + + // Expression WHERE with nested path traversal + whereExprNestedPath: () => + Person.select((p) => ({name: p.name})).where(((p: any) => + p.bestFriend.name.strlen().gt(3) + ) as any), + + // Expression WHERE combined with expression projection + whereExprWithProjection: () => + Person.select((p) => ({ + name: p.name, + nameLen: (p.name as any).strlen(), + })).where(((p: any) => p.name.strlen().gt(2)) as any), }; diff --git a/src/tests/core-utils.test.ts b/src/tests/core-utils.test.ts index 63d98ef..73c0886 100644 --- a/src/tests/core-utils.test.ts +++ b/src/tests/core-utils.test.ts @@ -237,7 +237,7 @@ describe('QueryContext edge cases', () => { if (!where) { throw new Error('Expected where clause'); } - const evaluation = isWhereEvaluationPath(where) ? where : where.firstPath; + const evaluation = isWhereEvaluationPath(where) ? where : (where as any).firstPath; if (!isWhereEvaluationPath(evaluation)) { throw new Error('Expected evaluation where clause'); } diff --git a/src/tests/expr-module.test.ts b/src/tests/expr-module.test.ts new file mode 100644 index 0000000..5b71fbd --- /dev/null +++ b/src/tests/expr-module.test.ts @@ -0,0 +1,259 @@ +import {describe, expect, test} from '@jest/globals'; +import {Expr} from '../expressions/Expr'; +import {ExpressionNode, toIRExpression} from '../expressions/ExpressionNode'; +import type {IRExpression} from '../queries/IntermediateRepresentation'; + +const a: IRExpression = {kind: 'property_expr', sourceAlias: 'a0', property: 'x'}; +const b: IRExpression = {kind: 'property_expr', sourceAlias: 'a0', property: 'y'}; + +const nodeA = new ExpressionNode(a); +const nodeB = new ExpressionNode(b); + +describe('Expr module', () => { + // ------------------------------------------------------------------------- + // Arithmetic — produces same IR as fluent + // ------------------------------------------------------------------------- + + describe('arithmetic', () => { + test('plus', () => { + expect(Expr.plus(nodeA, nodeB).ir).toEqual(nodeA.plus(nodeB).ir); + }); + + test('minus', () => { + expect(Expr.minus(nodeA, 5).ir).toEqual(nodeA.minus(5).ir); + }); + + test('times', () => { + expect(Expr.times(nodeA, 12).ir).toEqual(nodeA.times(12).ir); + }); + + test('divide', () => { + expect(Expr.divide(nodeA, 2).ir).toEqual(nodeA.divide(2).ir); + }); + + test('abs', () => { + expect(Expr.abs(nodeA).ir).toEqual(nodeA.abs().ir); + }); + + test('round', () => { + expect(Expr.round(nodeA).ir).toEqual(nodeA.round().ir); + }); + + test('ceil', () => { + expect(Expr.ceil(nodeA).ir).toEqual(nodeA.ceil().ir); + }); + + test('floor', () => { + expect(Expr.floor(nodeA).ir).toEqual(nodeA.floor().ir); + }); + + test('power', () => { + expect(Expr.power(nodeA, 3).ir).toEqual(nodeA.power(3).ir); + }); + }); + + // ------------------------------------------------------------------------- + // Comparison + // ------------------------------------------------------------------------- + + describe('comparison', () => { + test('eq', () => { + expect(Expr.eq(nodeA, 30).ir).toEqual(nodeA.eq(30).ir); + }); + + test('neq', () => { + expect(Expr.neq(nodeA, 0).ir).toEqual(nodeA.neq(0).ir); + }); + + test('gt', () => { + expect(Expr.gt(nodeA, 18).ir).toEqual(nodeA.gt(18).ir); + }); + + test('gte', () => { + expect(Expr.gte(nodeA, 18).ir).toEqual(nodeA.gte(18).ir); + }); + + test('lt', () => { + expect(Expr.lt(nodeA, 65).ir).toEqual(nodeA.lt(65).ir); + }); + + test('lte', () => { + expect(Expr.lte(nodeA, 65).ir).toEqual(nodeA.lte(65).ir); + }); + }); + + // ------------------------------------------------------------------------- + // String + // ------------------------------------------------------------------------- + + describe('string', () => { + test('concat produces CONCAT with all args', () => { + const result = Expr.concat(nodeA, ' ', nodeB); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'CONCAT', + args: [a, {kind: 'literal_expr', value: ' '}, b], + }); + }); + + test('concat requires at least 2 args', () => { + expect(() => (Expr as any).concat(nodeA)).toThrow('at least 2'); + }); + + test('contains', () => { + expect(Expr.contains(nodeA, 'foo').ir).toEqual(nodeA.contains('foo').ir); + }); + + test('startsWith', () => { + expect(Expr.startsWith(nodeA, 'A').ir).toEqual(nodeA.startsWith('A').ir); + }); + + test('endsWith', () => { + expect(Expr.endsWith(nodeA, 'z').ir).toEqual(nodeA.endsWith('z').ir); + }); + + test('substr', () => { + expect(Expr.substr(nodeA, 1, 5).ir).toEqual(nodeA.substr(1, 5).ir); + }); + + test('before', () => { + expect(Expr.before(nodeA, '@').ir).toEqual(nodeA.before('@').ir); + }); + + test('after', () => { + expect(Expr.after(nodeA, '@').ir).toEqual(nodeA.after('@').ir); + }); + + test('replace', () => { + expect(Expr.replace(nodeA, 'old', 'new', 'i').ir).toEqual( + nodeA.replace('old', 'new', 'i').ir, + ); + }); + + test('ucase', () => { + expect(Expr.ucase(nodeA).ir).toEqual(nodeA.ucase().ir); + }); + + test('lcase', () => { + expect(Expr.lcase(nodeA).ir).toEqual(nodeA.lcase().ir); + }); + + test('strlen', () => { + expect(Expr.strlen(nodeA).ir).toEqual(nodeA.strlen().ir); + }); + + test('encodeForUri', () => { + expect(Expr.encodeForUri(nodeA).ir).toEqual(nodeA.encodeForUri().ir); + }); + + test('regex', () => { + expect(Expr.regex(nodeA, '^A', 'i').ir).toEqual( + nodeA.matches('^A', 'i').ir, + ); + }); + }); + + // ------------------------------------------------------------------------- + // Date/Time + // ------------------------------------------------------------------------- + + describe('date/time', () => { + test('now produces function_expr with no args', () => { + expect(Expr.now().ir).toEqual({ + kind: 'function_expr', + name: 'NOW', + args: [], + }); + }); + + test.each([ + ['year', 'YEAR'], + ['month', 'MONTH'], + ['day', 'DAY'], + ['hours', 'HOURS'], + ['minutes', 'MINUTES'], + ['seconds', 'SECONDS'], + ['timezone', 'TIMEZONE'], + ['tz', 'TZ'], + ] as [string, string][])('%s', (method, sparqlName) => { + expect((Expr as any)[method](nodeA).ir).toEqual( + (nodeA as any)[method]().ir, + ); + }); + }); + + // ------------------------------------------------------------------------- + // Logical + // ------------------------------------------------------------------------- + + describe('logical', () => { + test('and', () => { + expect(Expr.and(nodeA, nodeB).ir).toEqual(nodeA.and(nodeB).ir); + }); + + test('or', () => { + expect(Expr.or(nodeA, nodeB).ir).toEqual(nodeA.or(nodeB).ir); + }); + + test('not', () => { + expect(Expr.not(nodeA).ir).toEqual(nodeA.not().ir); + }); + }); + + // ------------------------------------------------------------------------- + // Null-handling / Conditional + // ------------------------------------------------------------------------- + + describe('null-handling / conditional', () => { + test('firstDefined produces COALESCE', () => { + const result = Expr.firstDefined(nodeA, nodeB, 0); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'COALESCE', + args: [a, b, {kind: 'literal_expr', value: 0}], + }); + }); + + test('firstDefined requires at least 2 args', () => { + expect(() => (Expr as any).firstDefined(nodeA)).toThrow('at least 2'); + }); + + test('ifThen produces IF', () => { + const cond = nodeA.gt(0); + const result = Expr.ifThen(cond, 'yes', 'no'); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'IF', + args: [cond.ir, {kind: 'literal_expr', value: 'yes'}, {kind: 'literal_expr', value: 'no'}], + }); + }); + + test('bound', () => { + expect(Expr.bound(nodeA).ir).toEqual(nodeA.isDefined().ir); + }); + }); + + // ------------------------------------------------------------------------- + // RDF introspection / type / hash + // ------------------------------------------------------------------------- + + describe('rdf/type/hash', () => { + test.each([ + ['lang', 'lang'], + ['datatype', 'datatype'], + ['str', 'str'], + ['iri', 'iri'], + ['isIri', 'isIri'], + ['isLiteral', 'isLiteral'], + ['isBlank', 'isBlank'], + ['isNumeric', 'isNumeric'], + ['md5', 'md5'], + ['sha256', 'sha256'], + ['sha512', 'sha512'], + ] as [string, string][])('Expr.%s matches fluent', (exprMethod, fluentMethod) => { + expect((Expr as any)[exprMethod](nodeA).ir).toEqual( + (nodeA as any)[fluentMethod]().ir, + ); + }); + }); +}); diff --git a/src/tests/expression-node.test.ts b/src/tests/expression-node.test.ts new file mode 100644 index 0000000..fd745c1 --- /dev/null +++ b/src/tests/expression-node.test.ts @@ -0,0 +1,427 @@ +import {describe, expect, test} from '@jest/globals'; +import {ExpressionNode, toIRExpression} from '../expressions/ExpressionNode'; +import type {IRExpression} from '../queries/IntermediateRepresentation'; + +const prop: IRExpression = { + kind: 'property_expr', + sourceAlias: 'a0', + property: 'age', +}; + +function node(ir?: IRExpression): ExpressionNode { + return new ExpressionNode(ir ?? prop); +} + +describe('ExpressionNode', () => { + // ------------------------------------------------------------------------- + // toIRExpression normalization + // ------------------------------------------------------------------------- + + describe('toIRExpression', () => { + test('string → literal_expr', () => { + expect(toIRExpression('hello')).toEqual({kind: 'literal_expr', value: 'hello'}); + }); + + test('number → literal_expr', () => { + expect(toIRExpression(42)).toEqual({kind: 'literal_expr', value: 42}); + }); + + test('boolean → literal_expr', () => { + expect(toIRExpression(true)).toEqual({kind: 'literal_expr', value: true}); + }); + + test('Date → literal_expr ISO string', () => { + const d = new Date('2024-01-15T00:00:00.000Z'); + expect(toIRExpression(d)).toEqual({ + kind: 'literal_expr', + value: '2024-01-15T00:00:00.000Z', + }); + }); + + test('ExpressionNode → extracts .ir', () => { + const n = node(); + expect(toIRExpression(n)).toBe(prop); + }); + }); + + // ------------------------------------------------------------------------- + // Arithmetic + // ------------------------------------------------------------------------- + + describe('arithmetic', () => { + test('plus', () => { + const result = node().plus(1); + expect(result.ir).toEqual({ + kind: 'binary_expr', + operator: '+', + left: prop, + right: {kind: 'literal_expr', value: 1}, + }); + }); + + test('minus', () => { + expect(node().minus(5).ir.kind).toBe('binary_expr'); + expect((node().minus(5).ir as any).operator).toBe('-'); + }); + + test('times', () => { + expect((node().times(12).ir as any).operator).toBe('*'); + }); + + test('divide', () => { + expect((node().divide(2).ir as any).operator).toBe('/'); + }); + + test('abs', () => { + expect(node().abs().ir).toEqual({ + kind: 'function_expr', + name: 'ABS', + args: [prop], + }); + }); + + test('round', () => { + expect((node().round().ir as any).name).toBe('ROUND'); + }); + + test('ceil', () => { + expect((node().ceil().ir as any).name).toBe('CEIL'); + }); + + test('floor', () => { + expect((node().floor().ir as any).name).toBe('FLOOR'); + }); + + test('power(1) returns identity', () => { + const result = node().power(1); + expect(result.ir).toBe(prop); + }); + + test('power(3) produces nested multiplication', () => { + const result = node().power(3); + // (prop * prop) * prop + expect(result.ir).toEqual({ + kind: 'binary_expr', + operator: '*', + left: { + kind: 'binary_expr', + operator: '*', + left: prop, + right: prop, + }, + right: prop, + }); + }); + + test('power(20) succeeds', () => { + expect(() => node().power(20)).not.toThrow(); + }); + + test('power(21) throws', () => { + expect(() => node().power(21)).toThrow('≤ 20'); + }); + + test('power(0) throws', () => { + expect(() => node().power(0)).toThrow('positive integer'); + }); + + test('power(-1) throws', () => { + expect(() => node().power(-1)).toThrow('positive integer'); + }); + + test('power(2.5) throws', () => { + expect(() => node().power(2.5)).toThrow('positive integer'); + }); + }); + + // ------------------------------------------------------------------------- + // Comparison + // ------------------------------------------------------------------------- + + describe('comparison', () => { + test('eq', () => { + expect((node().eq(30).ir as any).operator).toBe('='); + }); + + test('equals is alias for eq', () => { + const a = node().eq(30); + const b = node().equals(30); + expect(a.ir).toEqual(b.ir); + }); + + test('neq / notEquals', () => { + expect((node().neq(0).ir as any).operator).toBe('!='); + expect(node().neq(0).ir).toEqual(node().notEquals(0).ir); + }); + + test('gt / greaterThan', () => { + expect((node().gt(18).ir as any).operator).toBe('>'); + expect(node().gt(18).ir).toEqual(node().greaterThan(18).ir); + }); + + test('gte / greaterThanOrEqual', () => { + expect((node().gte(18).ir as any).operator).toBe('>='); + expect(node().gte(18).ir).toEqual(node().greaterThanOrEqual(18).ir); + }); + + test('lt / lessThan', () => { + expect((node().lt(65).ir as any).operator).toBe('<'); + expect(node().lt(65).ir).toEqual(node().lessThan(65).ir); + }); + + test('lte / lessThanOrEqual', () => { + expect((node().lte(65).ir as any).operator).toBe('<='); + expect(node().lte(65).ir).toEqual(node().lessThanOrEqual(65).ir); + }); + }); + + // ------------------------------------------------------------------------- + // String + // ------------------------------------------------------------------------- + + describe('string', () => { + const strProp: IRExpression = {kind: 'property_expr', sourceAlias: 'a0', property: 'name'}; + const strNode = () => node(strProp); + + test('concat', () => { + const result = strNode().concat(' ', 'suffix'); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'CONCAT', + args: [strProp, {kind: 'literal_expr', value: ' '}, {kind: 'literal_expr', value: 'suffix'}], + }); + }); + + test('contains', () => { + expect((strNode().contains('foo').ir as any).name).toBe('CONTAINS'); + }); + + test('startsWith', () => { + expect((strNode().startsWith('A').ir as any).name).toBe('STRSTARTS'); + }); + + test('endsWith', () => { + expect((strNode().endsWith('z').ir as any).name).toBe('STRENDS'); + }); + + test('substr without length', () => { + const result = strNode().substr(1); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'SUBSTR', + args: [strProp, {kind: 'literal_expr', value: 1}], + }); + }); + + test('substr with length', () => { + const result = strNode().substr(1, 5); + expect((result.ir as any).args).toHaveLength(3); + }); + + test('before', () => { + expect((strNode().before('@').ir as any).name).toBe('STRBEFORE'); + }); + + test('after', () => { + expect((strNode().after('@').ir as any).name).toBe('STRAFTER'); + }); + + test('replace without flags', () => { + const result = strNode().replace('old', 'new'); + expect((result.ir as any).name).toBe('REPLACE'); + expect((result.ir as any).args).toHaveLength(3); + }); + + test('replace with flags', () => { + const result = strNode().replace('old', 'new', 'i'); + expect((result.ir as any).args).toHaveLength(4); + }); + + test('replace with invalid flag throws', () => { + expect(() => strNode().replace('a', 'b', 'x')).toThrow('Unsupported regex flag'); + }); + + test('ucase', () => { + expect((strNode().ucase().ir as any).name).toBe('UCASE'); + }); + + test('lcase', () => { + expect((strNode().lcase().ir as any).name).toBe('LCASE'); + }); + + test('strlen', () => { + expect((strNode().strlen().ir as any).name).toBe('STRLEN'); + }); + + test('encodeForUri', () => { + expect((strNode().encodeForUri().ir as any).name).toBe('ENCODE_FOR_URI'); + }); + + test('matches without flags', () => { + const result = strNode().matches('^A.*'); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'REGEX', + args: [strProp, {kind: 'literal_expr', value: '^A.*'}], + }); + }); + + test('matches with flags', () => { + const result = strNode().matches('^A', 'ims'); + expect((result.ir as any).args).toHaveLength(3); + }); + + test('matches with invalid flag throws', () => { + expect(() => strNode().matches('a', 'g')).toThrow('Unsupported regex flag'); + }); + }); + + // ------------------------------------------------------------------------- + // Date/Time + // ------------------------------------------------------------------------- + + describe('date/time', () => { + const dateProp: IRExpression = {kind: 'property_expr', sourceAlias: 'a0', property: 'created'}; + const dateNode = () => node(dateProp); + + test.each([ + ['year', 'YEAR'], + ['month', 'MONTH'], + ['day', 'DAY'], + ['hours', 'HOURS'], + ['minutes', 'MINUTES'], + ['seconds', 'SECONDS'], + ['timezone', 'TIMEZONE'], + ['tz', 'TZ'], + ] as [string, string][])('%s → %s', (method, sparqlName) => { + const result = (dateNode() as any)[method](); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: sparqlName, + args: [dateProp], + }); + }); + }); + + // ------------------------------------------------------------------------- + // Logical + // ------------------------------------------------------------------------- + + describe('logical', () => { + test('and', () => { + const a = node().gt(10); + const result = a.and(node().lt(20)); + expect(result.ir.kind).toBe('logical_expr'); + expect((result.ir as any).operator).toBe('and'); + }); + + test('or', () => { + const result = node().eq(1).or(node().eq(2)); + expect((result.ir as any).operator).toBe('or'); + }); + + test('not', () => { + const result = node().gt(10).not(); + expect(result.ir.kind).toBe('not_expr'); + }); + }); + + // ------------------------------------------------------------------------- + // Null-handling + // ------------------------------------------------------------------------- + + describe('null-handling', () => { + test('isDefined', () => { + expect(node().isDefined().ir).toEqual({ + kind: 'function_expr', + name: 'BOUND', + args: [prop], + }); + }); + + test('isNotDefined', () => { + const result = node().isNotDefined(); + expect(result.ir).toEqual({ + kind: 'not_expr', + expression: {kind: 'function_expr', name: 'BOUND', args: [prop]}, + }); + }); + + test('defaultTo', () => { + const result = node().defaultTo(0); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: 'COALESCE', + args: [prop, {kind: 'literal_expr', value: 0}], + }); + }); + }); + + // ------------------------------------------------------------------------- + // RDF introspection / type casting / hash + // ------------------------------------------------------------------------- + + describe('rdf/type/hash', () => { + test.each([ + ['lang', 'LANG'], + ['datatype', 'DATATYPE'], + ['str', 'STR'], + ['iri', 'IRI'], + ['isIri', 'isIRI'], + ['isLiteral', 'isLiteral'], + ['isBlank', 'isBlank'], + ['isNumeric', 'isNumeric'], + ['md5', 'MD5'], + ['sha256', 'SHA256'], + ['sha512', 'SHA512'], + ] as [string, string][])('%s → %s', (method, sparqlName) => { + const result = (node() as any)[method](); + expect(result.ir).toEqual({ + kind: 'function_expr', + name: sparqlName, + args: [prop], + }); + }); + }); + + // ------------------------------------------------------------------------- + // Chaining + // ------------------------------------------------------------------------- + + describe('chaining', () => { + test('plus then times (left-to-right)', () => { + const result = node().plus(1).times(2); + expect(result.ir).toEqual({ + kind: 'binary_expr', + operator: '*', + left: { + kind: 'binary_expr', + operator: '+', + left: prop, + right: {kind: 'literal_expr', value: 1}, + }, + right: {kind: 'literal_expr', value: 2}, + }); + }); + + test('strlen then gt (string → numeric → boolean)', () => { + const strProp: IRExpression = {kind: 'property_expr', sourceAlias: 'a0', property: 'name'}; + const result = node(strProp).strlen().gt(5); + expect(result.ir).toEqual({ + kind: 'binary_expr', + operator: '>', + left: {kind: 'function_expr', name: 'STRLEN', args: [strProp]}, + right: {kind: 'literal_expr', value: 5}, + }); + }); + + test('immutability — each call returns new instance', () => { + const a = node(); + const b = a.plus(1); + const c = a.plus(2); + expect(a).not.toBe(b); + expect(b).not.toBe(c); + expect(a.ir).toBe(prop); + }); + }); +}); diff --git a/src/tests/expression-types.test.ts b/src/tests/expression-types.test.ts new file mode 100644 index 0000000..83fea29 --- /dev/null +++ b/src/tests/expression-types.test.ts @@ -0,0 +1,119 @@ +/** + * Type-level tests for Phase 6: Expression-aware TypeScript types for updates. + * + * These tests verify that the type system correctly accepts expression values + * and function callbacks in update operations, and rejects invalid usage. + */ +import {describe, test, expect} from '@jest/globals'; +import {Dog, Person} from '../test-helpers/query-fixtures'; +import {ExpressionNode} from '../expressions/ExpressionNode'; +import {Expr} from '../expressions/Expr'; +import {UpdateBuilder} from '../queries/UpdateBuilder'; +import type {ExpressionUpdateProxy, ExpressionUpdateResult} from '../expressions/ExpressionMethods'; +import type {UpdatePartial} from '../queries/QueryFactory'; + +// Helper: a concrete ExpressionNode for use in tests +const someExprNode = Expr.now(); + +describe('Expression-aware update types', () => { + // ------------------------------------------------------------------------- + // Sub-A: .set({prop: ExpressionNode}) compiles + // ------------------------------------------------------------------------- + + test('set() accepts ExpressionNode for literal properties', () => { + // This should compile — ExpressionNode is now part of the literal union + const builder = UpdateBuilder.from(Dog).set({guardDogLevel: someExprNode}); + expect(builder).toBeDefined(); + }); + + test('set() accepts ExpressionNode for string properties', () => { + const builder = UpdateBuilder.from(Person).set({name: someExprNode}); + expect(builder).toBeDefined(); + }); + + test('set() accepts ExpressionNode for date properties', () => { + const builder = UpdateBuilder.from(Person).set({birthDate: Expr.now()}); + expect(builder).toBeDefined(); + }); + + test('set() still accepts plain literal values', () => { + const builder = UpdateBuilder.from(Person).set({name: 'Alice'}); + expect(builder).toBeDefined(); + }); + + // ------------------------------------------------------------------------- + // Sub-C: .set(p => ({...})) function callback compiles + // ------------------------------------------------------------------------- + + test('set() accepts function callback with expression methods', () => { + const builder = UpdateBuilder.from(Dog).set((p) => ({ + guardDogLevel: p.guardDogLevel.plus(1), + })); + expect(builder).toBeDefined(); + }); + + test('Shape.update() accepts function callback', () => { + const builder = Dog.update((p) => ({ + guardDogLevel: p.guardDogLevel.plus(1), + })); + expect(builder).toBeDefined(); + }); + + test('Shape.update() still accepts plain object', () => { + const builder = Person.update({name: 'Bob'}); + expect(builder).toBeDefined(); + }); + + // ------------------------------------------------------------------------- + // Negative type tests (compile-time only) + // ------------------------------------------------------------------------- + + test('type-level: string property cannot use .plus()', () => { + type Bad = ExpressionUpdateProxy['name'] extends {plus: any} ? true : false; + const check: Bad = false; + expect(check).toBe(false); + }); + + test('type-level: numeric property has .plus()', () => { + type Good = ExpressionUpdateProxy['guardDogLevel'] extends {plus: any} ? true : false; + const check: Good = true; + expect(check).toBe(true); + }); + + test('type-level: string property has .strlen()', () => { + type Good = ExpressionUpdateProxy['name'] extends {strlen: any} ? true : false; + const check: Good = true; + expect(check).toBe(true); + }); + + test('type-level: date property has .year()', () => { + type Good = ExpressionUpdateProxy['birthDate'] extends {year: any} ? true : false; + const check: Good = true; + expect(check).toBe(true); + }); + + test('type-level: boolean property has .and()', () => { + type Good = ExpressionUpdateProxy['isRealPerson'] extends {and: any} ? true : false; + const check: Good = true; + expect(check).toBe(true); + }); + + test('type-level: ShapeSet properties are never in ExpressionUpdateProxy', () => { + type Check = ExpressionUpdateProxy['friends']; + const check: Check = undefined as never; + // The type should be `never` for ShapeSet properties + }); + + test('type-level: ExpressionUpdateResult allows ExpressionNode or literal', () => { + // This should compile — ExpressionUpdateResult allows both + const result: ExpressionUpdateResult = { + name: someExprNode, + }; + expect(result).toBeDefined(); + + const result2: ExpressionUpdateResult = { + name: 'plain string', + }; + expect(result2).toBeDefined(); + }); +}); diff --git a/src/tests/ir-select-golden.test.ts b/src/tests/ir-select-golden.test.ts index f488a49..39637b2 100644 --- a/src/tests/ir-select-golden.test.ts +++ b/src/tests/ir-select-golden.test.ts @@ -690,4 +690,52 @@ describe("IR pipeline behavior", () => { projectedProperties.filter((prop) => prop.endsWith("/name")).length ).toBeGreaterThanOrEqual(2); }); + + // --- Computed expression tests --- + + test("exprStrlen: expression select produces function_expr projection", async () => { + const ir = await captureIR(() => queryFactories.exprStrlen()); + expect(ir.kind).toBe("select"); + expect(ir.projection.length).toBe(1); + expect(ir.projection[0].expression.kind).toBe("function_expr"); + expect((ir.projection[0].expression as any).name).toBe("STRLEN"); + // The argument should be a property_expr for name + const args = (ir.projection[0].expression as any).args; + expect(args.length).toBe(1); + expect(args[0].kind).toBe("property_expr"); + expect(args[0].property).toContain("name"); + }); + + test("exprCustomKey: expression with custom result key", async () => { + const ir = await captureIR(() => queryFactories.exprCustomKey()); + expect(ir.kind).toBe("select"); + expect(ir.projection.length).toBe(1); + expect(ir.projection[0].expression.kind).toBe("function_expr"); + expect(ir.resultMap?.length).toBe(1); + expect(ir.resultMap?.[0].key).toBe("nameLen"); + }); + + test("exprNestedPath: expression on nested property creates traversal", async () => { + const ir = await captureIR(() => queryFactories.exprNestedPath()); + expect(ir.kind).toBe("select"); + expect(ir.projection.length).toBe(1); + expect(ir.projection[0].expression.kind).toBe("function_expr"); + expect((ir.projection[0].expression as any).name).toBe("UCASE"); + // Should have a traverse pattern for bestFriend + expect( + ir.patterns.some( + (p) => p.kind === "traverse" && p.property.endsWith("/bestFriend") + ) + ).toBe(true); + }); + + test("exprMultiple: mix of plain path and expression select", async () => { + const ir = await captureIR(() => queryFactories.exprMultiple()); + expect(ir.kind).toBe("select"); + expect(ir.projection.length).toBe(2); + // First is a plain property_expr + expect(ir.projection[0].expression.kind).toBe("property_expr"); + // Second is a function_expr (strlen) + expect(ir.projection[1].expression.kind).toBe("function_expr"); + }); }); diff --git a/src/tests/query-builder.test.ts b/src/tests/query-builder.test.ts index a8dd646..197a3ac 100644 --- a/src/tests/query-builder.test.ts +++ b/src/tests/query-builder.test.ts @@ -564,6 +564,67 @@ describe('Shape.select().for() / .forAll() chaining', () => { }); }); +// ============================================================================= +// Phase 9: Expression equivalence tests — QueryBuilder vs DSL +// ============================================================================= + +describe('QueryBuilder — expression equivalence with DSL', () => { + test('SELECT expression projection equivalence', async () => { + const dslIR = await captureDslIR(() => + Person.select((p) => ({nameLen: (p.name as any).strlen()})), + ); + const builderIR = QueryBuilder.from(Person) + .select((p) => ({nameLen: (p.name as any).strlen()})) + .build(); + expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); + }); + + test('WHERE expression filter equivalence', async () => { + const dslIR = await captureDslIR(() => + Person.select((p) => ({name: p.name})).where(((p: any) => p.name.strlen().gt(5)) as any), + ); + const builderIR = QueryBuilder.from(Person) + .select((p) => ({name: p.name})) + .where(((p: any) => p.name.strlen().gt(5)) as any) + .build(); + expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); + }); + + test('mixed expression + evaluation WHERE equivalence', async () => { + const dslIR = await captureDslIR(() => + Person.select((p) => ({name: p.name})).where((p) => + p.name.equals('Bob').and((p.name as any).strlen().gt(3)), + ), + ); + const builderIR = QueryBuilder.from(Person) + .select((p) => ({name: p.name})) + .where((p) => p.name.equals('Bob').and((p.name as any).strlen().gt(3))) + .build(); + expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); + }); + + test('expression projection + expression WHERE combined equivalence', async () => { + const dslIR = await captureDslIR(() => + Person.select((p) => ({ + name: p.name, + nameLen: (p.name as any).strlen(), + })).where(((p: any) => p.name.strlen().gt(2)) as any), + ); + const builderIR = QueryBuilder.from(Person) + .select((p) => ({ + name: p.name, + nameLen: (p.name as any).strlen(), + })) + .where(((p: any) => p.name.strlen().gt(2)) as any) + .build(); + expect(sanitize(builderIR)).toEqual(sanitize(dslIR)); + }); +}); + +// ============================================================================= +// .for() and .forAll() chaining tests +// ============================================================================= + describe('Person.update(data).for(id) chaining', () => { test('Person.update(data).for(id) produces correct IR', () => { const ir = Person.update({hobby: 'Chess'}).for(entity('p1')).build(); diff --git a/src/tests/sparql-mutation-golden.test.ts b/src/tests/sparql-mutation-golden.test.ts index 88b1bbe..60fc14b 100644 --- a/src/tests/sparql-mutation-golden.test.ts +++ b/src/tests/sparql-mutation-golden.test.ts @@ -465,3 +465,98 @@ describe('SPARQL golden — builder equivalence', () => { expect(deleteWhereToSparql(irSugar)).toBe(deleteWhereToSparql(irBuilder)); }); }); + +// --------------------------------------------------------------------------- +// Expression-based mutation tests +// --------------------------------------------------------------------------- + +describe('SPARQL golden — expression mutations', () => { + test('updateExprCallback: functional callback with arithmetic expression', async () => { + const ir = (await captureQuery(queryFactories.updateExprCallback)) as IRUpdateMutation; + const sparql = updateToSparql(ir); + // Should contain BIND for computed value + expect(sparql).toContain('BIND'); + // Should reference old value and computed value + expect(sparql).toContain('old_guardDogLevel'); + expect(sparql).toContain('computed_guardDogLevel'); + // Should contain the arithmetic expression + expect(sparql).toContain('+'); + // Should have DELETE and INSERT + expect(sparql).toContain('DELETE'); + expect(sparql).toContain('INSERT'); + }); + + test('updateExprNow: expression value (Expr.now()) in update', async () => { + const ir = (await captureQuery(queryFactories.updateExprNow)) as IRUpdateMutation; + const sparql = updateToSparql(ir); + // Should contain BIND with NOW() + expect(sparql).toContain('BIND'); + expect(sparql).toContain('NOW()'); + }); + + test('updateExprTraversal: multi-segment ref produces traversal OPTIONAL', async () => { + const ir = (await captureQuery(queryFactories.updateExprTraversal)) as IRUpdateMutation; + const sparql = updateToSparql(ir); + + // Should have traversal pattern in IR + expect(ir.traversalPatterns).toBeDefined(); + expect(ir.traversalPatterns!.length).toBe(1); + expect(ir.traversalPatterns![0].from).toBe('__mutation_subject__'); + expect(ir.traversalPatterns![0].to).toBe('__trav_0__'); + + // SPARQL should contain OPTIONAL for the traversal + expect(sparql).toContain('OPTIONAL'); + expect(sparql).toContain(`<${P}/bestFriend>`); + // Should have BIND for computed value + expect(sparql).toContain('BIND'); + expect(sparql).toContain('UCASE'); + // The BIND expression should reference the traversal variable's property + expect(sparql).toContain('__trav_0__'); + }); + + test('updateExprSharedTraversal: shared traversal produces only one OPTIONAL', async () => { + const ir = (await captureQuery(queryFactories.updateExprSharedTraversal)) as IRUpdateMutation; + const sparql = updateToSparql(ir); + + // Should have exactly one traversal pattern (deduped) + expect(ir.traversalPatterns).toBeDefined(); + expect(ir.traversalPatterns!.length).toBe(1); + expect(ir.traversalPatterns![0].from).toBe('__mutation_subject__'); + expect(ir.traversalPatterns![0].to).toBe('__trav_0__'); + + // SPARQL should contain OPTIONAL for traversal + BIND for both fields + expect(sparql).toContain('OPTIONAL'); + expect(sparql).toContain(`<${P}/bestFriend>`); + expect(sparql).toContain('UCASE'); + expect(sparql).toContain('LCASE'); + // Both BINDs should reference the same traversal variable + expect(sparql).toContain('__trav_0__'); + // Only one OPTIONAL for bestFriend traversal + const optionalMatches = sparql.match(/OPTIONAL/g); + // Count traversal OPTIONAL (for bestFriend) + old value OPTIONALs (for name, hobby, and their expression-referenced properties) + expect(optionalMatches).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Expression-based WHERE on mutations (Phase 8) +// --------------------------------------------------------------------------- + +describe('SPARQL golden — expression WHERE mutations', () => { + test('whereExprUpdateBuilder — expression WHERE on update', async () => { + const ir = (await captureQuery(queryFactories.whereExprUpdateBuilder)) as IRUpdateWhereMutation; + const sparql = updateWhereToSparql(ir); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('DELETE'); + expect(sparql).toContain('INSERT'); + }); + + test('whereExprDeleteBuilder — expression WHERE on delete', async () => { + const ir = (await captureQuery(queryFactories.whereExprDeleteBuilder)) as IRDeleteWhereMutation; + const sparql = deleteWhereToSparql(ir); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('DELETE'); + }); +}); diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index f245f21..b774a00 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -1182,3 +1182,159 @@ WHERE { }`); }); }); + +// --------------------------------------------------------------------------- +// Computed expressions in projections +// --------------------------------------------------------------------------- + +describe('SPARQL golden — computed expressions', () => { + test('exprStrlen', async () => { + const sparql = await goldenSelect(queryFactories.exprStrlen); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/name>`); + }); + + test('exprCustomKey', async () => { + const sparql = await goldenSelect(queryFactories.exprCustomKey); + expect(sparql).toContain('STRLEN'); + }); + + test('exprNestedPath', async () => { + const sparql = await goldenSelect(queryFactories.exprNestedPath); + expect(sparql).toContain('UCASE'); + expect(sparql).toContain(`<${P}/bestFriend>`); + expect(sparql).toContain(`<${P}/name>`); + }); + + test('exprMultiple', async () => { + const sparql = await goldenSelect(queryFactories.exprMultiple); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/name>`); + }); +}); + +// --------------------------------------------------------------------------- +// Expression-based WHERE filters +// --------------------------------------------------------------------------- + +describe('SPARQL golden — expression WHERE filters', () => { + test('whereExprStrlen — string expression WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprStrlen); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('> "5"'); + }); + + test('whereExprArithmetic — numeric expression WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprArithmetic); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('+'); + expect(sparql).toContain('< "100"'); + }); + + test('whereExprAndChain — two expressions AND\'d', async () => { + const sparql = await goldenSelect(queryFactories.whereExprAndChain); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('&&'); + expect(sparql).toContain('STRLEN'); + }); + + test('whereExprMixed — Evaluation AND ExpressionNode', async () => { + const sparql = await goldenSelect(queryFactories.whereExprMixed); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('"Bob"'); + }); + + test('whereExprNestedPath — traversal in WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprNestedPath); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/bestFriend>`); + }); + + test('whereExprWithProjection — expression in both SELECT and WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprWithProjection); + // Expression projection may be inlined in SELECT or use BIND + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('FILTER'); + }); +}); + +// --------------------------------------------------------------------------- +// Computed expressions +// --------------------------------------------------------------------------- + +describe('SPARQL golden — computed expressions', () => { + test('exprStrlen', async () => { + const sparql = await goldenSelect(queryFactories.exprStrlen); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/name>`); + }); + + test('exprCustomKey', async () => { + const sparql = await goldenSelect(queryFactories.exprCustomKey); + expect(sparql).toContain('STRLEN'); + }); + + test('exprNestedPath', async () => { + const sparql = await goldenSelect(queryFactories.exprNestedPath); + expect(sparql).toContain('UCASE'); + expect(sparql).toContain(`<${P}/bestFriend>`); + expect(sparql).toContain(`<${P}/name>`); + }); + + test('exprMultiple', async () => { + const sparql = await goldenSelect(queryFactories.exprMultiple); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/name>`); + }); +}); + +// --------------------------------------------------------------------------- +// Expression-based WHERE filters +// --------------------------------------------------------------------------- + +describe('SPARQL golden — expression WHERE filters', () => { + test('whereExprStrlen — string expression WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprStrlen); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('> "5"'); + }); + + test('whereExprArithmetic — numeric expression WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprArithmetic); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('+'); + expect(sparql).toContain('< "100"'); + }); + + test('whereExprAndChain — two expressions AND\'d', async () => { + const sparql = await goldenSelect(queryFactories.whereExprAndChain); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('&&'); + expect(sparql).toContain('STRLEN'); + }); + + test('whereExprMixed — Evaluation AND ExpressionNode', async () => { + const sparql = await goldenSelect(queryFactories.whereExprMixed); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('"Bob"'); + }); + + test('whereExprNestedPath — traversal in WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprNestedPath); + expect(sparql).toContain('FILTER'); + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain(`<${P}/bestFriend>`); + }); + + test('whereExprWithProjection — expression in both SELECT and WHERE', async () => { + const sparql = await goldenSelect(queryFactories.whereExprWithProjection); + // Expression projection may be inlined in SELECT or use BIND + expect(sparql).toContain('STRLEN'); + expect(sparql).toContain('FILTER'); + }); +});