Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/wicked-lions-wonder.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 2 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +31,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
Expand All @@ -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.
185 changes: 146 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Person> */
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<Person> */
```

## Storage configuration
Expand Down Expand Up @@ -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]);
Expand All @@ -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
Expand Down Expand Up @@ -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);
```
Expand Down Expand Up @@ -293,29 +298,131 @@ const preloaded = await Person.select((p) => [
]);
```

#### Create / Update / Delete
#### Create

```typescript
/* Result: {id: string} & UpdatePartial<Person> */
const created = await Person.create({name: 'Alice'});
```
Where UpdatePartial<Shape> 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<Person> */
const updated = await Person.update({id: 'https://my.app/node1'}, {name: 'Alicia'});
```
Returns:
```json
{
id:"https://my.app/node1",
name:"Alicia"
}
```

// Overwrite a multi-value property
const overwriteFriends = await Person.update({id: 'https://my.app/node1'}, {
knows: [{id: 'https://my.app/node2'}],
**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.

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'}]);
```


## 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');

await Person.delete({id: 'https://my.app/node1'});
@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).
Expand Down
72 changes: 72 additions & 0 deletions docs/002-select-all-property-shapes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading