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/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/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/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/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'); + }); +});