Skip to content
Merged

Dev #46

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5c5b718
chore: sync package.json version to 2.2.2 from main
github-actions[bot] Mar 28, 2026
d501951
chore: sync package.json version to 2.2.3 from main
github-actions[bot] Mar 28, 2026
3504400
Fix single-value property traversals returning arrays instead of sing…
claude Apr 1, 2026
88fedbb
Update package-lock.json after npm install
claude Apr 1, 2026
53a2b63
Add golden test verifying maxCount flows through IR pipeline
claude Apr 2, 2026
1a03d44
Add changeset and report for single-value property fix
claude Apr 2, 2026
4c6b47b
Merge pull request #45 from Semantu/claude/fix-single-value-property-…
flyon Apr 2, 2026
e343b4f
Fix Fuseki integration tests for single-value bestFriend unwrapping
claude Apr 2, 2026
17f8132
Merge remote-tracking branch 'origin/dev' into claude/fix-single-valu…
claude Apr 2, 2026
c1e35f5
Strengthen 3 weak Fuseki integration tests
claude Apr 2, 2026
1e9905e
Strengthen 10+ remaining weak Fuseki integration tests
claude Apr 2, 2026
c9d6688
Fix flat multi-value property projection returning single values
claude Apr 3, 2026
8636405
Wrapup: code review cleanup, report, and changeset
claude Apr 3, 2026
2d8ae0f
Fix flat multi-value literal fields returning empty arrays
claude Apr 3, 2026
4917894
Consolidate docs: merge reports, clean up plans/ideations, update cha…
claude Apr 3, 2026
2831b06
Merge pull request #47 from Semantu/claude/fix-single-value-property-…
flyon Apr 3, 2026
fc5c763
Fix 6 failing Fuseki tests
claude Apr 3, 2026
fd7a5a7
Fix countNestedFriends/countLabel tests with robust count key lookup
claude Apr 3, 2026
aa735f2
Merge pull request #48 from Semantu/claude/fix-count-nested-tests
flyon Apr 3, 2026
e203749
Fix aggregate count mapping when alias collides with traversal
claude Apr 3, 2026
ee83aa5
Merge pull request #49 from Semantu/claude/fix-count-aggregate-tests
flyon Apr 3, 2026
a9eac4b
Add PR CI workflow and bump changeset to minor
claude Apr 3, 2026
8368c86
Remove redundant test step from publish workflow
claude Apr 3, 2026
86418ab
Merge pull request #50 from Semantu/claude/ci-and-changeset-fix
flyon Apr 3, 2026
8b63230
Remove broken npm global upgrade from publish workflow
claude Apr 3, 2026
3b3bb1d
Merge pull request #51 from Semantu/claude/fix-publish-npm
flyon Apr 3, 2026
b710ed6
Pin Node to 22.16.0 and restore npm 11 upgrade for publish
claude Apr 3, 2026
6ace771
Merge pull request #52 from Semantu/claude/fix-publish-npm-v2
flyon Apr 3, 2026
2172fff
Remove redundant standalone build job from publish workflow
claude Apr 3, 2026
92b7537
Merge pull request #54 from Semantu/claude/remove-redundant-build-job
flyon Apr 3, 2026
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
17 changes: 17 additions & 0 deletions .changeset/fix-maxcount-result-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@_linked/core": minor
---

Fix maxCount-aware result mapping for single-value and multi-value properties

**Single-value properties** (`maxCount: 1`, e.g. `bestFriend`) now return a single `ResultRow` (or `null` when absent) instead of `ResultRow[]` when accessed via traversal queries like `Person.select(p => p.bestFriend.name)`.

**Multi-value object properties** (e.g. `friends`, without `maxCount`) now correctly return `ResultRow[]` arrays when selected via flat projections like `Person.select(p => p.friends)`. Previously, only the first entity reference was returned.

**Multi-value literal properties** (e.g. `nickNames: string[]`) now correctly return typed arrays (e.g. `string[]`). Previously, values were silently dropped and an empty array was returned.

**Behavioral changes:**
- If your code accesses single-value traversal results as arrays (e.g. `result.bestFriend[0]`), update to access the value directly (`result.bestFriend`).
- If your code expects multi-value flat select results as single objects (e.g. `result.friends.id`), update to handle arrays (`result.friends[0].id`).

The `maxCount` metadata from `PropertyShape` is now propagated through the full IR pipeline (`IRTraversePattern.maxCount`, `IRPropertyExpression.maxCount`) and used during SPARQL result mapping.
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
pull_request:
branches:
- main
- dev

jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.16.0

- name: Install dependencies
run: npm ci --legacy-peer-deps

- name: Build
run: npm run build

- name: Test
run: npm test
28 changes: 2 additions & 26 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,8 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}

jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"

- name: Install dependencies
run: npm ci --legacy-peer-deps

- name: Build
run: npm run build

- name: Test
run: npm test

release:
name: Publish Stable Release
needs: build-and-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
Expand All @@ -47,7 +24,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 22.16.0
registry-url: "https://registry.npmjs.org"

- name: Setup npm
Expand Down Expand Up @@ -98,7 +75,6 @@ jobs:

dev-release:
name: Publish Dev Release
needs: build-and-test
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
permissions:
Expand All @@ -111,7 +87,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 22.16.0
registry-url: "https://registry.npmjs.org"

- name: Setup npm
Expand Down
76 changes: 76 additions & 0 deletions docs/reports/012-fix-single-value-property-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 012 — Fix maxCount-aware result mapping for single-value and multi-value properties

## Summary

Three related bugs in the result mapping layer were fixed:

1. **Single-value traversals returned arrays**: `@objectProperty({maxCount: 1})` properties like `bestFriend` returned `ResultRow[]` instead of a single `ResultRow` when selected via traversal queries (e.g., `Person.select(p => p.bestFriend.name)`).

2. **Multi-value flat projections returned single values**: Multi-value object properties like `friends` (no `maxCount`) returned a single entity reference instead of `ResultRow[]` when selected via flat projections (e.g., `Person.select(p => p.friends)`).

3. **Multi-value literal fields were silently dropped**: Multi-value literal properties like `nickNames: string[]` returned empty arrays because the collection logic filtered out non-URI values.

## Root cause

The `maxCount` metadata from `PropertyShape` was never propagated through the IR pipeline. The result mapping layer had no way to distinguish single-value from multi-value properties, and flat result mapping discarded duplicate bindings for the same root entity.

## Architecture: maxCount propagation pipeline

```
PropertyShape.maxCount
→ IRDesugar: DesugaredPropertyStep.maxCount
├→ IRLower: IRTraversePattern.maxCount
│ → resultMapping: NestedGroup.maxCount
│ → assignNestedGroupValue(): unwrap when maxCount <= 1
└→ IRProjection: IRPropertyExpression.maxCount
→ resultMapping: FieldDescriptor.maxCount
→ populateFlatFields(): array when absent, scalar when <= 1
```

Each layer adds an optional `maxCount?: number` field and passes it downstream. All additions are backward-compatible — properties without `maxCount` (or `maxCount > 1`) behave exactly as before.

## Key design decisions

1. **Optional field, not a boolean**: `maxCount?: number` preserves the full constraint value rather than reducing to `isSingleValue: boolean`. This allows future use (e.g., validation, LIMIT hints) without another pipeline change.

2. **Unwrap at result mapping, not query building**: The SPARQL query itself is unchanged — single-value and multi-value properties generate identical patterns. Only the post-processing applies the unwrap/collect logic.

3. **`null` for absent single values, `[]` for absent multi-values**: When a single-value traversal has no match, the result is `null`. When a multi-value flat field has no bindings, the result is `[]` (empty array).

4. **Group-then-collect in `mapFlatRows`**: Refactored from dedup-first to group-first. Bindings are grouped by root entity ID, then for each root: single-value fields take first binding, multi-value fields collect all distinct values.

5. **`extractFieldValue` consolidation**: Value extraction logic consolidated into a single `extractFieldValue()` function, eliminating duplication between `populateFields` and the old `mapFlatRows` loop.

6. **`ResultFieldValue` type widening**: Added `string[] | number[] | boolean[] | Date[]` to the `ResultFieldValue` union to support multi-value literal arrays alongside existing `ResultRow[]` for entity references.

## Files changed

| File | Responsibility |
|------|---------------|
| `src/queries/IntermediateRepresentation.ts` | Added `maxCount?: number` to `IRTraversePattern` and `IRPropertyExpression`; added primitive array types to `ResultFieldValue` |
| `src/queries/IRDesugar.ts` | Added `maxCount?: number` to `DesugaredPropertyStep`; propagated from `PropertyShape` |
| `src/queries/IRLower.ts` | Extended `getOrCreateTraversal` signature to accept `maxCount` |
| `src/queries/IRProjection.ts` | Forwarded `step.maxCount` to both traversal resolution and last-step `property_expr` |
| `src/sparql/resultMapping.ts` | Added `maxCount` to `NestedGroup` and `FieldDescriptor`; new helpers `isMultiValueField`, `extractFieldValue`, `populateFlatFields`; refactored `mapFlatRows`; added `assignNestedGroupValue()` |
| `src/test-helpers/query-fixtures.ts` | Added `selectBestFriendOnly` fixture |
| `src/tests/ir-select-golden.test.ts` | Golden snapshot tests for `maxCount` on traverse and property_expr |
| `src/tests/sparql-result-mapping.test.ts` | 13 new unit tests covering single-value unwrap, multi-value URI collection, multi-value literal collection, dedup, empty arrays, mixed fields |
| `src/tests/sparql-negative.test.ts` | Updated helper with `maxCount: 1` |
| `src/tests/sparql-fuseki.test.ts` | 13 weak integration tests strengthened with proper value assertions; 3 tests fixed for single-value unwrap |

## Public API surface

No new exports. Behavioral changes:

- `Person.select(p => p.bestFriend.name)` → `result.bestFriend` is now `ResultRow` (was `ResultRow[]`)
- `Person.select(p => p.friends)` → `result.friends` is now `ResultRow[]` (was single `{id: ...}`)
- `Person.select(p => p.nickNames)` → `result.nickNames` is now `string[]` (was `[]` empty)

## Test coverage

- **912 tests pass**, 114 skipped (Fuseki integration)
- 13 new unit tests + 13 strengthened Fuseki integration tests + 3 fixed Fuseki tests

## Known gap

`createTraversalResolver()` in `IRLower.ts` (used by `lowerWhere` for EXISTS/MINUS patterns) does not propagate `maxCount`. This is correct because WHERE-clause traversals do not produce result nesting — they only generate SPARQL graph patterns for filtering.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/queries/IRDesugar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type DesugaredPropertyStep = {
propertyShapeId: string;
pathExpr?: PathExpr;
where?: DesugaredWhere;
maxCount?: number;
};

export type DesugaredCountStep = {
Expand Down Expand Up @@ -185,6 +186,9 @@ const segmentsToSteps = (segments: PropertyShape[]): DesugaredPropertyStep[] =>
if (seg.path && isComplexPathExpr(seg.path)) {
step.pathExpr = seg.path;
}
if (typeof seg.maxCount === 'number') {
step.maxCount = seg.maxCount;
}
return step;
});

Expand Down Expand Up @@ -245,6 +249,9 @@ const desugarEntry = (entry: FieldSetEntry): DesugaredSelection => {
if (segment.path && isComplexPathExpr(segment.path)) {
step.pathExpr = segment.path;
}
if (typeof segment.maxCount === 'number') {
step.maxCount = segment.maxCount;
}
if (entry.scopedFilter && i === filterIndex) {
step.where = toWhere(entry.scopedFilter);
}
Expand Down
13 changes: 8 additions & 5 deletions src/queries/IRLower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class LoweringContext {
return `a${this.counter++}`;
}

getOrCreateTraversal(fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr): string {
getOrCreateTraversal(fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr, maxCount?: number): string {
const key = `${fromAlias}:${propertyShapeId}`;
const existing = this.traverseMap.get(key);
if (existing) return existing;
Expand All @@ -86,6 +86,9 @@ class LoweringContext {
if (pathExpr) {
pattern.pathExpr = pathExpr;
}
if (typeof maxCount === 'number') {
pattern.maxCount = maxCount;
}
this.patterns.push(pattern);
this.traverseMap.set(key, toAlias);
return toAlias;
Expand Down Expand Up @@ -120,7 +123,7 @@ type AliasGenerator = {

type PathLoweringOptions = {
rootAlias: string;
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) => string;
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr, maxCount?: number) => string;
};

const isShapeRef = (value: unknown): value is ShapeReferenceValue =>
Expand Down Expand Up @@ -277,8 +280,8 @@ export const lowerSelectQuery = (
const ctx = new LoweringContext();
const pathOptions: PathLoweringOptions = {
rootAlias: ctx.rootAlias,
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) =>
ctx.getOrCreateTraversal(fromAlias, propertyShapeId, pathExpr),
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr, maxCount?: number) =>
ctx.getOrCreateTraversal(fromAlias, propertyShapeId, pathExpr, maxCount),
};

const root: IRShapeScanPattern = {
Expand All @@ -291,7 +294,7 @@ export const lowerSelectQuery = (
let currentAlias = pathOptions.rootAlias;
for (const step of steps) {
if (step.kind === 'property_step') {
currentAlias = pathOptions.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr);
currentAlias = pathOptions.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr, step.maxCount);
}
}
return currentAlias;
Expand Down
9 changes: 6 additions & 3 deletions src/queries/IRProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type InlineFilterCallback = (

export type ProjectionPathLoweringOptions = {
rootAlias: string;
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) => string;
resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr, maxCount?: number) => string;
};

export type CanonicalProjectionResult = {
Expand Down Expand Up @@ -67,7 +67,7 @@ export const lowerSelectionPathExpression = (
if (step.kind === 'property_step') {
if (step.where && onInlineFilter) {
// Force traversal creation for step with inline where
currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr);
currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr, step.maxCount);
onInlineFilter(currentAlias, step.where);
if (isLast) {
return {kind: 'alias_expr', alias: currentAlias};
Expand All @@ -84,10 +84,13 @@ export const lowerSelectionPathExpression = (
if (step.pathExpr) {
(expr as import('./IntermediateRepresentation.js').IRPropertyExpression).pathExpr = step.pathExpr;
}
if (typeof step.maxCount === 'number') {
(expr as import('./IntermediateRepresentation.js').IRPropertyExpression).maxCount = step.maxCount;
}
return expr;
}

currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr);
currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr, step.maxCount);
continue;
}

Expand Down
8 changes: 7 additions & 1 deletion src/queries/IntermediateRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type IRTraversePattern = {
property: string;
pathExpr?: PathExpr;
filter?: IRExpression;
maxCount?: number;
};

export type IRJoinPattern = {
Expand Down Expand Up @@ -128,6 +129,7 @@ export type IRPropertyExpression = {
sourceAlias: IRAlias;
property: string;
pathExpr?: import('../paths/PropertyPathExpr.js').PathExpr;
maxCount?: number;
};

export type IRContextPropertyExpression = {
Expand Down Expand Up @@ -285,7 +287,11 @@ export type ResultFieldValue =
| null
| undefined
| ResultRow
| ResultRow[];
| ResultRow[]
| string[]
| number[]
| boolean[]
| Date[];

/**
* What `selectQuery` should return.
Expand Down
Loading
Loading