From ae522cf1c5ae9e9ee6c3b83f81cd2f8ce925a6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 16 Feb 2026 14:16:57 +0800 Subject: [PATCH 1/3] feat(core): implement selectAll() with inherited dedupe, stricter overrides, and docs/test updates - add Shape.selectAll() and nested selectAll() on QueryShape/QueryShapeSet - add NodeShape.getUniquePropertyShapes() and use it to dedupe inherited property labels (subclass overrides win once) - tighten selectAll typing by excluding base Shape keys and align top-level/nested inference - enforce registration-time override tightening for minCount/maxCount/nodeKind - preserve explicit minCount/maxCount=0 in createPropertyShape - expand runtime/metadata/type tests for selectAll, override constraints, and zero-count regression - update README with better examples and overall flow, and CRUD return semantics --- AGENTS.md | 2 +- README.md | 191 ++++++++++++++++---- docs/002-select-all-property-shapes.md | 72 ++++++++ src/queries/SelectQuery.ts | 42 +++++ src/shapes/SHACL.ts | 118 +++++++++++- src/shapes/Shape.ts | 42 +++++ src/test-helpers/query-fixtures.ts | 29 +++ src/tests/metadata.test.ts | 241 ++++++++++++++++++++++++- src/tests/query.test.ts | 62 ++++++- src/tests/query.types.test.ts | 28 +++ 10 files changed, 783 insertions(+), 44 deletions(-) create mode 100644 docs/002-select-all-property-shapes.md diff --git a/AGENTS.md b/AGENTS.md index 45fe0af..413ee40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Any task that changes package code requires a plan. Simple checks, info gatherin ### Creating a plan 1. **Inspect the relevant code thoroughly** before writing anything. Read the source files, tests, and existing docs that relate to the task. -2. Create a new doc in `docs/` with the next 3-digit prefix (e.g. `005-add-filter-support.md`). Start with YAML frontmatter. +2. Create a new doc in `docs/` with the next 3-digit prefix (e.g. `005-add-filter-support.md`) **for new feature/PR scope**. For follow-ups in the same thread/PR scope, update the existing plan doc. Start with YAML frontmatter. 3. Write the plan with these sections: - **Key considerations and choices** — tradeoffs, open questions, alternatives - **Potential problems** — what could go wrong, edge cases diff --git a/README.md b/README.md index c98a94c..3fd2e3b 100644 --- a/README.md +++ b/README.md @@ -85,41 +85,35 @@ export class Person extends Shape { ## Queries: Create, Select, Update, Delete Queries are expressed with the same Shape classes and compile to a query object that a store executes. +Use this section as a quick start. Detailed query variations are documented in `Query examples` below. +A few quick examples: + +**1) Select one field for all matching nodes** ```typescript -/* Result: Array<{id: string; name: string}> */ const names = await Person.select((p) => p.name); +/* names: {id: string; name: string}[] */ +``` -const myNode = {id: 'https://my.app/node1'}; -/* Result: {id: string; name: string} | null */ -const person = await Person.select(myNode, (p) => p.name); -const missing = await Person.select({id: 'https://my.app/missing'}, (p) => p.name); // null - -/* Result: {id: string} & UpdatePartial */ -const created = await Person.create({ - name: 'Alice', - knows: [{id: 'https://my.app/node2'}], -}); +**2) Select all decorated fields of nested related nodes** +```typescript +const allFriends = await Person.select((p) => p.knows.selectAll()); +/* allFriends: { + id?: string; + knows: { + id?: string; + ...all decorated Person fields... + }[] + }[] */ +``` +**3) Apply a simple mutation** +```typescript +const myNode = {id: 'https://my.app/node1'}; const updated = await Person.update(myNode, { name: 'Alicia', }); - -// Overwrite a multi-value property -const overwriteFriends = await Person.update(myNode, { - knows: [{id: 'https://my.app/node2'}], -}); - -// Add/remove items in a multi-value property -const addRemoveFriends = await Person.update(myNode, { - knows: { - add: [{id: 'https://my.app/node3'}], - remove: [{id: 'https://my.app/node2'}], - }, -}); - -/* Result: {deleted: Array<{id: string}>, count: number} */ -await Person.delete(myNode); +/* updated: {id: string} & UpdatePartial */ ``` ## Storage configuration @@ -172,10 +166,13 @@ Result types are inferred from your Shape definitions and the selected paths. Ex #### Basic selection ```typescript -/* Result: Array<{id: string; name: string}> */ +/* names: {id: string; name: string}[] */ const names = await Person.select((p) => p.name); -/* Result: Array<{id: string; knows: Array<{id: string}>}> */ +/* friends: { + id: string; + knows: { id: string }[] +}[] */ const friends = await Person.select((p) => p.knows); const dates = await Person.select((p) => [p.birthDate, p.name]); @@ -202,6 +199,12 @@ const deep = await Person.select((p) => p.knows.bestFriend.name); const detailed = await Person.select((p) => p.knows.select((f) => f.name), ); + +const allPeople = await Person.selectAll(); + +const detailedAll = await Person.select((p) => + p.knows.selectAll(), +); ``` #### Where + equals @@ -261,7 +264,9 @@ const custom = await Person.select((p) => ({ })); ``` -#### Query As (type casting) +#### 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: ```typescript const guards = await Person.select((p) => p.pets.as(Dog).guardDogLevel); ``` @@ -293,29 +298,131 @@ const preloaded = await Person.select((p) => [ ]); ``` -#### Create / Update / Delete +#### Create + ```typescript /* Result: {id: string} & UpdatePartial */ const created = await Person.create({name: 'Alice'}); +``` +Where UpdatePartial reflects the created properties. +#### Update + +Update will patch any property that you send as payload and leave the rest untouched. +```typescript +/* Result: {id: string} & UpdatePartial */ const updated = await Person.update({id: 'https://my.app/node1'}, {name: 'Alicia'}); +``` +Returns: +```json +{ + id:"https://my.app/node1", + name:"Alicia" +} +``` + +**Updating multi-value properties** +When updating a property that holds multiple values (one that returns an array in the results), you can either overwrite all the values with a new explicit array of values, or delete from/add to the current values. -// Overwrite a multi-value property -const overwriteFriends = await Person.update({id: 'https://my.app/node1'}, { - knows: [{id: 'https://my.app/node2'}], +To overwrite all values: +```typescript +// Overwrite the full set of "knows" values. +const overwriteFriends = await Person.update({id: 'https://my.app/person1'}, { + knows: [{id: 'https://my.app/person2'}], }); +``` +The result will contain an object with `updatedTo`, to indicate that previous values were overwritten to this new set of values: +```json +{ + id: "https://my.app/person1", + knows: { + updatedTo: [{id:"https://my.app/person2"}], + } +} +``` -// Add/remove items in a multi-value property -const addRemoveFriends = await Person.update({id: 'https://my.app/node1'}, { +To make incremental changes to the current set of values you can provide an object with `add` and/or `remove` keys: +```typescript +// Add one value and remove one value without replacing the whole set. +const addRemoveFriends = await Person.update({id: 'https://my.app/person1'}, { knows: { - add: [{id: 'https://my.app/node3'}], - remove: [{id: 'https://my.app/node2'}], + add: [{id: 'https://my.app/person2'}], + remove: [{id: 'https://my.app/person3'}], }, }); +``` +This returns an object with the added and removed items +```json +{ + id: "https://my.app/person1", + knows: { + added?: [{id:"https://my.app/person2"}, + removed?: [{id:"https://my.app/person3"}], + } +} +``` + + +#### Delete +To delete a node entirely: + +```typescript +/* Result: {deleted: Array<{id: string}>, count: number} */ +const deleted = await Person.delete({id: 'https://my.app/node1'}); +``` +Returns +```json +{ + deleted:[ + {id:"https://my.app/node1"} + ], + count:1 +} +``` + +To delete multiple nodes pass an array: + +```typescript +/* Result: {deleted: Array<{id: string}>, count: number} */ +const deleted = await Person.delete([{id: 'https://my.app/node1'},{id: 'https://my.app/node2'}]); +``` -await Person.delete({id: 'https://my.app/node1'}); + +## Extending shapes + +Shape classes can extend other shape classes. Subclasses inherit property shapes from their superclasses and may override them. +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; + + // Override inherited "name" with stricter constraints (still maxCount: 1) + @literalProperty({path: name, required: true, minLength: 2, maxCount: 1}) + declare name: string; + + @literalProperty({path: employeeId, required: true, maxCount: 1}) + declare employeeId: string; +} ``` +Override behavior: + +- `NodeShape.getUniquePropertyShapes()` returns one property shape per label, with subclass overrides taking precedence. +- Overrides must be tighten-only for `minCount`, `maxCount`, and `nodeKind` (widening is rejected at registration time). +- If an override omits `minCount`, `maxCount`, or `nodeKind`, inherited values are kept. +- Current scope: compatibility checks for `datatype`, `class`, and `pattern` are not enforced yet. + ## TODO - Allow `preloadFor` to accept another query (not just a component). @@ -323,4 +430,10 @@ await Person.delete({id: 'https://my.app/node1'}); ## Changelog +- Added `Shape.selectAll()` to select all decorated property shapes of a shape in one call. +- Updated `selectAll()` to deduplicate inherited overridden property labels so subclass overrides are selected once. +- Added `NodeShape.getUniquePropertyShapes()` to expose deduplicated inherited property shapes directly on the shape metadata API. +- Simplified `NodeShape.getUniquePropertyShapes()` to always resolve across the inheritance chain. +- Added registration-time override guards so subclass overrides cannot widen `minCount`/`maxCount`/`nodeKind` constraints. + See [CHANGELOG.md](./CHANGELOG.md). diff --git a/docs/002-select-all-property-shapes.md b/docs/002-select-all-property-shapes.md new file mode 100644 index 0000000..6f57af5 --- /dev/null +++ b/docs/002-select-all-property-shapes.md @@ -0,0 +1,72 @@ +--- +summary: Implement and refine selectAll across shape, sub-query, inheritance dedupe, and typing behavior +packages: [core] +--- + +## Key considerations and choices + +- `selectAll` was kept explicit (`Shape.selectAll()` and nested `p.friends.selectAll()` / `p.bestFriend.selectAll()`) and implemented by reusing existing `select(...)` internals. +- Inherited properties are included, with label-based dedupe so subclass overrides win and appear once. +- Unique-property-shape behavior was added to metadata as `NodeShape.getUniquePropertyShapes(): PropertyShape[]` so callers can inspect selected metadata directly. +- `getPropertyShapes(true)` semantics were preserved; dedupe behavior is isolated to `getUniquePropertyShapes()`. +- Nested `selectAll` type inference was improved enough to expose common nested fields in compile tests without changing runtime query behavior. +- `selectAll` key filtering was tightened to exclude base `Shape` instance keys from inferred property unions. +- Override validation was finalized as tighten-only for `minCount`, `maxCount`, and `nodeKind`, with omitted override fields inheriting base constraints silently. +- Property-shape count parsing was fixed to preserve explicit `0` values for `minCount`/`maxCount`. + +## Problems addressed + +- Inheritance-chain regressions were avoided by not changing `getPropertyShapes(true)` and adding a separate dedupe API. +- Override precedence is stable: dedupe order follows subclass-first traversal so overridden labels resolve to subclass property shapes. +- Over-broad override constraints are now blocked at registration time with explicit errors for lowered `minCount`, raised `maxCount`, and widened `nodeKind`. +- Mixed override cases (partial explicit override + inherited fields) are covered and validated in metadata tests. + +## Remaining limits + +- Compatibility checks for override fields like `datatype`, `class`, and `pattern` are not enforced yet. +- `selectAll` key inference is stricter than before but may still include non-decorated subclass members in some cases due to current `keyof`-based inference bounds. +- Shapes with no decorated properties still produce empty select projections (current intended behavior). +- Decorator-level contradictions like `required: true` with `minCount: 0` still follow existing precedence (`required` wins). + +## Phases + +1. **Initial `selectAll` implementation** ✅ + - Add `Shape.selectAll(...)` and reuse `select(...)` internals. + - Validate with query generation tests. +2. **Nested sub-query support** ✅ + - Add `selectAll()` on `QueryShapeSet` and `QueryShape`. + - Validate query object shape for `p.friends.selectAll()` and `p.bestFriend.selectAll()`. +3. **Inherited override dedupe** ✅ + - Ensure overridden labels appear once and subclass override wins. + - Add regression fixture/tests with subclass overrides. +4. **Move unique-property API onto `NodeShape`** ✅ + - Add `NodeShape.getUniquePropertyShapes()` and migrate `selectAll` call sites. + - Keep `getPropertyShapes(true)` semantics unchanged. +5. **Simplify API + improve nested typing** ✅ + - Make `getUniquePropertyShapes()` always use inheritance (remove boolean flag). + - Improve nested `selectAll` typing and add type tests for nested fields. +6. **Docs/changelog consolidation** ✅ + - Update README examples, type-inference notes, and changelog entries. + - Consolidate all selectAll planning history into this document. +7. **Registration-time override guard (tighten-only)** ✅ + - Enforce override checks for minCount/maxCount/nodeKind in property registration. + - Keep omitted override fields inheriting silently from super property shape. + - Add metadata tests for allowed and rejected override cases, including positive tighten cases and mixed inheritance. +8. **Docs cleanup + key-filter tightening follow-up** ✅ + - Trim intro `README` `selectAll` examples to one concise, shape-consistent example. + - Tighten `selectAll` key filtering to exclude base `Shape` instance keys from inferred property unions. +9. **Count parsing regression fix (`0` values)** ✅ + - Treat `minCount: 0` and `maxCount: 0` as explicit values in `createPropertyShape`. + - Add metadata tests for explicit zero-count persistence and override guarding from `0 -> 1`. + +## Implementation summary + +- Added top-level `Shape.selectAll(...)` and nested `selectAll()` support on `QueryShape` and `QueryShapeSet`. +- Added `NodeShape.getUniquePropertyShapes()` for deduped inherited property shape resolution with subclass-first precedence. +- Updated all `selectAll` paths to derive property labels from `getUniquePropertyShapes()`. +- Added runtime query tests for top-level and nested `selectAll`, plus dedupe regression coverage using subclass overrides. +- Added compile-only type tests for top-level and nested `selectAll` field inference. +- Tightened `selectAll` key filtering to exclude base `Shape` instance keys from inferred result unions. +- Added registration-time tighten-only override guards (`minCount`, `maxCount`, `nodeKind`) with explicit metadata test coverage for reject and allow cases. +- Fixed `createPropertyShape` count parsing to preserve explicit `0` values for `minCount`/`maxCount`. +- Updated `README.md` examples/changelog and documented current override-compatibility scope. diff --git a/src/queries/SelectQuery.ts b/src/queries/SelectQuery.ts index c25ab19..da8efd8 100644 --- a/src/queries/SelectQuery.ts +++ b/src/queries/SelectQuery.ts @@ -144,6 +144,18 @@ export type QueryShapeProps< [P in keyof T]: ToQueryBuilderObject, P>; }; +export type SelectAllQueryResponse = Array< + QueryShapeProps[Exclude< + Extract< + { + [K in keyof T]-?: T[K] extends (...args: any[]) => any ? never : K; + }[keyof T], + string + >, + Extract + >] +>; + /** * This type states that the ShapeSet has access to the same methods as the shape of all the items in the set * (this is enabled with the QueryShapeSet.proxifyShapeSet method) @@ -1207,6 +1219,20 @@ export class QueryShapeSet< return subQuery as any; } + selectAll(): SelectQueryFactory< + S, + SelectAllQueryResponse, + QueryShapeSet + > { + let leastSpecificShape = this.getOriginalValue().getLeastSpecificShape(); + const propertyLabels = leastSpecificShape.shape + .getUniquePropertyShapes() + .map((propertyShape) => propertyShape.label); + return this.select((shape) => + propertyLabels.map((label) => (shape as any)[label]), + ); + } + some(validation: WhereClause): SetEvaluation { return this.someOrEvery(validation, WhereMethods.SOME); } @@ -1365,6 +1391,22 @@ export class QueryShape< return subQuery as any; } + selectAll(): SelectQueryFactory< + S, + SelectAllQueryResponse, + QueryShape + > { + let leastSpecificShape = getShapeClass( + (this.getOriginalValue() as Shape).nodeShape.id, + ); + const propertyLabels = leastSpecificShape.shape + .getUniquePropertyShapes() + .map((propertyShape) => propertyShape.label); + return this.select((shape) => + propertyLabels.map((label) => (shape as any)[label]), + ); + } + // count(countable: QueryBuilderObject, resultKey?: string): SetSize { // return new SetSize(this, countable, resultKey); // // return this._count; diff --git a/src/shapes/SHACL.ts b/src/shapes/SHACL.ts index d182a48..766b581 100644 --- a/src/shapes/SHACL.ts +++ b/src/shapes/SHACL.ts @@ -198,6 +198,10 @@ export interface PropertyShapeConfig { sortBy?: PropertyPathInputList; } +const EXPLICIT_NODE_KIND_SYMBOL = Symbol('explicitNodeKind'); +const EXPLICIT_MIN_COUNT_SYMBOL = Symbol('explicitMinCount'); +const EXPLICIT_MAX_COUNT_SYMBOL = Symbol('explicitMaxCount'); + export interface ParameterConfig { optional?: number; } @@ -255,6 +259,18 @@ export class NodeShape extends Shape { return res; } + getUniquePropertyShapes(): PropertyShape[] { + const uniquePropertyShapes: PropertyShape[] = []; + const seen = new Set(); + this.getPropertyShapes(true).forEach((propertyShape) => { + if (!seen.has(propertyShape.label)) { + seen.add(propertyShape.label); + uniquePropertyShapes.push(propertyShape); + } + }); + return uniquePropertyShapes; + } + getPropertyShape( label: string, checkSubShapes: boolean = true, @@ -423,7 +439,20 @@ export function registerPropertyShape( shape: NodeShape, propertyShape: PropertyShape, ) { + const inherited = shape.getPropertyShape(propertyShape.label, true); const existing = shape.getPropertyShape(propertyShape.label, false); + if (!existing && inherited) { + if (!(propertyShape as any)[EXPLICIT_MIN_COUNT_SYMBOL]) { + propertyShape.minCount = inherited.minCount; + } + if (!(propertyShape as any)[EXPLICIT_MAX_COUNT_SYMBOL]) { + propertyShape.maxCount = inherited.maxCount; + } + if (!(propertyShape as any)[EXPLICIT_NODE_KIND_SYMBOL]) { + propertyShape.nodeKind = inherited.nodeKind; + } + validateOverrideTightening(shape, inherited, propertyShape); + } if (existing) { Object.assign(existing, propertyShape); return existing; @@ -433,6 +462,85 @@ export function registerPropertyShape( return propertyShape; } +const ATOMIC_NODE_KINDS = [ + shacl.BlankNode.id, + shacl.IRI.id, + shacl.Literal.id, +]; + +const nodeKindToAtomics = (nodeKind?: NodeReferenceValue): Set => { + if (!nodeKind?.id) { + return new Set(); + } + switch (nodeKind.id) { + case shacl.BlankNode.id: + case shacl.IRI.id: + case shacl.Literal.id: + return new Set([nodeKind.id]); + case shacl.BlankNodeOrIRI.id: + return new Set([shacl.BlankNode.id, shacl.IRI.id]); + case shacl.IRIOrLiteral.id: + return new Set([shacl.IRI.id, shacl.Literal.id]); + case shacl.BlankNodeOrLiteral.id: + return new Set([shacl.BlankNode.id, shacl.Literal.id]); + default: + return new Set(ATOMIC_NODE_KINDS); + } +}; + +const throwOverrideError = ( + shape: NodeShape, + propertyShape: PropertyShape, + message: string, +) => { + throw new Error( + `Invalid override for ${shape.label}.${propertyShape.label}: ${message}`, + ); +}; + +const validateOverrideTightening = ( + shape: NodeShape, + base: PropertyShape, + override: PropertyShape, +) => { + if ( + typeof base.minCount === 'number' && + typeof override.minCount === 'number' && + override.minCount < base.minCount + ) { + throwOverrideError( + shape, + override, + `minCount cannot be lowered (${base.minCount} -> ${override.minCount}).`, + ); + } + + if ( + typeof base.maxCount === 'number' && + typeof override.maxCount === 'number' && + override.maxCount > base.maxCount + ) { + throwOverrideError( + shape, + override, + `maxCount cannot be increased (${base.maxCount} -> ${override.maxCount}).`, + ); + } + + if (base.nodeKind && override.nodeKind) { + const baseKinds = nodeKindToAtomics(base.nodeKind); + const overrideKinds = nodeKindToAtomics(override.nodeKind); + const widensNodeKind = [...overrideKinds].some((kind) => !baseKinds.has(kind)); + if (widensNodeKind) { + throwOverrideError( + shape, + override, + `nodeKind cannot be widened (${base.nodeKind.id} -> ${override.nodeKind.id}).`, + ); + } + } +}; + export function createPropertyShape< Config extends LiteralPropertyShapeConfig | ObjectPropertyShapeConfig, >( @@ -454,13 +562,17 @@ export function createPropertyShape< if (config.required) { propertyShape.minCount = 1; - } else if (config.minCount) { + } else if (config.minCount !== undefined) { propertyShape.minCount = config.minCount; } + (propertyShape as any)[EXPLICIT_MIN_COUNT_SYMBOL] = + config.required === true || config.minCount !== undefined; - if (config.maxCount) { + if (config.maxCount !== undefined) { propertyShape.maxCount = config.maxCount; } + (propertyShape as any)[EXPLICIT_MAX_COUNT_SYMBOL] = + config.maxCount !== undefined; if ((config as LiteralPropertyShapeConfig).datatype) { propertyShape.datatype = toPlainNodeRef( (config as LiteralPropertyShapeConfig).datatype, @@ -493,6 +605,8 @@ export function createPropertyShape< } propertyShape.nodeKind = normalizeNodeKind(config.nodeKind, defaultNodeKind); + (propertyShape as any)[EXPLICIT_NODE_KIND_SYMBOL] = + config.nodeKind !== undefined; if (shapeClass) { onShapeSetup(shapeClass, (shape: NodeShape) => { diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index ff7e544..ccbfb65 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -14,6 +14,7 @@ import { QueryBuildFn, QueryResponseToResultType, QueryShape, + SelectAllQueryResponse, SelectQueryFactory, } from '../queries/SelectQuery.js'; import type {IQueryParser} from '../interfaces/IQueryParser.js'; @@ -205,6 +206,47 @@ export abstract class Shape { return query.patchResultPromise(p); } + /** + * Select all decorated properties of this shape. + * Returns a single result if a single subject is provided, or an array of results if no subject is provided. + */ + static selectAll< + ShapeType extends Shape, + ResultType = QueryResponseToResultType< + SelectAllQueryResponse, + ShapeType + >[], + >( + this: {new (...args: any[]): ShapeType; queryParser: IQueryParser}, + ): Promise & PatchedQueryPromise; + static selectAll< + ShapeType extends Shape, + ResultType = QueryResponseToResultType< + SelectAllQueryResponse, + ShapeType + >, + >( + this: {new (...args: any[]): ShapeType; queryParser: IQueryParser}, + subject: ShapeType | QResult, + ): Promise & PatchedQueryPromise; + static selectAll< + ShapeType extends Shape, + ResultType = QueryResponseToResultType< + SelectAllQueryResponse, + ShapeType + >[], + >( + this: {new (...args: any[]): ShapeType; queryParser: IQueryParser}, + subject?: ShapeType | QResult, + ): Promise & PatchedQueryPromise { + const propertyLabels = (this as any) + .shape.getUniquePropertyShapes() + .map((propertyShape: PropertyShape) => propertyShape.label); + return (this as any).select(subject as any, (shape: ShapeType) => + propertyLabels.map((label) => (shape as any)[label]), + ) as Promise & PatchedQueryPromise; + } + static update>( this: {new (...args: any[]): ShapeType; queryParser: IQueryParser}, id: string | NodeReferenceValue | QShape, diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index cbf9649..670b442 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -31,8 +31,11 @@ export const hasPet = prop('hasPet'); export const guardDogLevel = prop('guardDogLevel'); export const pluralTestProp = prop('pluralTestProp'); export const personClass = type('Person'); +export const employeeClass = type('Employee'); export const petClass = type('Pet'); export const dogClass = type('Dog'); +export const employeeName = prop('employeeName'); +export const employeeDepartment = prop('employeeDepartment'); @linkedShape export class Pet extends Shape { @@ -109,6 +112,26 @@ export class Person extends Shape { } } +@linkedShape +export class Employee extends Person { + static targetClass = employeeClass; + + @literalProperty({path: employeeName, maxCount: 1}) + get name(): string { + return ''; + } + + @objectProperty({path: bestFriend, maxCount: 1, shape: Employee}) + get bestFriend(): Employee { + return null; + } + + @literalProperty({path: employeeDepartment, maxCount: 1}) + get department(): string { + return ''; + } +} + const componentQuery = Person.query((p) => ({name: p.name})); const componentLike = {query: componentQuery}; @@ -172,6 +195,7 @@ export const queryFactories = { p.friends.where((f) => f.name.equals('Jinx').or(f.hobby.equals('Jogging'))), ), selectAll: () => Person.select(), + selectAllProperties: () => Person.selectAll(), selectWhereNameSemmy: () => Person.select().where((p) => p.name.equals('Semmy')), whereAndOrAnd: () => @@ -225,6 +249,10 @@ export const queryFactories = { Person.select((p) => p.friends.select((f) => ({name: f.name, hobby: f.hobby})), ), + subSelectAllProperties: () => + Person.select((p) => p.friends.selectAll()), + subSelectAllPropertiesSingle: () => + Person.select((p) => p.bestFriend.selectAll()), doubleNestedSubSelect: () => Person.select((p) => p.friends.select((p2) => @@ -249,6 +277,7 @@ export const queryFactories = { Person.select((p) => [p.bestFriend, p.friends]), selectShapeAs: () => Person.select((p) => p.firstPet.as(Dog).guardDogLevel), + selectAllEmployeeProperties: () => Employee.selectAll(), selectOne: () => Person.select((p) => p.name).where((p) => p.equals(entity('p1'))).one(), nestedQueries2: () => diff --git a/src/tests/metadata.test.ts b/src/tests/metadata.test.ts index 6093ede..24a44be 100644 --- a/src/tests/metadata.test.ts +++ b/src/tests/metadata.test.ts @@ -1,7 +1,7 @@ import {describe, expect, test} from '@jest/globals'; import {linkedPackage} from '../utils/Package'; import {Shape} from '../shapes/Shape'; -import {literalProperty, LINCD_DATA_ROOT} from '../shapes/SHACL'; +import {literalProperty, objectProperty, LINCD_DATA_ROOT} from '../shapes/SHACL'; import {URI} from '../utils/URI'; import {lincd} from '../ontologies/lincd'; import {shacl} from '../ontologies/shacl'; @@ -62,4 +62,243 @@ describe('Package & Shape Metadata Registration', () => { expect(propertyShape.nodeKind).toEqual(shacl.Literal); expect(propertyShape.parentNodeShape).toBe(MetaPerson.shape); }); + + test('allows override that omits min/max/nodeKind (inherits silently)', () => { + expect(() => { + @linkedShape + class InheritBase extends Shape { + static targetClass = type('InheritBase'); + + @objectProperty({ + path: prop('link'), + shape: InheritBase, + required: true, + maxCount: 1, + nodeKind: shacl.BlankNodeOrIRI, + }) + get link(): InheritBase { + return null; + } + } + + @linkedShape + class InheritChild extends InheritBase { + static targetClass = type('InheritChild'); + + @objectProperty({path: prop('link'), shape: InheritChild}) + get link(): InheritChild { + return null; + } + } + + expect(InheritChild.shape.getPropertyShape('link', false).minCount).toBe(1); + expect(InheritChild.shape.getPropertyShape('link', false).maxCount).toBe(1); + expect(InheritChild.shape.getPropertyShape('link', false).nodeKind).toEqual( + shacl.BlankNodeOrIRI, + ); + }).not.toThrow(); + }); + + test('throws when override lowers minCount', () => { + expect(() => { + @linkedShape + class TightMinBase extends Shape { + static targetClass = type('TightMinBase'); + + @literalProperty({path: prop('label'), minCount: 2}) + get label(): string { + return ''; + } + } + + @linkedShape + class TightMinChild extends TightMinBase { + static targetClass = type('TightMinChild'); + + @literalProperty({path: prop('label'), minCount: 1}) + get label(): string { + return ''; + } + } + + return TightMinChild; + }).toThrow(/minCount cannot be lowered/); + }); + + test('throws when override increases maxCount', () => { + expect(() => { + @linkedShape + class TightMaxBase extends Shape { + static targetClass = type('TightMaxBase'); + + @literalProperty({path: prop('code'), maxCount: 1}) + get code(): string { + return ''; + } + } + + @linkedShape + class TightMaxChild extends TightMaxBase { + static targetClass = type('TightMaxChild'); + + @literalProperty({path: prop('code'), maxCount: 2}) + get code(): string { + return ''; + } + } + + return TightMaxChild; + }).toThrow(/maxCount cannot be increased/); + }); + + test('respects explicit zero minCount and maxCount', () => { + @linkedShape + class ZeroCountShape extends Shape { + static targetClass = type('ZeroCountShape'); + + @literalProperty({path: prop('zeroLimited'), minCount: 0, maxCount: 0}) + get zeroLimited(): string { + return ''; + } + } + + const zeroLimited = ZeroCountShape.shape.getPropertyShape('zeroLimited'); + expect(zeroLimited.minCount).toBe(0); + expect(zeroLimited.maxCount).toBe(0); + }); + + test('throws when override increases explicit zero maxCount', () => { + expect(() => { + @linkedShape + class ZeroMaxBase extends Shape { + static targetClass = type('ZeroMaxBase'); + + @literalProperty({path: prop('locked'), maxCount: 0}) + get locked(): string { + return ''; + } + } + + @linkedShape + class ZeroMaxChild extends ZeroMaxBase { + static targetClass = type('ZeroMaxChild'); + + @literalProperty({path: prop('locked'), maxCount: 1}) + get locked(): string { + return ''; + } + } + + return ZeroMaxChild; + }).toThrow(/maxCount cannot be increased/); + }); + + test('throws when override widens nodeKind', () => { + expect(() => { + @linkedShape + class NodeKindBase extends Shape { + static targetClass = type('NodeKindBase'); + + @objectProperty({ + path: prop('ref'), + shape: NodeKindBase, + nodeKind: shacl.BlankNodeOrIRI, + }) + get ref(): NodeKindBase { + return null; + } + } + + @linkedShape + class NodeKindChild extends NodeKindBase { + static targetClass = type('NodeKindChild'); + + @objectProperty({ + path: prop('ref'), + shape: NodeKindChild, + nodeKind: shacl.IRIOrLiteral, + }) + get ref(): NodeKindChild { + return null; + } + } + + return NodeKindChild; + }).toThrow(/nodeKind cannot be widened/); + }); + + test('allows override that tightens nodeKind', () => { + expect(() => { + @linkedShape + class NodeKindTightenBase extends Shape { + static targetClass = type('NodeKindTightenBase'); + + @objectProperty({ + path: prop('target'), + shape: NodeKindTightenBase, + nodeKind: shacl.BlankNodeOrIRI, + }) + get target(): NodeKindTightenBase { + return null; + } + } + + @linkedShape + class NodeKindTightenChild extends NodeKindTightenBase { + static targetClass = type('NodeKindTightenChild'); + + @objectProperty({ + path: prop('target'), + shape: NodeKindTightenChild, + nodeKind: shacl.IRI, + }) + get target(): NodeKindTightenChild { + return null; + } + } + + expect( + NodeKindTightenChild.shape.getPropertyShape('target', false).nodeKind, + ).toEqual(shacl.IRI); + }).not.toThrow(); + }); + + test('allows mixed override: tighten minCount while maxCount/nodeKind inherit', () => { + expect(() => { + @linkedShape + class MixedInheritBase extends Shape { + static targetClass = type('MixedInheritBase'); + + @objectProperty({ + path: prop('member'), + shape: MixedInheritBase, + minCount: 1, + maxCount: 3, + nodeKind: shacl.BlankNodeOrIRI, + }) + get member(): MixedInheritBase { + return null; + } + } + + @linkedShape + class MixedInheritChild extends MixedInheritBase { + static targetClass = type('MixedInheritChild'); + + @objectProperty({ + path: prop('member'), + shape: MixedInheritChild, + minCount: 2, + }) + get member(): MixedInheritChild { + return null; + } + } + + const member = MixedInheritChild.shape.getPropertyShape('member', false); + expect(member.minCount).toBe(2); + expect(member.maxCount).toBe(3); + expect(member.nodeKind).toEqual(shacl.BlankNodeOrIRI); + }).not.toThrow(); + }); }); diff --git a/src/tests/query.test.ts b/src/tests/query.test.ts index 8cdfd6b..25cf1c5 100644 --- a/src/tests/query.test.ts +++ b/src/tests/query.test.ts @@ -9,7 +9,7 @@ import {UpdateQueryFactory} from '../queries/UpdateQuery'; import {CreateQueryFactory} from '../queries/CreateQuery'; import {DeleteQueryFactory} from '../queries/DeleteQuery'; import {NodeId} from '../queries/MutationQuery'; -import {Dog, Person, Pet, queryFactories, name as namePath, tmpEntityBase} from '../test-helpers/query-fixtures'; +import {Dog, Employee, Person, Pet, queryFactories, name as namePath, tmpEntityBase} from '../test-helpers/query-fixtures'; import {setQueryContext} from '../queries/QueryContext'; class QueryCaptureStore implements IQueryParser { @@ -54,6 +54,7 @@ const store = new QueryCaptureStore(); Person.queryParser = store; Pet.queryParser = store; Dog.queryParser = store; +Employee.queryParser = store; const captureQuery = async (runner: () => Promise) => { store.lastQuery = undefined; @@ -219,6 +220,37 @@ describe('3. Filtering (Where Clauses)', () => { expectSelectQuery(query); }); + test('select all properties of the shape', async () => { + const query = await captureQuery(() => queryFactories.selectAllProperties()); + + expectSelectQuery(query); + const labels = query?.select?.map((path: any) => path?.[0]?.property?.label); + expect(labels).toEqual(expect.arrayContaining([ + 'name', + 'hobby', + 'nickNames', + 'birthDate', + 'isRealPerson', + 'bestFriend', + 'friends', + 'pets', + 'firstPet', + 'pluralTestProp', + ])); + }); + + test('select all uses subclass overrides once', async () => { + const query = await captureQuery(() => queryFactories.selectAllEmployeeProperties()); + + expectSelectQuery(query); + const labels = query?.select?.map((path: any) => path?.[0]?.property?.label); + expect(labels?.filter((label: string) => label === 'name')).toHaveLength(1); + expect(labels?.filter((label: string) => label === 'bestFriend')).toHaveLength(1); + const nameStep = query?.select?.find((path: any) => path?.[0]?.property?.label === 'name'); + expect(nameStep?.[0]?.property?.path?.id).toBe('linked://tmp/props/employeeName'); + expect(labels).toContain('department'); + }); + test('empty select with where', async () => { const query = await captureQuery(() => queryFactories.selectWhereNameSemmy()); @@ -351,6 +383,34 @@ describe('4. Aggregation & Sub-Select', () => { expectSelectQuery(query); }); + test('sub select all properties on a shape set', async () => { + const query = await captureQuery(() => + queryFactories.subSelectAllProperties(), + ); + + expectSelectQuery(query); + expect(query?.select?.[0]?.[0]?.property?.label).toBe('friends'); + const nestedPaths = query?.select?.[0]?.[1] as any[]; + const nestedLabels = nestedPaths?.map((path: any) => + path?.[0]?.property?.label, + ); + expect(nestedLabels).toEqual(expect.arrayContaining(['name', 'hobby'])); + }); + + test('sub select all properties on a single shape', async () => { + const query = await captureQuery(() => + queryFactories.subSelectAllPropertiesSingle(), + ); + + expectSelectQuery(query); + expect(query?.select?.[0]?.[0]?.property?.label).toBe('bestFriend'); + const nestedPaths = query?.select?.[0]?.[1] as any[]; + const nestedLabels = nestedPaths?.map((path: any) => + path?.[0]?.property?.label, + ); + expect(nestedLabels).toEqual(expect.arrayContaining(['name', 'hobby'])); + }); + test('double nested sub select', async () => { const query = await captureQuery(() => queryFactories.doubleNestedSubSelect()); diff --git a/src/tests/query.types.test.ts b/src/tests/query.types.test.ts index f936f93..f863551 100644 --- a/src/tests/query.types.test.ts +++ b/src/tests/query.types.test.ts @@ -205,6 +205,16 @@ describe.skip('query result type inference (compile only)', () => { expectType(first.id); }); + test('select all properties of the shape', () => { + const promise = queryFactories.selectAllProperties(); + type Result = Awaited; + const first = (null as unknown as Result)[0]; + expectType(first.id); + expectType(first.name); + type HasNodeShape = 'nodeShape' extends keyof typeof first ? true : false; + expectType(null as unknown as HasNodeShape); + }); + test('empty select with where', () => { const promise = queryFactories.selectWhereNameSemmy(); type Result = Awaited; @@ -326,6 +336,24 @@ describe.skip('query result type inference (compile only)', () => { expectType(first.friends[0].hobby); }); + test('sub select all properties on a shape set', () => { + const promise = queryFactories.subSelectAllProperties(); + type Result = Awaited; + const first = (null as unknown as Result)[0]; + expectType(first.friends[0].id); + expectType(first.friends[0].name); + expectType(first.friends[0].hobby); + }); + + test('sub select all properties on a single shape', () => { + const promise = queryFactories.subSelectAllPropertiesSingle(); + type Result = Awaited; + const first = (null as unknown as Result)[0]; + expectType(first.bestFriend.id); + expectType(first.bestFriend.name); + expectType(first.bestFriend.hobby); + }); + test('double nested sub select', () => { const promise = queryFactories.doubleNestedSubSelect(); type Result = Awaited; From c35e6861600d7aa8683b4b288fc4d1dc74c4aff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 16 Feb 2026 14:20:30 +0800 Subject: [PATCH 2/3] changeset --- .changeset/wicked-lions-wonder.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/wicked-lions-wonder.md diff --git a/.changeset/wicked-lions-wonder.md b/.changeset/wicked-lions-wonder.md new file mode 100644 index 0000000..cc5fc64 --- /dev/null +++ b/.changeset/wicked-lions-wonder.md @@ -0,0 +1,10 @@ +--- +"@_linked/core": minor +--- + +- Added `Shape.selectAll()` plus nested `selectAll()` support on sub-queries. +- Added inherited property deduplication via `NodeShape.getUniquePropertyShapes()` so subclass overrides win by label and are selected once. +- Improved `selectAll()` type inference (including nested queries) and excluded base `Shape` keys from inferred results. +- Added registration-time override guards: `minCount` cannot be lowered, `maxCount` cannot be increased, and `nodeKind` cannot be widened. +- Fixed `createPropertyShape` to preserve explicit `minCount: 0` / `maxCount: 0`. +- Expanded tests and README documentation for `selectAll`, CRUD return types, and multi-value update semantics. From 9a103e78516eea2da43e8c7b36bfa4366d59e74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 16 Feb 2026 14:22:50 +0800 Subject: [PATCH 3/3] clearing changelog from readme --- AGENTS.md | 4 +--- README.md | 6 ------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 413ee40..571a5ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,6 @@ ls docs/ # list all docs head -4 docs/*.md # get summaries (or replace * with a specific file) ``` -Each package also has a `README.md` with API docs and a `## Changelog` at the bottom. - ## Planning and implementation workflow ### When to plan @@ -56,4 +54,4 @@ Any task that changes package code requires a plan. Simple checks, info gatherin Before committing final changes or preparing a PR: 1. **Consolidate the plan doc** — collapse alternatives into the choices that were made, summarize implementation details and breaking changes, keep a brief problems section if relevant, remove anything redundant for future readers. -2. **Update `## Changelog`** in each affected package's `README.md` — user-facing entry covering behavior changes, new APIs, breaking changes, and migration steps. +2. **Run changesets** Ask the user if this is a patch/minor/major change, then run `npx changesets/cli` and provide an appropriate changelog message covering behavior changes, new APIs, breaking changes, and migration steps. diff --git a/README.md b/README.md index 3fd2e3b..851313f 100644 --- a/README.md +++ b/README.md @@ -430,10 +430,4 @@ Override behavior: ## Changelog -- Added `Shape.selectAll()` to select all decorated property shapes of a shape in one call. -- Updated `selectAll()` to deduplicate inherited overridden property labels so subclass overrides are selected once. -- Added `NodeShape.getUniquePropertyShapes()` to expose deduplicated inherited property shapes directly on the shape metadata API. -- Simplified `NodeShape.getUniquePropertyShapes()` to always resolve across the inheritance chain. -- Added registration-time override guards so subclass overrides cannot widen `minCount`/`maxCount`/`nodeKind` constraints. - See [CHANGELOG.md](./CHANGELOG.md).