diff --git a/.changeset/shacl-property-paths-and-prefix-resolution.md b/.changeset/shacl-property-paths-and-prefix-resolution.md new file mode 100644 index 0000000..a78dac0 --- /dev/null +++ b/.changeset/shacl-property-paths-and-prefix-resolution.md @@ -0,0 +1,34 @@ +--- +"@_linked/core": patch +--- + +### SHACL property path support + +Property decorators now accept full SPARQL property path syntax: + +```ts +@literalProperty({path: 'foaf:knows/foaf:name'}) // sequence +@literalProperty({path: '|'}) // alternative +@literalProperty({path: '^foaf:knows'}) // inverse +@literalProperty({path: 'foaf:knows*'}) // zeroOrMore +``` + +New exports from `src/paths/`: +- `PathExpr`, `PathRef` — AST types for property paths +- `parsePropertyPath(input): PathExpr` — parser for SPARQL property path strings +- `normalizePropertyPath(input): PathExpr` — normalizes any input form to canonical AST +- `pathExprToSparql(expr): string` — renders PathExpr to SPARQL syntax +- `serializePathToSHACL(expr): SHACLPathResult` — serializes to SHACL RDF triples + +`PropertyShape.path` is now typed as `PathExpr` (was opaque). Complex paths flow through the full IR pipeline and emit correct SPARQL property path syntax in generated queries. + +### Strict prefix resolution in query API + +`QueryBuilder.for()` and `.forAll()` now throw on unregistered prefixes instead of silently passing through. New export: +- `resolveUriOrThrow(str): string` — strict prefix resolution (throws on unknown prefix) + +### SHACL constraint field fixes + +- `hasValue` and `in` config fields now correctly handle literal values (`string`, `number`, `boolean`) — previously all values were wrapped as IRI nodes +- `lessThan` and `lessThanOrEquals` config fields are now wired into `createPropertyShape` and exposed via `getResult()` +- New `PropertyShapeResult` interface provides typed access to `getResult()` output diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 82e1a71..8b2897e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,6 +69,33 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + sync-version-to-dev: + name: Sync Version to Dev + needs: release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout dev + uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: Sync package.json version from main + run: | + MAIN_VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version") + DEV_VERSION=$(node -p "require('./package.json').version") + if [ "$MAIN_VERSION" != "$DEV_VERSION" ]; then + npm version "$MAIN_VERSION" --no-git-tag-version --allow-same-version + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json + git commit -m "chore: sync package.json version to $MAIN_VERSION from main" + git push + fi + dev-release: name: Publish Dev Release needs: build-and-test @@ -98,7 +125,11 @@ jobs: - name: Publish next release run: | - # Apply pending changesets to get the next version (without committing) + # Start from main's version so changesets apply on top of the latest stable + git fetch origin main --depth=1 + MAIN_VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version") + npm version "$MAIN_VERSION" --no-git-tag-version --allow-same-version + # Apply pending changesets to get the next version npx changeset version NEXT_VERSION=$(node -p "require('./package.json').version") # Append dev prerelease identifier diff --git a/docs/ideas/013-shacl-property-paths.md b/docs/ideas/013-shacl-property-paths.md new file mode 100644 index 0000000..78d4b6d --- /dev/null +++ b/docs/ideas/013-shacl-property-paths.md @@ -0,0 +1,209 @@ +# Full SHACL Property Paths in Decorators — Ideation + +## Context + +Our property-shape decorator config currently treats `path` as either a single node reference or an array of node references: +- Single IRI-like path value (`ex:name`) +- Sequence-only list (`[ex:friend, ex:name]`) + +That means we are effectively limited to **predicate paths and simple sequence paths**. Other SHACL/SPARQL path forms are not modeled in the decorator API yet. + +Current code state (`src/shapes/SHACL.ts`): +- `PropertyPathInput` is currently `NodeReferenceValue`, and `PropertyPathInputList` is only scalar-or-array of that scalar. +- `normalizePathInput` currently normalizes strings/objects to plain node refs and arrays of node refs. +- No SHACL path serialization code exists yet. + +## Goals + +Extend property decorators and SHACL serialization support so users can express **all SPARQL property path forms supported by SHACL** in a concise and readable way, then verify end-to-end behavior via query tests that assert generated SPARQL. + +Spec-aligned path forms to support: + +1. Predicate path: `ex:knows` +2. Sequence path: `ex:knows/ex:name` +3. Alternative path: `ex:knows|ex:colleague` +4. Inverse path: `^ex:parent` +5. Zero-or-more: `ex:broader*` +6. One-or-more: `ex:broader+` +7. Zero-or-one: `ex:middleName?` +8. Grouped combinations, e.g. `(ex:knows|ex:colleague)/ex:name` + +## Open Questions + +- [x] **String parser strictness:** Full SPARQL property path grammar from the start. +- [x] **Prefix handling:** Parser operates on raw strings; prefix resolution happens downstream. +- [x] **Type inference:** No change — `shape` is always explicit or omitted. No ontology inference. +- [x] **Readability limits:** Document guidance (recommend object/builder at ~2+ nesting levels) but don't enforce. +- [x] **AST type design:** Discriminated-object union; include `negatedPropertySet` for full SPARQL coverage. +- [x] **Builder API scope:** Defer to Phase 4; object form suffices until real usage patterns inform builder design. +- [x] **SHACL serialization approach:** Standard blank-node + RDF-list encoding; simple predicates stay as direct IRIs; `negatedPropertySet` errors at serialization boundary. +- [x] **Query/IR threading:** Embed `PathExpr` directly in traversal IR nodes; SPARQL generation emits property-path syntax inline. + +## Decisions + +| # | Decision | Chosen | Rationale | +|---|----------|--------|-----------| +| 1 | String parser strictness | Full SPARQL property path grammar | Start with maximum expressiveness; avoid needing a second parser pass later. Accept that negated property sets won't map to SHACL `sh:path` and handle that at the serialization boundary. | +| 2 | Prefix handling | Parser is stateless; raw strings preserved | Prefix resolution happens downstream via existing `toPlainNodeRef` pipeline. Keeps parser context-free and reusable. | +| 3 | Type inference | No change — existing behavior | `shape` is always user-provided or omitted; no ontology is available for inference. Complex paths follow the same rule as simple paths. | +| 4 | Readability limits | Document guidance, don't enforce | Recommend object/builder syntax at ~2+ nesting levels. Parser accepts anything valid regardless. | +| 5a | AST union style | Discriminated-object union | Concise, doubles as user-facing decorator input — no conversion layer needed. | +| 5b | Negated property sets | Include in AST | Completes full SPARQL grammar per decision 1. SHACL serialization throws descriptive error at boundary. | +| 6 | Builder API scope | Defer to Phase 4 | Object form (`{ inv: 'ex:parent' }`) is already concise. Wait for real usage patterns from Phases 1–3 to inform builder design. | +| 7 | SHACL serialization approach | Standard blank-node + RDF-list encoding | Spec-compliant SHACL path serialization. Simple predicates as direct IRIs; complex paths use `sh:alternativePath`, `sh:inversePath`, etc. with blank nodes and RDF lists. `negatedPropertySet` throws at boundary. | +| 8 | Query/IR threading | Embed PathExpr in traversal IR nodes | SPARQL generation emits property-path syntax inline (`?s (path) ?o`). Avoids expanding into multiple triple patterns. Simple predicates use existing field unchanged. | + +## Notes + +### Proposed API direction + +#### 1) Canonical internal AST (single source of truth) + +Define a typed internal representation used by decorators, SHACL materialization, and query/SPARQL conversion: + +```ts +type PathRef = string | { id: string }; +type PathExpr = + | PathRef + | { seq: PathExpr[] } + | { alt: PathExpr[] } + | { inv: PathExpr } + | { zeroOrMore: PathExpr } + | { oneOrMore: PathExpr } + | { zeroOrOne: PathExpr } + | { negatedPropertySet: (PathRef | { inv: PathRef })[] }; +``` + +This shape maps directly to SHACL path node encodings and SPARQL property path operators. + +#### 2) Ergonomic decorator input (string + object hybrid) + +Allow users to pass either: +- A compact string expression for common usage: + - `'ex:friend/ex:name'` + - `'^ex:parent'` + - `'(ex:friend|ex:colleague)/ex:name'` +- Structured objects for explicit/typed composition: + - `{ seq: ['ex:friend', 'ex:name'] }` + - `{ alt: ['ex:friend', 'ex:colleague'] }` + - `{ inv: 'ex:parent' }` + +And continue to support existing forms (`string`, `{id}`, and plain arrays as sequence shorthand). + +#### 3) Backward-compatible normalization pipeline + +Introduce `normalizePropertyPathExpr(input)`: +1. Parse string path syntax into AST +2. Convert shorthand arrays into `{ seq: [...] }` +3. Normalize all refs to `{id}` internally +4. Validate operator arity and nesting +5. Return canonical AST + +This keeps old decorator calls working while enabling new syntax. + +#### 4) Explicit helper builder (optional but elegant) + +For teams that prefer no mini-parser, provide helper functions: + +```ts +path.seq('ex:friend', 'ex:name') +path.alt('ex:friend', 'ex:colleague') +path.inv('ex:parent') +path.zeroOrMore('ex:broader') +``` + +Builders can emit the same AST, so parser + builder share downstream code. + +### Decorator surface proposal + +Update property decorator config types from current scalar/list path input to: + +```ts +type PropertyPathDecoratorInput = + | string + | { id: string } + | PropertyPathDecoratorInput[] // sequence shorthand + | PathExpr; +``` + +Decorator examples: + +```ts +@objectProperty({ + path: 'ex:friend/ex:name', + shape: Person, +}) +declare friendName: string; + +@objectProperty({ + path: { alt: ['ex:friend', 'ex:colleague'] }, + shape: Person, +}) +declare socialEdge: Person; + +@objectProperty({ + path: { seq: [{ inv: 'ex:parent' }, 'ex:name'] }, + shape: Person, +}) +declare parentName: string; +``` + +### SHACL materialization strategy + +- Keep simple predicate path as direct IRI node where possible. +- Materialize complex paths using SHACL path-node structures (blank nodes and RDF lists) corresponding to: + - `sh:alternativePath` + - `sh:inversePath` + - `sh:zeroOrMorePath` + - `sh:oneOrMorePath` + - `sh:zeroOrOnePath` + - RDF list sequence for path order + +### Query to SPARQL generation implications + +1. Ensure query primitives can carry path expressions (not only linked property-shape segment chains). +2. Extend IR representation for traversals to include path expression AST. +3. In SPARQL conversion, emit property-path syntax inside triple patterns: + - `?s (ex:friend/ex:name) ?o` + - `?s (^ex:parent/ex:name) ?o` + - `?s (ex:friend|ex:colleague) ?o` + +### Test plan (idea-level) + +Add query factories + golden SPARQL assertions for each form: + +1. Predicate: `ex:p` +2. Sequence: `ex:p1/ex:p2` +3. Alternative: `ex:p1|ex:p2` +4. Inverse: `^ex:p` +5. Zero-or-more: `ex:p*` +6. One-or-more: `ex:p+` +7. Zero-or-one: `ex:p?` +8. Nested grouped: `(ex:p1|^ex:p2)/ex:p3+` + +For each, validate: +- Decorator metadata normalization result (AST) +- SHACL graph serialization of `sh:path` +- Lowered IR contains expected path expression +- Final SPARQL string contains correct property-path operator and grouping + +### Suggested phased rollout + +**Phase 1 — metadata model + normalization** +- Add AST types and normalizer +- Keep existing behavior unchanged for simple paths +- Add unit tests for normalization + invalid syntax + +**Phase 2 — SHACL serialization** +- Serialize all AST variants to SHACL path nodes +- Add SHACL output tests for every operator + +**Phase 3 — query/IR/SPARQL** +- Thread path AST through query internals +- Generate property path SPARQL for all forms +- Add golden tests per operator and combination + +**Phase 4 — ergonomics + docs** +- Add helper builders (`path.seq`, `path.alt`, etc.) +- Document string syntax and migration examples +- Add guidance on when to use strings vs object builders diff --git a/docs/ideas/014-prefixed-uris-in-json.md b/docs/ideas/014-prefixed-uris-in-json.md new file mode 100644 index 0000000..cf45f02 --- /dev/null +++ b/docs/ideas/014-prefixed-uris-in-json.md @@ -0,0 +1,77 @@ +# Prefixed URIs in JSON/Object Formats — Ideation + +## Context + +Currently, users writing shape definitions and queries interact with URIs in two primary ways: + +1. **`NodeReferenceValue` objects** — `{id: 'http://xmlns.com/foaf/0.1/knows'}` — verbose but explicit +2. **Ontology module exports** — `import {foaf} from '../ontologies/foaf'; foaf.knows` — ergonomic but requires imports + +The `Prefix` registry (`Prefix.ts`) already supports bidirectional conversion: +- `Prefix.toFull('foaf:knows')` → `'http://xmlns.com/foaf/0.1/knows'` (throws if unknown prefix) +- `Prefix.toFullIfPossible('foaf:knows')` → resolves or returns original +- `Prefix.toPrefixed('http://...')` → `'foaf:knows'` (for SPARQL rendering) + +The property path system (phases 1–6) already accepts prefixed strings in path expressions: `@literalProperty({path: 'foaf:knows/foaf:name'})`. The parser preserves them as raw strings. But the `{id}` form and `NodeReferenceValue` inputs throughout the codebase don't support prefixed names — they always expect full IRIs. + +### Key entry points that accept URIs from users + +| Location | Current type | Example | +|----------|-------------|---------| +| `@literalProperty({path})` | `PropertyPathDecoratorInput` | `path: foaf.name` or `path: 'foaf:name/foaf:nick'` | +| `@objectProperty({path, shape, class})` | same + `NodeReferenceValue` | `class: rdf.Class` | +| `static targetClass` | `NodeReferenceValue` | `static targetClass = {id: 'http://...'}` | +| `PropertyShapeConfig.equals/disjoint/hasValue` | `NodeReferenceValue \| string` | `equals: rdf.type` | +| `PropertyShapeConfig.in` | `NodeReferenceValue[]` | `in: [rdf.type, rdf.Property]` | +| `LiteralPropertyShapeConfig.datatype` | `NodeReferenceValue \| string` | `datatype: xsd.integer` | +| `.for(id)` / `.forAll(ids)` | `string \| NodeReferenceValue` | `.for({id: '...'})` | +| `.where(...).equals(val)` | `JSNonNullPrimitive \| NodeReferenceValue` | `.equals({id: '...'})` | + +### Relevant code + +- `src/utils/Prefix.ts` — prefix registry with `toFull()`, `toPrefixed()` +- `src/utils/NodeReference.ts` — `NodeReferenceValue = {id: string}`, `toNodeReference()` +- `src/shapes/SHACL.ts` — `toPlainNodeRef()`, `createPropertyShape()` +- `src/paths/normalizePropertyPath.ts` — already handles prefixed strings for paths +- `src/paths/PropertyPathExpr.ts` — `PathRef = string | {id: string}` + +## Goals + +Let users write prefixed names anywhere they currently write full IRIs in decorator configs, shape definitions, and query inputs. For example: + +```ts +// Before +static targetClass = {id: 'http://xmlns.com/foaf/0.1/Person'}; +@literalProperty({path: {id: 'http://xmlns.com/foaf/0.1/name'}, datatype: {id: 'http://www.w3.org/2001/XMLSchema#string'}}) + +// After +static targetClass = 'foaf:Person'; +@literalProperty({path: 'foaf:name', datatype: 'xsd:string'}) +``` + +## Open Questions + +- [ ] 1. Where should prefix resolution happen — at decoration time (eager) or at consumption time (lazy)? +- [ ] 2. Should `NodeReferenceValue` be widened to accept strings, or add a new input type that resolves to `NodeReferenceValue`? +- [ ] 3. How to handle `targetClass` — it's a static class property typed as `NodeReferenceValue`, widening its type has broad implications +- [ ] 4. Should `PathRef` in the PathExpr AST resolve prefixed strings to full IRIs, or continue storing them raw? +- [ ] 5. Error behavior — what happens when a prefixed name references an unregistered prefix? +- [ ] 6. Scope — which input points to include in this change? + +## Decisions + +| # | Decision | Chosen | Rationale | +|---|----------|--------|-----------| +| 1 | Where should prefix resolution happen? | (C) Enhance `Prefix` singleton — resolve at decoration/normalization time using `Prefix.toFullIfPossible()` | Core resolution logic stays centralized in `Prefix`. Eager resolution at decoration time means all downstream code sees full IRIs. `toFullIfPossible` already exists. | +| 2 | Should `NodeReferenceValue` be widened? | (B) Keep `NodeReferenceValue` internal, widen only input types | `NodeReferenceInput = NodeReferenceValue \| string` already exists. Resolve prefixed strings to `{id: fullIRI}` at the boundary via `toNodeReference`/`toPlainNodeRef`. Zero changes to internal code. | +| 3 | How to handle `targetClass`? | (C) Widen type, normalize immediately in `@linkedShape` | The `@linkedShape` decorator normalizes `targetClass` to `{id: string}` before storing. All runtime reads see `{id: string}`. | +| 4 | Should `PathRef` in PathExpr AST resolve prefixed strings? | (A) Resolve at normalization time | `normalizePropertyPath` calls `Prefix.toFullIfPossible()` on all string refs. AST becomes canonical (all full IRIs). Simplifies every downstream consumer. | +| 5 | Error behavior for unregistered prefixes? | (A) Throw immediately (fail-fast) | `Prefix.toFull()` already throws. Clear error message with the offending prefix. Surfaces errors at shape registration time. | +| 6 | Scope — which input points to include? | (B) Decorators + query API | `.for()` always takes a node ref (unambiguous). `{id: 'prefix:name'}` resolves because `{id}` signals IRI. Bare string `.equals('hello')` remains a literal. | + +## Notes + +- `Prefix.toFullIfPossible()` already exists in the codebase — no new method needed on Prefix itself +- `toNodeReference` and `toPlainNodeRef` are the two normalization functions that need prefix resolution added +- The `in` field in PropertyShapeConfig is `NodeReferenceValue[]` — needs widening to `(NodeReferenceValue | string)[]` +- `collectPathUris` currently skips prefixed-name strings — after D4 resolution, all refs are full IRIs, fixing this gap diff --git a/docs/ideas/015-shacl-rdf-serialization.md b/docs/ideas/015-shacl-rdf-serialization.md new file mode 100644 index 0000000..8b5ad7a --- /dev/null +++ b/docs/ideas/015-shacl-rdf-serialization.md @@ -0,0 +1,165 @@ +# SHACL Shape RDF Serialization — Ideation + +## Context + +NodeShape and PropertyShape objects hold rich SHACL metadata: `class`, `datatype`, `nodeKind`, `minCount`, `maxCount`, `equals`, `disjoint`, `hasValue`, `in`, `lessThan`, `lessThanOrEquals`, `name`, `description`, `order`, `group`, `path` (including complex property path expressions), and `valueShape`. + +Today this metadata is: +- Set via decorators (`@literalProperty`, `@objectProperty`, `@linkedShape`) +- Stored on `PropertyShape` instances +- Exposed via `nodeShape.properties` → `getResult()` +- Consumed by: nothing internal. External consumers read `getResult()` for form generation, UI, etc. + +The metadata is **never serialized to actual SHACL RDF**. If a user wants to load these shapes into a SHACL-validation-enabled triplestore (GraphDB, Stardog, etc.), they must manually write the SHACL RDF — defeating the purpose of having the metadata in code. + +### Related work + +- `src/paths/serializePathToSHACL.ts` already serializes `PathExpr` to SHACL RDF triples (`sh:inversePath`, `sh:alternativePath`, etc.) +- `Shape.create(metadata)` uses the query engine's `CreateBuilder` to generate INSERT SPARQL for data instances +- The SHACL ontology is partially defined in `src/ontologies/shacl.ts` (exports `sh:NodeShape`, `sh:PropertyShape`, `sh:path`, etc.) + +## Goals + +1. Serialize any `NodeShape` (with its `PropertyShape`s) to SHACL-compliant RDF +2. Reuse the existing query/mutation infrastructure where possible (e.g., `CreateBuilder`, INSERT generation) +3. Support round-tripping: shapes defined in code → SHACL RDF → loadable into a triplestore for validation + +## Routes + +### Route A: Dedicated serializer function + +A standalone `serializeNodeShapeToRDF(shape: NodeShape): Triple[]` function that walks the shape and its property shapes, producing RDF triples directly. + +**Approach:** +- New file `src/shapes/serializeShapeToSHACL.ts` +- Maps each PropertyShape field to its SHACL predicate: + - `path` → `sh:path` (delegates to existing `serializePathToSHACL`) + - `class` → `sh:class` + - `datatype` → `sh:datatype` + - `nodeKind` → `sh:nodeKind` + - `minCount` → `sh:minCount` + - `maxCount` → `sh:maxCount` + - `equals` → `sh:equals` + - `disjoint` → `sh:disjoint` + - `hasValue` → `sh:hasValue` + - `in` → `sh:in` (RDF list) + - `lessThan` → `sh:lessThan` + - `lessThanOrEquals` → `sh:lessThanOrEquals` + - `name` → `sh:name` + - `description` → `sh:description` + - `order` → `sh:order` + - `group` → `sh:group` + - `valueShape` → `sh:node` +- NodeShape level: + - `rdf:type sh:NodeShape` + - `sh:targetClass` → the shape's `targetClass` + - `sh:property` → blank node per PropertyShape + +**Pros:** +- Simple, explicit, easy to test +- No dependency on query engine internals +- Full control over blank node generation (for `sh:in` RDF lists, complex paths, etc.) + +**Cons:** +- Doesn't reuse the existing mutation/query infrastructure +- Parallel triple-generation logic that could drift from the query engine + +### Route B: Shape-as-data via `NodeShape.create()` + +Define a SHACL meta-shape (a Shape class whose instances ARE NodeShapes) and use the existing `Shape.create()` / `CreateBuilder` pipeline to generate INSERT SPARQL for shape definitions. + +**Approach:** +```ts +// A Shape class that describes SHACL NodeShapes themselves +@linkedShape({targetClass: shacl.NodeShape}) +class SHACLNodeShape extends Shape { + @objectProperty({path: shacl.targetClass}) + targetClass: NodeReferenceValue; + + @objectProperty({path: shacl.property, shape: SHACLPropertyShape}) + properties: SHACLPropertyShape[]; +} + +@linkedShape({targetClass: shacl.PropertyShape}) +class SHACLPropertyShape extends Shape { + @objectProperty({path: shacl.path}) + path: NodeReferenceValue; + + @literalProperty({path: shacl.minCount, datatype: xsd.integer}) + minCount: number; + // ... etc +} + +// Usage: serialize a shape by creating an instance of the meta-shape +const triples = SHACLNodeShape.create({ + targetClass: personShape.targetClass, + properties: personShape.propertyShapes.map(ps => ({ + path: ps.path, + minCount: ps.minCount, + // ... + })) +}); +``` + +**Pros:** +- Reuses the engine's own mutation pipeline — dogfooding +- The SHACL shape definition IS a linked data shape, which is conceptually elegant +- Gets INSERT SPARQL for free — can directly push shapes to a triplestore +- Validates the engine's own capabilities (can it describe itself?) + +**Cons:** +- Complex property paths (`PathExpr`) don't map cleanly to simple property values — `sh:path` can be a blank node tree (sequences, inverses, etc.), which `CreateBuilder` may not handle +- `sh:in` requires RDF lists (blank node chains), which `CreateBuilder` likely doesn't support +- The meta-shape approach may hit edge cases in the engine that aren't designed for self-description +- More complex to implement and test + +### Route C: Hybrid — meta-shape for simple fields, serializer for complex ones + +Use Route B's meta-shape approach for the straightforward scalar fields (`minCount`, `maxCount`, `name`, `class`, `datatype`, etc.) and fall back to the dedicated serializer (Route A) for complex structures (`sh:path` with property path expressions, `sh:in` with RDF lists). + +**Pros:** +- Dogfoods the engine where it works well +- Handles complex RDF structures correctly +- Tests the engine's capabilities while acknowledging its current limits + +**Cons:** +- Two code paths for one feature +- More complex than either pure approach + +## Considerations + +### What output format? + +- **Triples array**: `{subject, predicate, object}[]` — most flexible, can be serialized to any RDF format +- **INSERT SPARQL**: Ready to execute against a triplestore — natural fit with `CreateBuilder` +- **Turtle string**: Human-readable, good for debugging and config files +- **JSON-LD**: Matches the library's JSON-oriented approach +- Could support multiple: generate triples internally, offer serializers to different formats + +### Blank node handling + +SHACL property shapes are typically blank nodes (anonymous). Complex paths, `sh:in` lists, and `sh:or`/`sh:and` groups all use blank node structures. The serializer needs a blank node ID generator. + +### Named graph placement + +SHACL shapes are often stored in a separate named graph (e.g., ``) so they don't mix with instance data. The serialization should support specifying a target graph. + +### Incremental / partial serialization + +Should users be able to serialize a single PropertyShape independently? Or always a full NodeShape with all its properties? Probably both — individual PropertyShape serialization is useful for testing and for adding constraints incrementally. + +### The `sh:in` RDF list problem + +`sh:in` values are serialized as RDF lists (linked blank nodes with `rdf:first`/`rdf:rest`). This is a common pain point. The serializer needs an RDF list builder utility. This same utility would be useful for `sh:path` sequences (which `serializePathToSHACL.ts` already handles). + +### Completeness of `src/ontologies/shacl.ts` + +The current SHACL ontology file may not export all needed predicates. Need to verify it covers: `sh:targetClass`, `sh:property`, `sh:path`, `sh:class`, `sh:datatype`, `sh:nodeKind`, `sh:minCount`, `sh:maxCount`, `sh:equals`, `sh:disjoint`, `sh:hasValue`, `sh:in`, `sh:lessThan`, `sh:lessThanOrEquals`, `sh:name`, `sh:description`, `sh:order`, `sh:group`, `sh:node`, `sh:closed`, `sh:ignoredProperties`. + +## Open Questions + +1. Should this be a method on NodeShape (`nodeShape.toSHACL()`) or a standalone function? +2. Should we support reading/importing SHACL RDF back into NodeShape objects (round-trip)? +3. Which output format(s) to support initially? +4. Should the serialized output include `sh:closed` / `sh:ignoredProperties` based on shape configuration? +5. How does this interact with shape inheritance (subclasses)? diff --git a/docs/reports/011-shacl-property-paths-and-prefix-resolution.md b/docs/reports/011-shacl-property-paths-and-prefix-resolution.md new file mode 100644 index 0000000..cfef97d --- /dev/null +++ b/docs/reports/011-shacl-property-paths-and-prefix-resolution.md @@ -0,0 +1,190 @@ +# Report: SHACL Property Paths & Prefix Resolution + +**Plans:** `013-shacl-property-paths`, `014-prefixed-uris-in-json` +**Ideation docs:** `docs/ideas/013-shacl-property-paths.md`, `docs/ideas/014-prefixed-uris-in-json.md` +**Deferred work:** `docs/ideas/015-shacl-rdf-serialization.md` + +## Summary + +Added full SPARQL property path support in property decorators with end-to-end pipeline from decorator config through SHACL serialization and SPARQL generation. Also added strict prefix resolution in the query API (`.for()`, `.forAll()`), fixed SHACL constraint field type semantics, and wired up missing `lessThan`/`lessThanOrEquals` fields. + +## Architecture Overview + +### Property Path Pipeline + +``` +Decorator config string → parsePropertyPath() → PathExpr AST + ↓ +PropertyPathDecoratorInput → normalizePropertyPath() → PathExpr (canonical) + ↓ + PropertyShape.path + ↓ + ┌───────────────┼───────────────┐ + ↓ ↓ ↓ + serializePathToSHACL IRDesugar pathExprToSparql + (RDF triples) (DesugaredStep) (SPARQL string) + ↓ + IRLower + (IRTraversePattern) + ↓ + irToAlgebra + (SparqlTerm 'path') + ↓ + algebraToString + (final SPARQL) +``` + +### PathExpr AST + +Discriminated-object union representing all SPARQL property path forms: + +```ts +type PathRef = string | {id: string}; +type PathExpr = + | PathRef + | {seq: PathExpr[]} + | {alt: PathExpr[]} + | {inv: PathExpr} + | {zeroOrMore: PathExpr} + | {oneOrMore: PathExpr} + | {zeroOrOne: PathExpr} + | {negatedPropertySet: (PathRef | {inv: PathRef})[]}; +``` + +Simple `PathRef` values (single IRI) are backward-compatible with the pre-existing `{id: string}` pattern used throughout the codebase. + +## New Files + +### `src/paths/PropertyPathExpr.ts` +- **PathExpr/PathRef types** — canonical AST for all SPARQL property path forms +- **`parsePropertyPath(input: string): PathExpr`** — recursive-descent parser supporting sequence (`/`), alternative (`|`), inverse (`^`), repetition (`*`, `+`, `?`), negatedPropertySet (`!`), grouping (`()`), and angle-bracket IRIs (`<...>`) +- **`isPathRef()`, `isComplexPathExpr()`** — type guards +- **`PATH_OPERATOR_CHARS`** — regex for detecting path operators in strings + +### `src/paths/normalizePropertyPath.ts` +- **`normalizePropertyPath(input: PropertyPathDecoratorInput): PathExpr`** — normalizes any input form to canonical PathExpr: strings with operators are parsed, arrays become `{seq}`, `{id}` and structured PathExpr pass through +- **`PropertyPathDecoratorInput`** — union type: `string | {id} | PathExpr | array` +- **`getSimplePathId(expr: PathExpr): string | null`** — extracts IRI from simple paths (backward compat helper) + +### `src/paths/pathExprToSparql.ts` +- **`pathExprToSparql(expr: PathExpr): string`** — renders PathExpr to SPARQL property path syntax with correct precedence parenthesization +- **`collectPathUris(expr: PathExpr): string[]`** — walks AST collecting full IRIs for PREFIX block generation +- Uses `formatUri()` from sparqlUtils for full-IRI-to-prefixed-form rendering + +### `src/paths/serializePathToSHACL.ts` +- **`serializePathToSHACL(expr: PathExpr): SHACLPathResult`** — serializes PathExpr to SHACL RDF triples using blank nodes and RDF lists per SHACL spec +- Supports: sequence (RDF list), alternative (`sh:alternativePath`), inverse (`sh:inversePath`), zeroOrMore/oneOrMore/zeroOrOne (`sh:*Path`) +- Throws on `negatedPropertySet` (no SHACL representation) +- **`resetBlankNodeCounter()`** — for deterministic testing + +## Modified Files + +### `src/shapes/SHACL.ts` +- **`PropertyShape.path`** type changed from `PropertyPathInputList` to `PathExpr` +- **`PropertyShape.lessThan`**, **`PropertyShape.lessThanOrEquals`** — new fields, wired in `createPropertyShape` +- **`PropertyShape.hasValueConstraint`** — widened to `NodeReferenceValue | string | number | boolean` +- **`PropertyShape.in`** — widened to `(NodeReferenceValue | string | number | boolean)[]` +- **`PropertyShapeResult`** — new typed interface for `getResult()` output, exposes all constraint fields +- **`createPropertyShape`** — processes `hasValue`/`in` with type dispatch (literals pass through, `{id}` objects go through `toNodeReference`); reads `lessThan`/`lessThanOrEquals` from config +- **`getResult()`** — uses `!== undefined` for `hasValue` to handle falsy literals (0, false, "") +- Removed `toPlainNodeRef` wrapper (was just delegating to `toNodeReference`) +- Removed `normalizePathInput` wrapper (was just delegating to `normalizePropertyPath`) +- Merged duplicate `NodeReferenceValue` import + +### `src/utils/NodeReference.ts` +- **`resolvePrefixedUri(str: string): string`** — lenient resolver using `Prefix.toFullIfPossible()`. Passes through full IRIs, plain IDs, and unregistered prefixes unchanged. +- **`resolveUriOrThrow(str: string): string`** — strict resolver using `Prefix.toFull()`. Throws on unknown prefixes. Used at query API boundaries. +- **`toNodeReference`** — simple wrap only (no prefix resolution). String → `{id}`, `{id}` → pass through. + +### `src/queries/QueryBuilder.ts` +- `.for()` and `.forAll()` use `resolveUriOrThrow` for strict prefix resolution on string inputs + +### `src/queries/IntermediateRepresentation.ts` +- `IRTraversePattern` — added optional `pathExpr: PathExpr` field +- `IRPropertyExpression` — added optional `pathExpr` field + +### `src/queries/IRDesugar.ts` +- `DesugaredPropertyStep` — added optional `pathExpr: PathExpr` field +- `segmentsToSteps()` — attaches `pathExpr` from `PropertyShape.path` when complex +- `desugarEntry()` main code path — same `pathExpr` attachment +- `toSortBy()` — same pattern, fixes sortBy with complex paths + +### `src/queries/IRLower.ts` +- `getOrCreateTraversal()` — propagates `pathExpr` to `IRTraversePattern` +- `resolveTraversal` callback signature widened to accept optional `pathExpr` + +### `src/queries/IRProjection.ts` +- `resolveTraversal` signature widened for `pathExpr` +- `lowerSelectionPathExpression` — passes `pathExpr` through property step traversals and leaf `property_expr` nodes + +### `src/sparql/SparqlAlgebra.ts` +- `SparqlTerm` — added `{kind: 'path'; value: string; uris: string[]}` variant + +### `src/sparql/irToAlgebra.ts` +- Three locations emit `path` terms when `pathExpr` present: traverse patterns, optional property triples, and EXISTS patterns +- Uses `pathExprToSparql()` and `collectPathUris()` from pathExprToSparql.ts + +### `src/sparql/algebraToString.ts` +- `serializeTerm` handles `'path'` kind: returns raw value, collects URIs for PREFIX block + +### `src/ontologies/shacl.ts` +- Added SHACL path vocabulary constants: `alternativePath`, `inversePath`, `zeroOrMorePath`, `oneOrMorePath`, `zeroOrOnePath` + +### `src/utils/ShapeClass.ts` +- `resolveTargetClassId` — explicit falsy check instead of optional chaining (avoids edge case with `targetClass` being non-null but falsy) + +### `jest.config.js` +- Added new test files to test paths array + +## Key Design Decisions + +### 1. Stateless parser, downstream resolution +The parser produces raw strings — no prefix resolution. Resolution happens at SPARQL rendering time via `formatUri()`. This keeps the parser pure and avoids import-order dependencies with the Prefix registry. + +### 2. PathExpr embedded in IR traverse nodes +Complex paths are carried as `pathExpr` on `IRTraversePattern` and `IRPropertyExpression`. When present, SPARQL generation emits the path expression as the predicate. When absent, falls back to simple IRI predicate. This is additive — no existing IR consumers break. + +### 3. Prefix resolution scoped to query API only +After iteration 2, prefix resolution was removed from decorators/shapes. Rationale: decorators use ontology imports (`foaf.name`) which provide compile-time safety. The query API (`.for()`, `.forAll()`) uses `resolveUriOrThrow` (strict — throws on unknown prefix) since strings there are always URIs. + +### 4. Literal vs IRI discrimination in hasValue/in +`hasValue` and `in` accept mixed types: `{id: string}` for IRI nodes, plain `string | number | boolean` for literals. `createPropertyShape` dispatches on `typeof v === 'object'` to decide whether to wrap via `toNodeReference`. + +### 5. negatedPropertySet accepted in AST, rejected at SHACL boundary +The parser and AST accept `negatedPropertySet` (valid SPARQL). SHACL serialization throws because SHACL has no representation for it. SPARQL generation handles it correctly. + +## Test Coverage + +| Test File | Tests | Coverage | +|-----------|-------|---------| +| `property-path-parser.test.ts` | 34 | All 8 path forms, nested/grouped, error cases | +| `property-path-normalize.test.ts` | 16 | String/object/array inputs, operator detection | +| `property-path-shacl.test.ts` | 11 | All SHACL-supported forms + negatedPropertySet error | +| `property-path-sparql.test.ts` | 24 | E2E golden tests: each form, nested, PREFIX collection | +| `property-path-integration.test.ts` | 7 | Full decorator→SPARQL pipeline | +| `property-path-fuseki.test.ts` | 29 | Live Fuseki E2E (skipped without server) | +| `prefix-resolution.test.ts` | 15 | resolvePrefixedUri, toNodeReference, resolveUriOrThrow | +| `shacl-constraints.test.ts` | 21 | hasValue/in literals, lessThan/lessThanOrEquals, equals/disjoint | + +**Total new tests:** 157. **Full suite:** 993 passed, 0 failed (3 suites skipped — require Fuseki). + +## Resolved Gaps + +1. **Prefix resolution in path SPARQL** — `refToSparql()` uses `formatUri()` for full IRIs; `collectPathUris()` feeds URIs to PREFIX block via `SparqlTerm.uris` +2. **sortBy with complex paths** — `toSortBy()` now copies `pathExpr` from segments +3. **desugarEntry main path missing pathExpr** — both `segmentsToSteps` and the main desugar loop now attach pathExpr +4. **IRPropertyExpression leaf missing pathExpr** — added optional field, threaded through projection +5. **hasValue/in literal handling** — type dispatch preserves literal values, only wraps `{id}` objects +6. **lessThan/lessThanOrEquals not wired** — now read from config and exposed via `getResult()` +7. **Strict prefix resolution in query API** — `resolveUriOrThrow` throws on unknown prefixes (was lenient `toFullIfPossible`) + +## Known Limitations + +- **`QResult` type doesn't expose constraint fields** — `getResult()` returns runtime values but TypeScript type is limited. Pre-existing issue, not introduced here. +- **No SHACL RDF serialization for shape constraints** — constraint fields are stored correctly but not serialized to RDF. Tracked in `docs/ideas/015-shacl-rdf-serialization.md`. +- **Fuseki E2E tests require running server** — 29 tests in `property-path-fuseki.test.ts` are skipped when Fuseki is unavailable. + +## Deferred Work + +- **SHACL RDF serialization** (`docs/ideas/015-shacl-rdf-serialization.md`) — serialize full shapes (including constraints) to SHACL RDF triples. Three routes explored in ideation. +- **Builder API for property paths** — fluent API like `path.inv('foaf:knows').zeroOrMore()`. Deferred per ideation decision #6. diff --git a/jest.config.js b/jest.config.js index 9bc28b3..dcd4f57 100644 --- a/jest.config.js +++ b/jest.config.js @@ -34,6 +34,14 @@ module.exports = { '**/expression-node.test.ts', '**/expr-module.test.ts', '**/expression-types.test.ts', + '**/property-path-parser.test.ts', + '**/property-path-normalize.test.ts', + '**/property-path-shacl.test.ts', + '**/property-path-sparql.test.ts', + '**/property-path-fuseki.test.ts', + '**/property-path-integration.test.ts', + '**/prefix-resolution.test.ts', + '**/shacl-constraints.test.ts', ], testPathIgnorePatterns: ['/old/'], transform: { diff --git a/package-lock.json b/package-lock.json index 15a87d4..7b206d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@_linked/core", - "version": "1.1.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@_linked/core", - "version": "1.1.0", + "version": "2.1.0", "license": "MIT", "dependencies": { "next-tick": "^1.1.0", diff --git a/package.json b/package.json index e9b65e3..44ddada 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@_linked/core", - "version": "1.1.0", + "version": "2.1.0", "license": "MIT", "description": "Linked.js core query and SHACL shape DSL (copy-then-prune baseline)", "repository": { diff --git a/src/ontologies/shacl.ts b/src/ontologies/shacl.ts index 376fe67..5d02866 100644 --- a/src/ontologies/shacl.ts +++ b/src/ontologies/shacl.ts @@ -37,6 +37,12 @@ const node = ns('node'); const nodeKind = ns('nodeKind'); const Shape = ns('Shape'); +const alternativePath = ns('alternativePath'); +const inversePath = ns('inversePath'); +const zeroOrMorePath = ns('zeroOrMorePath'); +const oneOrMorePath = ns('oneOrMorePath'); +const zeroOrOnePath = ns('zeroOrOnePath'); + const BlankNode = ns('BlankNode'); const IRI = ns('IRI'); const Literal = ns('Literal'); @@ -133,4 +139,9 @@ export const shacl = { MaxLengthConstraintComponent, AbstractResult, result, + alternativePath, + inversePath, + zeroOrMorePath, + oneOrMorePath, + zeroOrOnePath, }; diff --git a/src/paths/PropertyPathExpr.ts b/src/paths/PropertyPathExpr.ts new file mode 100644 index 0000000..d32b4e6 --- /dev/null +++ b/src/paths/PropertyPathExpr.ts @@ -0,0 +1,259 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// --------------------------------------------------------------------------- +// PathExpr AST types +// --------------------------------------------------------------------------- + +/** A simple path reference — either a raw string (prefixed/IRI) or a node ref. */ +export type PathRef = string | {id: string}; + +/** Discriminated-object union for all SPARQL property path forms. */ +export type PathExpr = + | PathRef + | {seq: PathExpr[]} + | {alt: PathExpr[]} + | {inv: PathExpr} + | {zeroOrMore: PathExpr} + | {oneOrMore: PathExpr} + | {zeroOrOne: PathExpr} + | {negatedPropertySet: (PathRef | {inv: PathRef})[]}; + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +export const isPathRef = (expr: PathExpr): expr is PathRef => + typeof expr === 'string' || (typeof expr === 'object' && expr !== null && 'id' in expr && Object.keys(expr).length === 1); + +export const isComplexPathExpr = (expr: PathExpr): boolean => !isPathRef(expr); + +// --------------------------------------------------------------------------- +// Parser — recursive-descent for SPARQL property path grammar +// --------------------------------------------------------------------------- + +/** + * Characters that signal a string contains path operators and should be parsed. + * Used by the normalizer to decide whether to invoke the parser. + */ +export const PATH_OPERATOR_CHARS = /[/|^*+?()!<]/; + +class PathParser { + private pos = 0; + private readonly input: string; + + constructor(input: string) { + this.input = input; + } + + parse(): PathExpr { + this.skipWhitespace(); + const result = this.parseAlt(); + this.skipWhitespace(); + if (this.pos < this.input.length) { + this.error(`Unexpected character '${this.input[this.pos]}'`); + } + return result; + } + + // alt = seq ( '|' seq )* + private parseAlt(): PathExpr { + const first = this.parseSeq(); + const branches: PathExpr[] = [first]; + while (this.peek() === '|') { + this.advance(); // consume '|' + this.skipWhitespace(); + branches.push(this.parseSeq()); + } + return branches.length === 1 ? branches[0] : {alt: branches}; + } + + // seq = unary ( '/' unary )* + private parseSeq(): PathExpr { + const first = this.parseUnary(); + const steps: PathExpr[] = [first]; + while (this.peek() === '/') { + this.advance(); // consume '/' + this.skipWhitespace(); + steps.push(this.parseUnary()); + } + return steps.length === 1 ? steps[0] : {seq: steps}; + } + + // unary = '^' unary | primary ( '*' | '+' | '?' )? + private parseUnary(): PathExpr { + this.skipWhitespace(); + if (this.peek() === '^') { + this.advance(); // consume '^' + this.skipWhitespace(); + const inner = this.parseUnary(); + return {inv: inner}; + } + let expr = this.parsePrimary(); + this.skipWhitespace(); + const postfix = this.peek(); + if (postfix === '*') { + this.advance(); + expr = {zeroOrMore: expr}; + } else if (postfix === '+') { + this.advance(); + expr = {oneOrMore: expr}; + } else if (postfix === '?') { + this.advance(); + expr = {zeroOrOne: expr}; + } + this.skipWhitespace(); + return expr; + } + + // primary = '(' alt ')' | '!' negatedPropertySet | iri + private parsePrimary(): PathExpr { + this.skipWhitespace(); + const ch = this.peek(); + + // Grouped expression + if (ch === '(') { + this.advance(); // consume '(' + this.skipWhitespace(); + const inner = this.parseAlt(); + this.skipWhitespace(); + if (this.peek() !== ')') { + this.error("Expected ')'"); + } + this.advance(); // consume ')' + return inner; + } + + // Negated property set + if (ch === '!') { + this.advance(); // consume '!' + this.skipWhitespace(); + return this.parseNegatedPropertySet(); + } + + // IRI or prefixed name + return this.parseIri(); + } + + // negatedPropertySet = '(' negatedItem ( '|' negatedItem )* ')' | negatedItem + private parseNegatedPropertySet(): PathExpr { + this.skipWhitespace(); + if (this.peek() === '(') { + this.advance(); // consume '(' + this.skipWhitespace(); + const items = this.parseNegatedItems(); + this.skipWhitespace(); + if (this.peek() !== ')') { + this.error("Expected ')' in negated property set"); + } + this.advance(); // consume ')' + return {negatedPropertySet: items}; + } + // Single negated item + const item = this.parseNegatedItem(); + return {negatedPropertySet: [item]}; + } + + private parseNegatedItems(): (PathRef | {inv: PathRef})[] { + const items: (PathRef | {inv: PathRef})[] = [this.parseNegatedItem()]; + while (this.peek() === '|') { + this.advance(); // consume '|' + this.skipWhitespace(); + items.push(this.parseNegatedItem()); + } + return items; + } + + private parseNegatedItem(): PathRef | {inv: PathRef} { + this.skipWhitespace(); + if (this.peek() === '^') { + this.advance(); // consume '^' + this.skipWhitespace(); + const ref = this.parseIri(); + return {inv: ref}; + } + return this.parseIri(); + } + + // iri = '<' chars '>' | prefixedName + private parseIri(): string { + this.skipWhitespace(); + if (this.peek() === '<') { + this.advance(); // consume '<' + const start = this.pos; + while (this.pos < this.input.length && this.input[this.pos] !== '>') { + this.pos++; + } + if (this.pos >= this.input.length) { + this.error("Expected '>' to close IRI"); + } + const iri = this.input.slice(start, this.pos); + this.advance(); // consume '>' + return iri; + } + return this.parsePrefixedName(); + } + + // prefixedName = PNAME_NS PNAME_LOCAL | PNAME_NS + // We accept any characters until we hit a path operator or whitespace or end + private parsePrefixedName(): string { + this.skipWhitespace(); + const start = this.pos; + while (this.pos < this.input.length && !this.isTerminator(this.input[this.pos])) { + this.pos++; + } + if (this.pos === start) { + this.error('Expected IRI or prefixed name'); + } + return this.input.slice(start, this.pos); + } + + private isTerminator(ch: string): boolean { + return ch === '/' || ch === '|' || ch === '*' || ch === '+' || ch === '?' || + ch === '(' || ch === ')' || ch === '^' || ch === '!' || ch === ' ' || + ch === '\t' || ch === '\n' || ch === '\r'; + } + + private peek(): string | undefined { + return this.pos < this.input.length ? this.input[this.pos] : undefined; + } + + private advance(): void { + this.pos++; + } + + private skipWhitespace(): void { + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + this.pos++; + } else { + break; + } + } + } + + private error(message: string): never { + throw new Error( + `Property path parse error at position ${this.pos}: ${message} (input: "${this.input}")`, + ); + } +} + +/** + * Parse a SPARQL property path string into a PathExpr AST. + * + * Supports: sequence (/), alternative (|), inverse (^), zeroOrMore (*), + * oneOrMore (+), zeroOrOne (?), negatedPropertySet (!), and grouping (()). + * + * Does NOT resolve prefixes — raw strings are preserved in the AST. + */ +export function parsePropertyPath(input: string): PathExpr { + if (!input || input.trim().length === 0) { + throw new Error('Property path input must not be empty'); + } + return new PathParser(input.trim()).parse(); +} diff --git a/src/paths/normalizePropertyPath.ts b/src/paths/normalizePropertyPath.ts new file mode 100644 index 0000000..88a5868 --- /dev/null +++ b/src/paths/normalizePropertyPath.ts @@ -0,0 +1,82 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import {PathExpr, parsePropertyPath, PATH_OPERATOR_CHARS} from './PropertyPathExpr.js'; + +/** + * Input type for property path decorators. + * Accepts all forms: string, {id}, array (sequence shorthand), or PathExpr. + */ +export type PropertyPathDecoratorInput = + | string + | {id: string} + | PropertyPathDecoratorInput[] + | PathExpr; + +/** Path expression operator keys used to detect structured PathExpr objects. */ +const PATH_EXPR_KEYS = new Set(['seq', 'alt', 'inv', 'zeroOrMore', 'oneOrMore', 'zeroOrOne', 'negatedPropertySet']); + +/** Check if an object is a structured PathExpr (not a plain {id} ref). */ +const isStructuredPathExpr = (value: unknown): boolean => { + if (typeof value !== 'object' || value === null) return false; + return Object.keys(value).some((key) => PATH_EXPR_KEYS.has(key)); +}; + +/** + * Normalize any property path decorator input into a canonical PathExpr. + * + * - `string` without path operators → preserved as-is (a PathRef) + * - `string` with operators → parsed via `parsePropertyPath` + * - `{id: string}` → preserved as PathRef + * - `PathExpr` structured object → passed through + * - `Array` → converted to `{seq: [...]}` + */ +export function normalizePropertyPath(input: PropertyPathDecoratorInput): PathExpr { + let result: PathExpr; + + // String input + if (typeof input === 'string') { + if (PATH_OPERATOR_CHARS.test(input)) { + result = parsePropertyPath(input); + } else { + result = input; + } + } + // Array → sequence shorthand + else if (Array.isArray(input)) { + const normalized = input.map((item) => normalizePropertyPath(item)); + result = normalized.length === 1 ? normalized[0] : {seq: normalized}; + } + // Object + else if (typeof input === 'object' && input !== null) { + // Structured PathExpr (has seq, alt, inv, etc.) + if (isStructuredPathExpr(input)) { + result = input as PathExpr; + } + // Plain {id} ref + else if ('id' in input) { + result = input as {id: string}; + } else { + throw new Error(`Invalid property path input: ${JSON.stringify(input)}`); + } + } else { + throw new Error(`Invalid property path input: ${JSON.stringify(input)}`); + } + + return result; +} + +/** + * Check whether a PathExpr is a simple single-IRI path (backward-compatible form). + * Returns the IRI string if simple, or null if complex. + */ +export function getSimplePathId(expr: PathExpr): string | null { + if (typeof expr === 'string') return expr; + if (typeof expr === 'object' && expr !== null && 'id' in expr && !isStructuredPathExpr(expr)) { + return (expr as {id: string}).id; + } + return null; +} diff --git a/src/paths/pathExprToSparql.ts b/src/paths/pathExprToSparql.ts new file mode 100644 index 0000000..cc354c6 --- /dev/null +++ b/src/paths/pathExprToSparql.ts @@ -0,0 +1,124 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import type {PathExpr, PathRef} from './PropertyPathExpr.js'; +import {isPathRef} from './PropertyPathExpr.js'; +import {formatUri} from '../sparql/sparqlUtils.js'; + +// --------------------------------------------------------------------------- +// Precedence levels (higher = tighter binding) +// --------------------------------------------------------------------------- +const PREC_ALT = 1; +const PREC_SEQ = 2; +const PREC_UNARY = 3; +const PREC_PRIMARY = 4; + +function refToSparql(ref: PathRef): string { + if (typeof ref === 'string') { + // If it looks like a full IRI (contains ://), use formatUri for prefix shortening + if (ref.includes('://')) return formatUri(ref); + // Otherwise treat as prefixed name (already in prefix:local form) + return ref; + } + // {id} refs always contain full IRIs — use formatUri for prefix shortening + return formatUri(ref.id); +} + +/** + * Collect all full IRIs from a PathExpr AST. + * Returns IRIs that need PREFIX declarations (full URIs from string refs + * containing `://` and from `{id}` refs). Does not collect prefixed-name + * string refs since those are already in prefix:local form. + */ +export function collectPathUris(expr: PathExpr): string[] { + const uris: string[] = []; + walkPathExpr(expr, uris); + return uris; +} + +function collectRef(ref: PathRef, uris: string[]): void { + if (typeof ref === 'string') { + if (ref.includes('://')) uris.push(ref); + } else { + uris.push(ref.id); + } +} + +function walkPathExpr(expr: PathExpr, uris: string[]): void { + if (isPathRef(expr)) { + collectRef(expr, uris); + return; + } + if ('seq' in expr) { for (const e of expr.seq) walkPathExpr(e, uris); return; } + if ('alt' in expr) { for (const e of expr.alt) walkPathExpr(e, uris); return; } + if ('inv' in expr) { walkPathExpr(expr.inv, uris); return; } + if ('zeroOrMore' in expr) { walkPathExpr(expr.zeroOrMore, uris); return; } + if ('oneOrMore' in expr) { walkPathExpr(expr.oneOrMore, uris); return; } + if ('zeroOrOne' in expr) { walkPathExpr(expr.zeroOrOne, uris); return; } + if ('negatedPropertySet' in expr) { + for (const item of expr.negatedPropertySet) { + if (typeof item === 'string' || (typeof item === 'object' && 'id' in item && !('inv' in item))) { + collectRef(item as PathRef, uris); + } else { + collectRef((item as {inv: PathRef}).inv, uris); + } + } + } +} + +/** + * Render a PathExpr to SPARQL property path syntax. + * Handles all forms including negatedPropertySet. + * Adds parentheses only when needed for correct precedence. + */ +export function pathExprToSparql(expr: PathExpr): string { + return renderExpr(expr, 0); +} + +function renderExpr(expr: PathExpr, parentPrec: number): string { + if (isPathRef(expr)) { + return refToSparql(expr); + } + + if ('seq' in expr) { + const inner = expr.seq.map((e) => renderExpr(e, PREC_SEQ)).join('/'); + return parentPrec > PREC_SEQ ? `(${inner})` : inner; + } + + if ('alt' in expr) { + const inner = expr.alt.map((e) => renderExpr(e, PREC_ALT)).join('|'); + return parentPrec > PREC_ALT ? `(${inner})` : inner; + } + + if ('inv' in expr) { + return `^${renderExpr(expr.inv, PREC_UNARY)}`; + } + + if ('zeroOrMore' in expr) { + return `${renderExpr(expr.zeroOrMore, PREC_PRIMARY)}*`; + } + + if ('oneOrMore' in expr) { + return `${renderExpr(expr.oneOrMore, PREC_PRIMARY)}+`; + } + + if ('zeroOrOne' in expr) { + return `${renderExpr(expr.zeroOrOne, PREC_PRIMARY)}?`; + } + + if ('negatedPropertySet' in expr) { + const items = expr.negatedPropertySet.map((item) => { + if (typeof item === 'string' || (typeof item === 'object' && 'id' in item && !('inv' in item))) { + return refToSparql(item as PathRef); + } + const invItem = item as {inv: PathRef}; + return `^${refToSparql(invItem.inv)}`; + }); + return items.length === 1 ? `!${items[0]}` : `!(${items.join('|')})`; + } + + throw new Error(`Unknown PathExpr shape: ${JSON.stringify(expr)}`); +} diff --git a/src/paths/serializePathToSHACL.ts b/src/paths/serializePathToSHACL.ts new file mode 100644 index 0000000..96285ab --- /dev/null +++ b/src/paths/serializePathToSHACL.ts @@ -0,0 +1,163 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import type {PathExpr, PathRef} from './PropertyPathExpr.js'; +import {isPathRef} from './PropertyPathExpr.js'; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +export type SHACLTriple = { + subject: string; + predicate: string; + object: string; +}; + +export type SHACLPathResult = { + /** The root node of the serialized path (IRI or blank node id). */ + root: string; + /** Additional triples needed to describe the path structure. */ + triples: SHACLTriple[]; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SH = 'http://www.w3.org/ns/shacl#'; +const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + +const SH_ALTERNATIVE_PATH = `${SH}alternativePath`; +const SH_INVERSE_PATH = `${SH}inversePath`; +const SH_ZERO_OR_MORE_PATH = `${SH}zeroOrMorePath`; +const SH_ONE_OR_MORE_PATH = `${SH}oneOrMorePath`; +const SH_ZERO_OR_ONE_PATH = `${SH}zeroOrOnePath`; +const RDF_FIRST = `${RDF}first`; +const RDF_REST = `${RDF}rest`; +const RDF_NIL = `${RDF}nil`; + +// --------------------------------------------------------------------------- +// Serializer +// --------------------------------------------------------------------------- + +let blankNodeCounter = 0; + +/** Reset blank node counter (for deterministic testing). */ +export function resetBlankNodeCounter(): void { + blankNodeCounter = 0; +} + +function freshBlankNode(): string { + return `_:b${blankNodeCounter++}`; +} + +function refToIri(ref: PathRef): string { + if (typeof ref === 'string') return ref; + return ref.id; +} + +/** + * Serialize a PathExpr to SHACL RDF triples. + * + * - Simple PathRef → direct IRI (no extra triples) + * - Complex paths → blank nodes with SHACL path vocabulary + * - negatedPropertySet → throws (not supported by SHACL) + */ +export function serializePathToSHACL(expr: PathExpr): SHACLPathResult { + const triples: SHACLTriple[] = []; + + const serialize = (e: PathExpr): string => { + // Simple ref → IRI directly + if (isPathRef(e)) { + return refToIri(e); + } + + // Sequence → RDF list + if ('seq' in e) { + return buildRdfList(e.seq, triples); + } + + // Alternative → sh:alternativePath with RDF list + if ('alt' in e) { + const bnode = freshBlankNode(); + const listRoot = buildRdfList(e.alt, triples); + triples.push({subject: bnode, predicate: SH_ALTERNATIVE_PATH, object: listRoot}); + return bnode; + } + + // Inverse → sh:inversePath + if ('inv' in e) { + const bnode = freshBlankNode(); + const inner = serialize(e.inv); + triples.push({subject: bnode, predicate: SH_INVERSE_PATH, object: inner}); + return bnode; + } + + // zeroOrMore → sh:zeroOrMorePath + if ('zeroOrMore' in e) { + const bnode = freshBlankNode(); + const inner = serialize(e.zeroOrMore); + triples.push({subject: bnode, predicate: SH_ZERO_OR_MORE_PATH, object: inner}); + return bnode; + } + + // oneOrMore → sh:oneOrMorePath + if ('oneOrMore' in e) { + const bnode = freshBlankNode(); + const inner = serialize(e.oneOrMore); + triples.push({subject: bnode, predicate: SH_ONE_OR_MORE_PATH, object: inner}); + return bnode; + } + + // zeroOrOne → sh:zeroOrOnePath + if ('zeroOrOne' in e) { + const bnode = freshBlankNode(); + const inner = serialize(e.zeroOrOne); + triples.push({subject: bnode, predicate: SH_ZERO_OR_ONE_PATH, object: inner}); + return bnode; + } + + // negatedPropertySet → not supported in SHACL + if ('negatedPropertySet' in e) { + throw new Error( + 'negatedPropertySet cannot be serialized to SHACL sh:path. ' + + 'This path form is valid in SPARQL but has no SHACL representation.', + ); + } + + throw new Error(`Unknown PathExpr shape: ${JSON.stringify(e)}`); + }; + + /** + * Build an RDF list from an array of PathExpr elements. + * Returns the root blank node of the list. + */ + function buildRdfList(items: PathExpr[], out: SHACLTriple[]): string { + if (items.length === 0) return RDF_NIL; + + let current = freshBlankNode(); + const root = current; + + for (let i = 0; i < items.length; i++) { + const itemNode = serialize(items[i]); + out.push({subject: current, predicate: RDF_FIRST, object: itemNode}); + + if (i === items.length - 1) { + out.push({subject: current, predicate: RDF_REST, object: RDF_NIL}); + } else { + const next = freshBlankNode(); + out.push({subject: current, predicate: RDF_REST, object: next}); + current = next; + } + } + + return root; + } + + const root = serialize(expr); + return {root, triples}; +} diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index 977390d..b709042 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -15,6 +15,8 @@ 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'; +import type {PathExpr} from '../paths/PropertyPathExpr.js'; +import {isComplexPathExpr} from '../paths/PropertyPathExpr.js'; /** * Pipeline input type — accepts FieldSet entries directly. @@ -47,6 +49,7 @@ export type RawSelectInput = { export type DesugaredPropertyStep = { kind: 'property_step'; propertyShapeId: string; + pathExpr?: PathExpr; where?: DesugaredWhere; }; @@ -174,10 +177,16 @@ const isNodeRef = (value: unknown): value is NodeReferenceValue => * Convert PropertyShape segments to DesugaredPropertyStep[]. */ const segmentsToSteps = (segments: PropertyShape[]): DesugaredPropertyStep[] => - segments.map((seg) => ({ - kind: 'property_step' as const, - propertyShapeId: seg.id, - })); + segments.map((seg) => { + const step: DesugaredPropertyStep = { + kind: 'property_step' as const, + propertyShapeId: seg.id, + }; + if (seg.path && isComplexPathExpr(seg.path)) { + step.pathExpr = seg.path; + } + return step; + }); /** * Convert a FieldSetEntry directly to a DesugaredSelection. @@ -230,6 +239,9 @@ const desugarEntry = (entry: FieldSetEntry): DesugaredSelection => { kind: 'property_step', propertyShapeId: segment.id, }; + if (segment.path && isComplexPathExpr(segment.path)) { + step.pathExpr = segment.path; + } if (entry.scopedFilter && i === segments.length - 1) { step.where = toWhere(entry.scopedFilter); } @@ -418,10 +430,16 @@ const toSortBy = (query: RawSelectInput): DesugaredSortBy | undefined => { direction: query.sortBy.direction, paths: query.sortBy.paths.map((path) => ({ kind: 'selection_path' as const, - steps: path.segments.map((seg) => ({ - kind: 'property_step' as const, - propertyShapeId: seg.id, - })), + steps: path.segments.map((seg) => { + const step: DesugaredPropertyStep = { + kind: 'property_step' as const, + propertyShapeId: seg.id, + }; + if (seg.path && isComplexPathExpr(seg.path)) { + step.pathExpr = seg.path; + } + return step; + }), })), }; }; diff --git a/src/queries/IRLower.ts b/src/queries/IRLower.ts index 02db069..0392334 100644 --- a/src/queries/IRLower.ts +++ b/src/queries/IRLower.ts @@ -30,6 +30,7 @@ import {canonicalizeWhere} from './IRCanonicalize.js'; import {lowerSelectionPathExpression, projectionKeyFromPath} from './IRProjection.js'; import {IRAliasScope} from './IRAliasScope.js'; import {NodeReferenceValue, ShapeReferenceValue} from './QueryFactory.js'; +import type {PathExpr} from '../paths/PropertyPathExpr.js'; /** * Creates a memoized traversal resolver that deduplicates (fromAlias, propertyShapeId) @@ -70,18 +71,22 @@ class LoweringContext { return `a${this.counter++}`; } - getOrCreateTraversal(fromAlias: string, propertyShapeId: string): string { + getOrCreateTraversal(fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr): string { const key = `${fromAlias}:${propertyShapeId}`; const existing = this.traverseMap.get(key); if (existing) return existing; const toAlias = this.nextAlias(); - this.patterns.push({ + const pattern: IRTraversePattern = { kind: 'traverse', from: fromAlias, to: toAlias, property: propertyShapeId, - }); + }; + if (pathExpr) { + pattern.pathExpr = pathExpr; + } + this.patterns.push(pattern); this.traverseMap.set(key, toAlias); return toAlias; } @@ -115,7 +120,7 @@ type AliasGenerator = { type PathLoweringOptions = { rootAlias: string; - resolveTraversal: (fromAlias: string, propertyShapeId: string) => string; + resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) => string; }; const isShapeRef = (value: unknown): value is ShapeReferenceValue => @@ -272,8 +277,8 @@ export const lowerSelectQuery = ( const ctx = new LoweringContext(); const pathOptions: PathLoweringOptions = { rootAlias: ctx.rootAlias, - resolveTraversal: (fromAlias: string, propertyShapeId: string) => - ctx.getOrCreateTraversal(fromAlias, propertyShapeId), + resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) => + ctx.getOrCreateTraversal(fromAlias, propertyShapeId, pathExpr), }; const root: IRShapeScanPattern = { @@ -286,7 +291,7 @@ export const lowerSelectQuery = ( let currentAlias = pathOptions.rootAlias; for (const step of steps) { if (step.kind === 'property_step') { - currentAlias = pathOptions.resolveTraversal(currentAlias, step.propertyShapeId); + currentAlias = pathOptions.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr); } } return currentAlias; diff --git a/src/queries/IRProjection.ts b/src/queries/IRProjection.ts index 621f81d..9d3c60c 100644 --- a/src/queries/IRProjection.ts +++ b/src/queries/IRProjection.ts @@ -1,6 +1,7 @@ import {DesugaredSelectionPath, DesugaredWhere} from './IRDesugar.js'; import {IRAliasScope} from './IRAliasScope.js'; import {IRExpression, IRProjectionItem, IRResultMapEntry} from './IntermediateRepresentation.js'; +import type {PathExpr} from '../paths/PropertyPathExpr.js'; /** * Callback invoked when a property step with an inline `.where()` is encountered @@ -14,7 +15,7 @@ export type InlineFilterCallback = ( export type ProjectionPathLoweringOptions = { rootAlias: string; - resolveTraversal: (fromAlias: string, propertyShapeId: string) => string; + resolveTraversal: (fromAlias: string, propertyShapeId: string, pathExpr?: PathExpr) => string; }; export type CanonicalProjectionResult = { @@ -66,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); + currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr); onInlineFilter(currentAlias, step.where); if (isLast) { return {kind: 'alias_expr', alias: currentAlias}; @@ -75,14 +76,18 @@ export const lowerSelectionPathExpression = ( } if (isLast) { - return { + const expr: IRExpression = { kind: 'property_expr', sourceAlias: currentAlias, property: step.propertyShapeId, }; + if (step.pathExpr) { + (expr as import('./IntermediateRepresentation.js').IRPropertyExpression).pathExpr = step.pathExpr; + } + return expr; } - currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId); + currentAlias = options.resolveTraversal(currentAlias, step.propertyShapeId, step.pathExpr); continue; } diff --git a/src/queries/IntermediateRepresentation.ts b/src/queries/IntermediateRepresentation.ts index a0f5175..dbda187 100644 --- a/src/queries/IntermediateRepresentation.ts +++ b/src/queries/IntermediateRepresentation.ts @@ -1,4 +1,5 @@ import {NodeReferenceValue} from './QueryFactory.js'; +import type {PathExpr} from '../paths/PropertyPathExpr.js'; export type IRDirection = 'ASC' | 'DESC'; export type IRAlias = string; @@ -64,6 +65,7 @@ export type IRTraversePattern = { from: IRAlias; to: IRAlias; property: string; + pathExpr?: PathExpr; filter?: IRExpression; }; @@ -125,6 +127,7 @@ export type IRPropertyExpression = { kind: 'property_expr'; sourceAlias: IRAlias; property: string; + pathExpr?: import('../paths/PropertyPathExpr.js').PathExpr; }; export type IRContextPropertyExpression = { diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index c8a11ef..689bb9c 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -16,6 +16,7 @@ import type {PropertyPathSegment, RawSelectInput} from './IRDesugar.js'; import {buildSelectQuery} from './IRPipeline.js'; import {getQueryDispatch} from './queryDispatch.js'; import type {NodeReferenceValue} from './QueryFactory.js'; +import {resolveUriOrThrow} from '../utils/NodeReference.js'; import {FieldSet, FieldSetJSON, FieldSetFieldJSON} from './FieldSet.js'; import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; @@ -234,7 +235,7 @@ export class QueryBuilder /** Target a single entity by ID. Implies singleResult; unwraps array Result type. */ for(id: string | NodeReferenceValue): QueryBuilder { - const subject: NodeReferenceValue = typeof id === 'string' ? {id} : id; + const subject: NodeReferenceValue = typeof id === 'string' ? {id: resolveUriOrThrow(id)} : id; return this.clone({subject, subjects: undefined, singleResult: true}) as any; } @@ -243,7 +244,7 @@ export class QueryBuilder if (!ids) { return this.clone({subject: undefined, subjects: undefined, singleResult: false}); } - const subjects = ids.map((id) => (typeof id === 'string' ? {id} : id)); + const subjects = ids.map((id) => typeof id === 'string' ? {id: resolveUriOrThrow(id)} : id); return this.clone({subject: undefined, subjects, singleResult: false}); } diff --git a/src/shapes/SHACL.ts b/src/shapes/SHACL.ts index 72d8144..6ea91b9 100644 --- a/src/shapes/SHACL.ts +++ b/src/shapes/SHACL.ts @@ -3,41 +3,46 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import {NodeReferenceValue} from '../utils/NodeReference.js'; +import {NodeReferenceValue, toNodeReference} from '../utils/NodeReference.js'; import {Shape, ShapeConstructor} from './Shape.js'; import {shacl} from '../ontologies/shacl.js'; import {URI} from '../utils/URI.js'; -import {toNodeReference} from '../utils/NodeReference.js'; -import {QResult} from '../queries/SelectQuery.js'; import {getShapeClass} from '../utils/ShapeClass.js'; +import type {PathExpr} from '../paths/PropertyPathExpr.js'; +import {normalizePropertyPath, type PropertyPathDecoratorInput} from '../paths/normalizePropertyPath.js'; export const LINCD_DATA_ROOT: string = 'https://data.lincd.org/'; type NodeKindConfig = NodeReferenceValue | NodeReferenceValue[]; -export type PropertyPathInput = NodeReferenceValue; -export type PropertyPathInputList = PropertyPathInput | PropertyPathInput[]; +export type PropertyPathInput = PropertyPathDecoratorInput; +export type PropertyPathInputList = PropertyPathDecoratorInput; -const toPlainNodeRef = ( - value: NodeReferenceValue | {id: string} | string, -): NodeReferenceValue => { - if (typeof value === 'string') { - return {id: value}; - } - if (value && typeof value === 'object' && 'id' in value) { - return {id: (value as NodeReferenceValue).id}; - } - return toNodeReference(value as NodeReferenceValue); -}; +/** Result object returned by PropertyShape.getResult() and NodeShape.properties. */ +export interface PropertyShapeResult { + id: string; + label: string; + path: PathExpr; + nodeKind?: NodeReferenceValue; + datatype?: NodeReferenceValue; + minCount?: number; + maxCount?: number; + name?: string; + description?: string; + order?: number; + group?: string; + class?: NodeReferenceValue; + in?: (NodeReferenceValue | string | number | boolean)[]; + equals?: NodeReferenceValue; + disjoint?: NodeReferenceValue; + lessThan?: NodeReferenceValue; + lessThanOrEquals?: NodeReferenceValue; + hasValue?: NodeReferenceValue | string | number | boolean; + defaultValue?: unknown; + sortBy?: PathExpr; + valueShape?: NodeReferenceValue; +} -const normalizePathInput = ( - value: PropertyPathInputList, -): PropertyPathInputList => { - if (Array.isArray(value)) { - return value.map((entry) => toPlainNodeRef(entry)); - } - return toPlainNodeRef(value); -}; const normalizeNodeKind = ( nodeKind?: NodeKindConfig, @@ -80,13 +85,15 @@ export interface NodeShapeConfig { export interface LiteralPropertyShapeConfig extends PropertyShapeConfig { nodeKind?: NodeReferenceValue; /** - * Values of the configured property must be less than the values of this 'lessThan' property + * Values of the configured property must be less than the values of this 'lessThan' property. + * Value is always a property IRI (pair constraint). */ - lessThan?: NodeReferenceValue | string; + lessThan?: NodeReferenceValue; /** - * Values of the configured property must be less than or equal the values of this 'lessThan' property + * Values of the configured property must be less than or equal the values of this 'lessThanOrEquals' property. + * Value is always a property IRI (pair constraint). */ - lessThanOrEquals?: NodeReferenceValue | string; + lessThanOrEquals?: NodeReferenceValue; /** * All values of this property must be higher than this number */ @@ -126,11 +133,12 @@ export interface LiteralPropertyShapeConfig extends PropertyShapeConfig { /** * Each literal value of this property must use this datatype */ - datatype?: NodeReferenceValue | string; + datatype?: NodeReferenceValue; /** - * Each value of the property must occur in this set + * Each value of the property must occur in this set. + * Use {id: '...'} for IRI nodes, or plain strings/numbers/booleans for literal values. */ - in?: NodeReferenceValue[]; + in?: (NodeReferenceValue | string | number | boolean)[]; } export interface ObjectPropertyShapeConfig extends PropertyShapeConfig { @@ -138,7 +146,7 @@ export interface ObjectPropertyShapeConfig extends PropertyShapeConfig { /** * Each value of this property must have this class as its rdf:type */ - class?: NodeReferenceValue | string; + class?: NodeReferenceValue; /** * The shape that values of this property path need to confirm to. * You need to provide a class that extends Shape. @@ -170,16 +178,19 @@ export interface PropertyShapeConfig { maxCount?: number; /** * Values of the configured property must equal the values of this 'equals' property. + * Value is always a property IRI (pair constraint). */ - equals?: NodeReferenceValue | string; + equals?: NodeReferenceValue; /** - * Values of the configured property must differ from the values of this 'disjoint' property + * Values of the configured property must differ from the values of this 'disjoint' property. + * Value is always a property IRI (pair constraint). */ - disjoint?: NodeReferenceValue | string; + disjoint?: NodeReferenceValue; /** - * At least one value of this property must equal the given Node + * At least one value of this property must equal the given value. + * Use {id: '...'} for IRI nodes, or a plain string for literal values. */ - hasValue?: NodeReferenceValue | string; + hasValue?: NodeReferenceValue | string | number | boolean; name?: string; description?: string; order?: number; @@ -189,9 +200,14 @@ export interface PropertyShapeConfig { */ defaultValue?: unknown; /** - * Each value of the property must occur in this set + * Each value of the property must occur in this set. + * Use {id: '...'} for IRI nodes, or plain strings for literal values. + * + * @example + * in: ['ACTIVE', 'PENDING', 'CLOSED'] + * in: [{id: 'http://example.org/StatusA'}, {id: 'http://example.org/StatusB'}] */ - in?: NodeReferenceValue[]; + in?: (NodeReferenceValue | string | number | boolean)[]; /** * Values of the configured property path are sorted by the values of this property path. */ @@ -238,7 +254,7 @@ export class NodeShape extends Shape { this._label = value; } - get properties(): QResult[] { + get properties(): PropertyShapeResult[] { return this.propertyShapes.map((propertyShape) => propertyShape.getResult()); } @@ -317,7 +333,7 @@ export class NodeShape extends Shape { export class PropertyShape extends Shape { static targetClass = shacl.PropertyShape; private _label?: string; - path: PropertyPathInputList; + path: PathExpr; nodeKind?: NodeReferenceValue; datatype?: NodeReferenceValue; minCount?: number; @@ -327,12 +343,14 @@ export class PropertyShape extends Shape { order?: number; group?: string; class?: NodeReferenceValue; - in?: NodeReferenceValue[]; + in?: (NodeReferenceValue | string | number | boolean)[]; equalsConstraint?: NodeReferenceValue; disjoint?: NodeReferenceValue; - hasValueConstraint?: NodeReferenceValue; + lessThan?: NodeReferenceValue; + lessThanOrEquals?: NodeReferenceValue; + hasValueConstraint?: NodeReferenceValue | string | number | boolean; defaultValue?: unknown; - sortBy?: PropertyPathInputList; + sortBy?: PathExpr; valueShape?: NodeReferenceValue; parentNodeShape?: NodeShape; @@ -348,8 +366,8 @@ export class PropertyShape extends Shape { this._label = value; } - getResult(): QResult { - const result: QResult> = { + getResult(): PropertyShapeResult { + const result: Record & {id: string; label: string; path: PathExpr} = { id: this.id, label: this.label, path: this.path, @@ -390,7 +408,13 @@ export class PropertyShape extends Shape { if (this.disjoint) { result.disjoint = this.disjoint; } - if (this.hasValueConstraint) { + if (this.lessThan) { + result.lessThan = this.lessThan; + } + if (this.lessThanOrEquals) { + result.lessThanOrEquals = this.lessThanOrEquals; + } + if (this.hasValueConstraint !== undefined) { result.hasValue = this.hasValueConstraint; } if (this.defaultValue !== undefined) { @@ -402,7 +426,7 @@ export class PropertyShape extends Shape { if (this.valueShape) { result.valueShape = this.valueShape; } - return result as QResult; + return result as PropertyShapeResult; } clone(): this { @@ -559,7 +583,7 @@ export function createPropertyShape< shapeClass: typeof Shape | [string, string] = null, ) { const propertyShape = new PropertyShape(); - propertyShape.path = normalizePathInput(config.path); + propertyShape.path = normalizePropertyPath(config.path); propertyShape.label = propertyKey; if (config.name) { @@ -583,34 +607,43 @@ export function createPropertyShape< (propertyShape as unknown as ExplicitFlags)[EXPLICIT_MAX_COUNT_SYMBOL] = config.maxCount !== undefined; if ((config as LiteralPropertyShapeConfig).datatype) { - propertyShape.datatype = toPlainNodeRef( + propertyShape.datatype = toNodeReference( (config as LiteralPropertyShapeConfig).datatype, ); } if ((config as ObjectPropertyShapeConfig).class) { - propertyShape.class = toPlainNodeRef( + propertyShape.class = toNodeReference( (config as ObjectPropertyShapeConfig).class, ); } if (config.equals) { - propertyShape.equalsConstraint = toPlainNodeRef(config.equals); + propertyShape.equalsConstraint = toNodeReference(config.equals); } if (config.disjoint) { - propertyShape.disjoint = toPlainNodeRef(config.disjoint); + propertyShape.disjoint = toNodeReference(config.disjoint); } - if (config.hasValue) { - propertyShape.hasValueConstraint = toPlainNodeRef(config.hasValue); + if ((config as LiteralPropertyShapeConfig).lessThan) { + propertyShape.lessThan = toNodeReference((config as LiteralPropertyShapeConfig).lessThan); + } + if ((config as LiteralPropertyShapeConfig).lessThanOrEquals) { + propertyShape.lessThanOrEquals = toNodeReference((config as LiteralPropertyShapeConfig).lessThanOrEquals); + } + if (config.hasValue !== undefined) { + const v = config.hasValue; + propertyShape.hasValueConstraint = typeof v === 'object' && v !== null ? toNodeReference(v) : v; } if (config.defaultValue !== undefined) { propertyShape.defaultValue = config.defaultValue; } if (config.in) { - propertyShape.in = config.in.map((entry) => toPlainNodeRef(entry)); + propertyShape.in = config.in.map((entry) => + typeof entry === 'object' && entry !== null ? toNodeReference(entry) : entry, + ); } if (config.sortBy) { - propertyShape.sortBy = normalizePathInput(config.sortBy); + propertyShape.sortBy = normalizePropertyPath(config.sortBy); } propertyShape.nodeKind = normalizeNodeKind(config.nodeKind, defaultNodeKind); diff --git a/src/sparql/SparqlAlgebra.ts b/src/sparql/SparqlAlgebra.ts index e6015e0..a3f0991 100644 --- a/src/sparql/SparqlAlgebra.ts +++ b/src/sparql/SparqlAlgebra.ts @@ -3,7 +3,8 @@ export type SparqlTerm = | {kind: 'variable'; name: string} | {kind: 'iri'; value: string} - | {kind: 'literal'; value: string; datatype?: string; language?: string}; + | {kind: 'literal'; value: string; datatype?: string; language?: string} + | {kind: 'path'; value: string; uris: string[]}; export type SparqlTriple = { subject: SparqlTerm; diff --git a/src/sparql/algebraToString.ts b/src/sparql/algebraToString.ts index 57ff495..1cfd7c4 100644 --- a/src/sparql/algebraToString.ts +++ b/src/sparql/algebraToString.ts @@ -50,6 +50,11 @@ export function serializeTerm( } return formatLiteral(term.value, term.datatype); } + case 'path': + if (collector && term.uris) { + for (const uri of term.uris) collectUri(collector, uri); + } + return term.value; } } diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index eca32f4..21f03b6 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -13,6 +13,7 @@ import { IRSetModificationValue, } from '../queries/IntermediateRepresentation.js'; import {NodeReferenceValue} from '../utils/NodeReference.js'; +import {pathExprToSparql, collectPathUris} from '../paths/pathExprToSparql.js'; import { SparqlSelectPlan, SparqlInsertDataPlan, @@ -521,9 +522,12 @@ function processPattern( // Register the traverse variable: (from, property) → to registry.set(pattern.from, pattern.property, pattern.to); // Add traverse triple to required pattern (or filtered block if inline where) + const predicate = pattern.pathExpr + ? {kind: 'path' as const, value: pathExprToSparql(pattern.pathExpr), uris: collectPathUris(pattern.pathExpr)} + : iriTerm(pattern.property); const triple = tripleOf( varTerm(pattern.from), - iriTerm(pattern.property), + predicate, varTerm(pattern.to), ); if (pattern.filter && filteredTraverseBlocks) { @@ -584,10 +588,13 @@ function processExpressionForProperties( if (!registry.has(expr.sourceAlias, expr.property)) { // Create a new OPTIONAL triple for this property const varName = registry.getOrCreate(expr.sourceAlias, expr.property); + const predicate = expr.pathExpr + ? {kind: 'path' as const, value: pathExprToSparql(expr.pathExpr), uris: collectPathUris(expr.pathExpr)} + : iriTerm(expr.property); optionalPropertyTriples.push( tripleOf( varTerm(expr.sourceAlias), - iriTerm(expr.property), + predicate, varTerm(varName), ), ); @@ -793,9 +800,12 @@ function convertExistsPattern( ): SparqlAlgebraNode { switch (pattern.kind) { case 'traverse': { + const existsPredicate = pattern.pathExpr + ? {kind: 'path' as const, value: pathExprToSparql(pattern.pathExpr), uris: collectPathUris(pattern.pathExpr)} + : iriTerm(pattern.property); const triple = tripleOf( varTerm(pattern.from), - iriTerm(pattern.property), + existsPredicate, varTerm(pattern.to), ); return {type: 'bgp', triples: [triple]}; diff --git a/src/tests/prefix-resolution.test.ts b/src/tests/prefix-resolution.test.ts new file mode 100644 index 0000000..37970ff --- /dev/null +++ b/src/tests/prefix-resolution.test.ts @@ -0,0 +1,94 @@ +import {Prefix} from '../utils/Prefix'; +import {toNodeReference, resolvePrefixedUri, resolveUriOrThrow} from '../utils/NodeReference'; + +// Register test prefixes before tests run +const FOAF_NS = 'http://xmlns.com/foaf/0.1/'; +const XSD_NS = 'http://www.w3.org/2001/XMLSchema#'; + +beforeAll(() => { + Prefix.add('foaf', FOAF_NS); + Prefix.add('xsd', XSD_NS); +}); + +afterAll(() => { + Prefix.delete('foaf'); + Prefix.delete('xsd'); +}); + +// --------------------------------------------------------------------------- +// resolvePrefixedUri +// --------------------------------------------------------------------------- + +describe('resolvePrefixedUri', () => { + it('resolves a registered prefixed name to full IRI', () => { + expect(resolvePrefixedUri('foaf:Person')).toBe(`${FOAF_NS}Person`); + }); + + it('resolves xsd prefix', () => { + expect(resolvePrefixedUri('xsd:string')).toBe(`${XSD_NS}string`); + }); + + it('passes through full IRIs unchanged', () => { + expect(resolvePrefixedUri('http://example.org/foo')).toBe('http://example.org/foo'); + }); + + it('passes through strings without colons (plain IDs)', () => { + expect(resolvePrefixedUri('plain-id')).toBe('plain-id'); + }); + + it('passes through unregistered prefixes unchanged', () => { + expect(resolvePrefixedUri('unknown:foo')).toBe('unknown:foo'); + }); + + it('passes through URN-style strings unchanged', () => { + expect(resolvePrefixedUri('urn:uuid:12345')).toBe('urn:uuid:12345'); + }); +}); + +// --------------------------------------------------------------------------- +// toNodeReference — simple wrap, no prefix resolution +// --------------------------------------------------------------------------- + +describe('toNodeReference (simple wrap)', () => { + it('wraps string in {id} without resolving prefixes', () => { + expect(toNodeReference('foaf:Person')).toEqual({id: 'foaf:Person'}); + }); + + it('wraps full IRI string in {id}', () => { + expect(toNodeReference('http://example.org/foo')).toEqual({id: 'http://example.org/foo'}); + }); + + it('wraps plain ID string in {id}', () => { + expect(toNodeReference('my-entity-id')).toEqual({id: 'my-entity-id'}); + }); + + it('passes through {id} with prefixed value unchanged', () => { + expect(toNodeReference({id: 'foaf:Person'})).toEqual({id: 'foaf:Person'}); + }); + + it('passes through {id} with full IRI', () => { + expect(toNodeReference({id: 'http://example.org/foo'})).toEqual({id: 'http://example.org/foo'}); + }); +}); + +// --------------------------------------------------------------------------- +// resolveUriOrThrow — strict resolution +// --------------------------------------------------------------------------- + +describe('resolveUriOrThrow', () => { + it('resolves a registered prefixed name to full IRI', () => { + expect(resolveUriOrThrow('foaf:Person')).toBe(`${FOAF_NS}Person`); + }); + + it('passes through full IRIs unchanged', () => { + expect(resolveUriOrThrow('http://example.org/foo')).toBe('http://example.org/foo'); + }); + + it('throws on unregistered prefix', () => { + expect(() => resolveUriOrThrow('unknown:foo')).toThrow('Unknown prefix'); + }); + + it('throws on typo prefix', () => { + expect(() => resolveUriOrThrow('foa:Person')).toThrow('Unknown prefix'); + }); +}); diff --git a/src/tests/property-path-fuseki.test.ts b/src/tests/property-path-fuseki.test.ts new file mode 100644 index 0000000..c30e1ff --- /dev/null +++ b/src/tests/property-path-fuseki.test.ts @@ -0,0 +1,520 @@ +/** + * End-to-end Fuseki integration tests for SHACL property paths. + * + * Tests the full pipeline: shape definition with complex paths → IR → SPARQL → Fuseki → results + * + * Skipped gracefully if Fuseki is not available on localhost:3030. + * + * Coverage: + * - Sequence paths (ex:friend/ex:name) + * - Alternative paths (ex:friend|ex:colleague) + * - Inverse paths (^ex:knows) + * - Repetition paths (ex:friend+, ex:friend*, ex:friend?) + * - Combined complex paths + */ +import {describe, expect, test, beforeAll, afterAll} from '@jest/globals'; +import { + isFusekiAvailable, + createTestDataset, + deleteTestDataset, + loadTestData, + executeSparqlQuery, + clearAllData, +} from '../test-helpers/fuseki-test-store'; +import {pathExprToSparql} from '../paths/pathExprToSparql'; +import {normalizePropertyPath} from '../paths/normalizePropertyPath'; +import type {PathExpr} from '../paths/PropertyPathExpr'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EX = 'http://example.org/'; +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const XSD = 'http://www.w3.org/2001/XMLSchema#'; + +// --------------------------------------------------------------------------- +// Test data: a small social graph +// +// Alice --knows--> Bob --knows--> Carol --knows--> Dave +// | | +// +--worksAt--> AcmeCo | +// +--worksAt--> GlobexCo +// +// Alice --likes--> Bob +// Carol --likes--> Dave +// Alice --name--> "Alice" +// Bob --name--> "Bob" +// Carol --name--> "Carol" +// Dave --name--> "Dave" +// AcmeCo --companyName--> "Acme Corp" +// GlobexCo --companyName--> "Globex Corp" +// Alice --hasPet--> Fluffy +// Fluffy --petName--> "Fluffy" +// Bob --hasPet--> Rex +// Rex --petName--> "Rex" +// Alice --manages--> Bob +// Bob --manages--> Carol +// Alice --age--> 30 +// Bob --age--> 25 +// Carol --age--> 35 +// Dave --age--> 28 +// --------------------------------------------------------------------------- + +const TEST_DATA = ` +<${EX}alice> <${RDF_TYPE}> <${EX}Person> . +<${EX}alice> <${EX}name> "Alice" . +<${EX}alice> <${EX}age> "30"^^<${XSD}integer> . +<${EX}alice> <${EX}knows> <${EX}bob> . +<${EX}alice> <${EX}likes> <${EX}bob> . +<${EX}alice> <${EX}hasPet> <${EX}fluffy> . +<${EX}alice> <${EX}manages> <${EX}bob> . + +<${EX}bob> <${RDF_TYPE}> <${EX}Person> . +<${EX}bob> <${EX}name> "Bob" . +<${EX}bob> <${EX}age> "25"^^<${XSD}integer> . +<${EX}bob> <${EX}knows> <${EX}carol> . +<${EX}bob> <${EX}worksAt> <${EX}acme> . +<${EX}bob> <${EX}hasPet> <${EX}rex> . +<${EX}bob> <${EX}manages> <${EX}carol> . + +<${EX}carol> <${RDF_TYPE}> <${EX}Person> . +<${EX}carol> <${EX}name> "Carol" . +<${EX}carol> <${EX}age> "35"^^<${XSD}integer> . +<${EX}carol> <${EX}knows> <${EX}dave> . +<${EX}carol> <${EX}likes> <${EX}dave> . +<${EX}carol> <${EX}worksAt> <${EX}globex> . + +<${EX}dave> <${RDF_TYPE}> <${EX}Person> . +<${EX}dave> <${EX}name> "Dave" . +<${EX}dave> <${EX}age> "28"^^<${XSD}integer> . + +<${EX}fluffy> <${RDF_TYPE}> <${EX}Pet> . +<${EX}fluffy> <${EX}petName> "Fluffy" . + +<${EX}rex> <${RDF_TYPE}> <${EX}Pet> . +<${EX}rex> <${EX}petName> "Rex" . + +<${EX}acme> <${RDF_TYPE}> <${EX}Company> . +<${EX}acme> <${EX}companyName> "Acme Corp" . + +<${EX}globex> <${RDF_TYPE}> <${EX}Company> . +<${EX}globex> <${EX}companyName> "Globex Corp" . +`.trim(); + +// --------------------------------------------------------------------------- +// Fuseki availability and lifecycle +// --------------------------------------------------------------------------- + +let fusekiAvailable = false; + +beforeAll(async () => { + fusekiAvailable = await isFusekiAvailable(); + if (!fusekiAvailable) { + console.log( + 'Fuseki not available — skipping property path integration tests', + ); + return; + } + await createTestDataset(); + await clearAllData(); + await loadTestData(TEST_DATA); +}, 30000); + +afterAll(async () => { + if (!fusekiAvailable) return; + await deleteTestDataset(); +}); + +// --------------------------------------------------------------------------- +// Helper: run a raw SPARQL SELECT with a property path predicate +// --------------------------------------------------------------------------- + +/** + * Build and execute a SPARQL SELECT that uses a property path. + * This bypasses the full shape→IR pipeline and tests pathExprToSparql + Fuseki directly, + * then a second set of tests exercises the full pipeline through irToAlgebra. + */ +async function selectWithPath( + subject: string, + pathExpr: PathExpr, + varName = 'target', +): Promise { + const pathStr = pathExprToSparql(pathExpr); + const sparql = `SELECT ?${varName} WHERE { <${subject}> ${pathStr} ?${varName} . }`; + const result = await executeSparqlQuery(sparql); + return result.results.bindings.map( + (b: any) => b[varName].value, + ); +} + +async function selectPathValues( + subject: string, + pathExpr: PathExpr, +): Promise { + return selectWithPath(subject, pathExpr); +} + +// ========================================================================= +// SEQUENCE PATHS +// ========================================================================= + +describe('Property path E2E — sequence paths', () => { + test('two-step sequence: knows/name (friend of Alice → name)', async () => { + if (!fusekiAvailable) return; + + // Alice --knows--> Bob, Bob has name "Bob" + const path: PathExpr = {seq: [{id: `${EX}knows`}, {id: `${EX}name`}]}; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toEqual(['Bob']); + }); + + test('three-step sequence: knows/knows/name (friend-of-friend name)', async () => { + if (!fusekiAvailable) return; + + // Alice → Bob → Carol → name = "Carol" + const path: PathExpr = { + seq: [{id: `${EX}knows`}, {id: `${EX}knows`}, {id: `${EX}name`}], + }; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toEqual(['Carol']); + }); + + test('sequence to company: knows/worksAt/companyName', async () => { + if (!fusekiAvailable) return; + + // Alice → Bob → AcmeCo → "Acme Corp" + const path: PathExpr = { + seq: [ + {id: `${EX}knows`}, + {id: `${EX}worksAt`}, + {id: `${EX}companyName`}, + ], + }; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toEqual(['Acme Corp']); + }); +}); + +// ========================================================================= +// ALTERNATIVE PATHS +// ========================================================================= + +describe('Property path E2E — alternative paths', () => { + test('alternative: knows|likes (Alice knows Bob, Alice likes Bob)', async () => { + if (!fusekiAvailable) return; + + const path: PathExpr = {alt: [{id: `${EX}knows`}, {id: `${EX}likes`}]}; + const values = await selectPathValues(`${EX}alice`, path); + // Both paths lead to Bob (deduplication depends on SPARQL engine) + expect(values).toContain(`${EX}bob`); + }); + + test('alternative: name|petName (works on different types)', async () => { + if (!fusekiAvailable) return; + + // Fluffy has petName "Fluffy", not name + const path: PathExpr = {alt: [{id: `${EX}name`}, {id: `${EX}petName`}]}; + const values = await selectPathValues(`${EX}fluffy`, path); + expect(values).toEqual(['Fluffy']); + }); + + test('alternative in sequence: (knows|likes)/name', async () => { + if (!fusekiAvailable) return; + + // Alice knows Bob and likes Bob → name = "Bob" (possibly duplicated) + const path: PathExpr = { + seq: [ + {alt: [{id: `${EX}knows`}, {id: `${EX}likes`}]}, + {id: `${EX}name`}, + ], + }; + const values = await selectPathValues(`${EX}alice`, path); + // Should contain "Bob" (possibly twice since both knows and likes reach Bob) + expect(values).toContain('Bob'); + }); +}); + +// ========================================================================= +// INVERSE PATHS +// ========================================================================= + +describe('Property path E2E — inverse paths', () => { + test('inverse: ^knows (who knows Bob?)', async () => { + if (!fusekiAvailable) return; + + // Alice --knows--> Bob, so ^knows from Bob returns Alice + const path: PathExpr = {inv: {id: `${EX}knows`}}; + const values = await selectPathValues(`${EX}bob`, path); + expect(values).toContain(`${EX}alice`); + }); + + test('inverse sequence: ^knows/name (name of who knows Bob)', async () => { + if (!fusekiAvailable) return; + + const path: PathExpr = { + seq: [{inv: {id: `${EX}knows`}}, {id: `${EX}name`}], + }; + const values = await selectPathValues(`${EX}bob`, path); + expect(values).toContain('Alice'); + }); + + test('inverse then forward: ^knows/hasPet/petName (pet of person who knows Bob)', async () => { + if (!fusekiAvailable) return; + + // ^knows from Bob → Alice, Alice hasPet Fluffy, Fluffy petName "Fluffy" + const path: PathExpr = { + seq: [ + {inv: {id: `${EX}knows`}}, + {id: `${EX}hasPet`}, + {id: `${EX}petName`}, + ], + }; + const values = await selectPathValues(`${EX}bob`, path); + expect(values).toContain('Fluffy'); + }); +}); + +// ========================================================================= +// REPETITION PATHS +// ========================================================================= + +describe('Property path E2E — repetition paths', () => { + test('oneOrMore: knows+ (transitive closure of knows from Alice)', async () => { + if (!fusekiAvailable) return; + + // Alice →+ Bob, Carol, Dave + const path: PathExpr = {oneOrMore: {id: `${EX}knows`}}; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toContain(`${EX}bob`); + expect(values).toContain(`${EX}carol`); + expect(values).toContain(`${EX}dave`); + // Should NOT contain Alice herself (oneOrMore, no self-loop) + }); + + test('zeroOrMore: knows* (transitive closure including self)', async () => { + if (!fusekiAvailable) return; + + // Alice →* Alice, Bob, Carol, Dave + const path: PathExpr = {zeroOrMore: {id: `${EX}knows`}}; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toContain(`${EX}alice`); // zero steps = self + expect(values).toContain(`${EX}bob`); + expect(values).toContain(`${EX}carol`); + expect(values).toContain(`${EX}dave`); + }); + + test('zeroOrOne: knows? from Alice', async () => { + if (!fusekiAvailable) return; + + // Alice →? Alice (zero steps) or Bob (one step) + const path: PathExpr = {zeroOrOne: {id: `${EX}knows`}}; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toContain(`${EX}alice`); // zero steps + expect(values).toContain(`${EX}bob`); // one step + expect(values).not.toContain(`${EX}carol`); // two steps — too many + }); + + test('oneOrMore with sequence: manages+/name (transitive reports)', async () => { + if (!fusekiAvailable) return; + + // Alice manages Bob, Bob manages Carol + // manages+/name from Alice → "Bob", "Carol" + const path: PathExpr = { + seq: [{oneOrMore: {id: `${EX}manages`}}, {id: `${EX}name`}], + }; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toContain('Bob'); + expect(values).toContain('Carol'); + }); +}); + +// ========================================================================= +// COMBINED / COMPLEX PATHS +// ========================================================================= + +describe('Property path E2E — complex combinations', () => { + test('inverse + oneOrMore: ^manages+ (all managers above Carol)', async () => { + if (!fusekiAvailable) return; + + // Carol ← Bob ← Alice + const path: PathExpr = {oneOrMore: {inv: {id: `${EX}manages`}}}; + const values = await selectPathValues(`${EX}carol`, path); + expect(values).toContain(`${EX}bob`); + expect(values).toContain(`${EX}alice`); + }); + + test('alternative + sequence: (knows|manages)/worksAt/companyName', async () => { + if (!fusekiAvailable) return; + + // Alice knows Bob (worksAt Acme) and manages Bob (worksAt Acme) + const path: PathExpr = { + seq: [ + {alt: [{id: `${EX}knows`}, {id: `${EX}manages`}]}, + {id: `${EX}worksAt`}, + {id: `${EX}companyName`}, + ], + }; + const values = await selectPathValues(`${EX}alice`, path); + expect(values).toContain('Acme Corp'); + }); + + test('sequence with inverse and forward: ^knows/knows (co-known with)', async () => { + if (!fusekiAvailable) return; + + // From Carol: ^knows → Bob, Bob knows → Carol again + // So ^knows/knows from Carol → Carol + const path: PathExpr = { + seq: [{inv: {id: `${EX}knows`}}, {id: `${EX}knows`}], + }; + const values = await selectPathValues(`${EX}carol`, path); + expect(values).toContain(`${EX}carol`); + }); +}); + +// ========================================================================= +// SPARQL RENDERING VERIFICATION +// ========================================================================= + +describe('Property path E2E — SPARQL rendering', () => { + test('pathExprToSparql renders sequence correctly', () => { + const path: PathExpr = {seq: [{id: `${EX}knows`}, {id: `${EX}name`}]}; + const sparql = pathExprToSparql(path); + expect(sparql).toBe(`<${EX}knows>/<${EX}name>`); + }); + + test('pathExprToSparql renders alternative correctly', () => { + const path: PathExpr = {alt: [{id: `${EX}knows`}, {id: `${EX}likes`}]}; + const sparql = pathExprToSparql(path); + expect(sparql).toBe(`<${EX}knows>|<${EX}likes>`); + }); + + test('pathExprToSparql renders inverse correctly', () => { + const path: PathExpr = {inv: {id: `${EX}knows`}}; + const sparql = pathExprToSparql(path); + expect(sparql).toBe(`^<${EX}knows>`); + }); + + test('pathExprToSparql renders oneOrMore correctly', () => { + const path: PathExpr = {oneOrMore: {id: `${EX}knows`}}; + const sparql = pathExprToSparql(path); + expect(sparql).toBe(`<${EX}knows>+`); + }); + + test('pathExprToSparql renders complex combination with correct precedence', () => { + // (knows|likes)/name+ + const path: PathExpr = { + seq: [ + {alt: [{id: `${EX}knows`}, {id: `${EX}likes`}]}, + {oneOrMore: {id: `${EX}name`}}, + ], + }; + const sparql = pathExprToSparql(path); + expect(sparql).toBe(`(<${EX}knows>|<${EX}likes>)/<${EX}name>+`); + }); +}); + +// ========================================================================= +// STRING INPUT — full pipeline: string → normalize → SPARQL → Fuseki +// ========================================================================= + +describe('Property path E2E — string decorator input', () => { + /** + * Helper: takes a raw string (as you'd write in a decorator), + * normalizes it, renders to SPARQL, and executes against Fuseki. + */ + async function selectWithStringPath( + subject: string, + pathString: string, + ): Promise { + const pathExpr = normalizePropertyPath(pathString); + const pathSparql = pathExprToSparql(pathExpr); + const sparql = `SELECT ?target WHERE { <${subject}> ${pathSparql} ?target . }`; + const result = await executeSparqlQuery(sparql); + return result.results.bindings.map((b: any) => b.target.value); + } + + test('string sequence: / parses and executes', async () => { + if (!fusekiAvailable) return; + + // Same as the {seq} object test but starting from a raw string + const values = await selectWithStringPath( + `${EX}alice`, + `<${EX}knows>/<${EX}name>`, + ); + expect(values).toEqual(['Bob']); + }); + + test('string three-step sequence: //', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}alice`, + `<${EX}knows>/<${EX}knows>/<${EX}name>`, + ); + expect(values).toEqual(['Carol']); + }); + + test('string inverse: ^', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}bob`, + `^<${EX}knows>`, + ); + expect(values).toContain(`${EX}alice`); + }); + + test('string inverse + sequence: ^//', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}bob`, + `^<${EX}knows>/<${EX}hasPet>/<${EX}petName>`, + ); + expect(values).toContain('Fluffy'); + }); + + test('string oneOrMore: +', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}alice`, + `<${EX}knows>+`, + ); + expect(values).toContain(`${EX}bob`); + expect(values).toContain(`${EX}carol`); + expect(values).toContain(`${EX}dave`); + }); + + test('string alternative: |', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}alice`, + `<${EX}knows>|<${EX}likes>`, + ); + expect(values).toContain(`${EX}bob`); + }); + + test('string complex: (|)//', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}alice`, + `(<${EX}knows>|<${EX}manages>)/<${EX}worksAt>/<${EX}companyName>`, + ); + expect(values).toContain('Acme Corp'); + }); + + test('string transitive + sequence: +/', async () => { + if (!fusekiAvailable) return; + + const values = await selectWithStringPath( + `${EX}alice`, + `<${EX}manages>+/<${EX}name>`, + ); + expect(values).toContain('Bob'); + expect(values).toContain('Carol'); + }); +}); diff --git a/src/tests/property-path-integration.test.ts b/src/tests/property-path-integration.test.ts new file mode 100644 index 0000000..be38e41 --- /dev/null +++ b/src/tests/property-path-integration.test.ts @@ -0,0 +1,193 @@ +/** + * Integration tests: shape decorator → FieldSet → desugar → lower → algebra → SPARQL string. + * + * Verifies the full pipeline from @literalProperty / @objectProperty decorators + * with complex property paths through to the final SPARQL SELECT output. + */ +import {describe, expect, test} from '@jest/globals'; +import {linkedShape} from '../package'; +import {literalProperty, objectProperty} from '../shapes/SHACL'; +import {Shape} from '../shapes/Shape'; +import {ShapeSet} from '../collections/ShapeSet'; +import {captureQuery} from '../test-helpers/query-capture-store'; +import {selectToSparql} from '../sparql/irToAlgebra'; +import {setQueryContext} from '../queries/QueryContext'; +import {NodeReferenceValue} from '../queries/QueryFactory'; + +// Ensure prefixes are registered +import '../ontologies/rdf'; +import '../ontologies/xsd'; +import '../ontologies/rdfs'; + +// --------------------------------------------------------------------------- +// Property references used in test shapes +// --------------------------------------------------------------------------- + +const testBase = 'linked://path-integration/'; +const prop = (suffix: string): NodeReferenceValue => ({id: `${testBase}${suffix}`}); +const cls = (suffix: string): NodeReferenceValue => ({id: `${testBase}type/${suffix}`}); + +// --------------------------------------------------------------------------- +// Test shapes with complex paths +// --------------------------------------------------------------------------- + +const memberProp = prop('member'); +const roleProp = prop('role'); +const labelProp = prop('label'); + +@linkedShape +class RoleShape extends Shape { + static targetClass = cls('Role'); + + @literalProperty({path: labelProp, maxCount: 1}) + get label(): string { + return ''; + } +} + +@linkedShape +class OrgShape extends Shape { + static targetClass = cls('Organization'); + + // Complex sequence path: member / role + @objectProperty({ + path: {seq: [{id: `${testBase}member`}, {id: `${testBase}role`}]}, + shape: RoleShape, + }) + get memberRoles(): ShapeSet { + return null; + } +} + +// Shapes using {id} refs (standard full-IRI paths) for backward compat +@linkedShape +class SimplePersonShape extends Shape { + static targetClass = cls('SimplePerson'); + + @literalProperty({path: prop('name'), maxCount: 1}) + get name(): string { + return ''; + } +} + +// Shape with inverse path +@linkedShape +class ChildShape extends Shape { + static targetClass = cls('Child'); + + @literalProperty({ + path: {seq: [{inv: {id: `${testBase}parent`}}, {id: `${testBase}name`}]}, + maxCount: 1, + }) + get parentName(): string { + return ''; + } +} + +// Shape with alternative path +@linkedShape +class ContactShape extends Shape { + static targetClass = cls('Contact'); + + @literalProperty({ + path: {alt: [{id: `${testBase}email`}, {id: `${testBase}phone`}]}, + }) + get contactInfo(): string[] { + return []; + } +} + +// Set query context (required for query pipeline) +setQueryContext('user', {id: 'test-user'}, SimplePersonShape); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const goldenSelect = async ( + factory: () => Promise, +): Promise => { + const ir = await captureQuery(factory); + return selectToSparql(ir); +}; + +// Shape URI prefix used in generated IDs +const O = 'https://data.lincd.org/module/-_linked-core/shape/orgshape'; +const R = 'https://data.lincd.org/module/-_linked-core/shape/roleshape'; +const SP = 'https://data.lincd.org/module/-_linked-core/shape/simplepersonshape'; +const CH = 'https://data.lincd.org/module/-_linked-core/shape/childshape'; +const CO = 'https://data.lincd.org/module/-_linked-core/shape/contactshape'; + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +describe('property path integration — decorator to SPARQL', () => { + test('sequence path on object property', async () => { + const sparql = await goldenSelect(() => OrgShape.select((p) => p.memberRoles)); + // The sequence path should appear as a property path in the traversal triple + expect(sparql).toContain(`<${testBase}member>/<${testBase}role>`); + // Type triple should be present + expect(sparql).toContain('rdf:type'); + expect(sparql).toContain('PREFIX rdf:'); + }); + + test('sequence path with nested property selection', async () => { + const sparql = await goldenSelect(() => + OrgShape.select((p) => p.memberRoles.label), + ); + // Should have the complex path for the memberRoles traversal + expect(sparql).toContain(`<${testBase}member>/<${testBase}role>`); + // Should have the simple property for label + expect(sparql).toContain(`<${R}/label>`); + }); + + test('inverse path in decorator', async () => { + const sparql = await goldenSelect(() => + ChildShape.select((p) => p.parentName), + ); + // Should have inverse + sequence path + expect(sparql).toContain(`^<${testBase}parent>/<${testBase}name>`); + }); + + test('alternative path in decorator', async () => { + const sparql = await goldenSelect(() => + ContactShape.select((p) => p.contactInfo), + ); + // Should have alternative path + expect(sparql).toContain(`<${testBase}email>|<${testBase}phone>`); + }); + + test('backward compat — simple {id} path produces standard SPARQL', async () => { + const sparql = await goldenSelect(() => + SimplePersonShape.select((p) => p.name), + ); + // Simple paths should still use standard IRI format (not path syntax) + expect(sparql).toContain(`<${SP}/name>`); + // Should NOT contain property path operators + expect(sparql).not.toMatch(/[/|^*+?!]<.*?>[/|^*+?!]/); + }); + + test('sortBy with complex path intermediate step', async () => { + const sparql = await goldenSelect(() => + OrgShape.select((p) => p.memberRoles.label).orderBy( + (p) => p.memberRoles.label, + ), + ); + // The complex path should be used in the traversal, not the raw propertyShapeId + expect(sparql).toContain(`<${testBase}member>/<${testBase}role>`); + expect(sparql).toContain('ORDER BY ASC('); + }); + + test('where filter on complex-path property', async () => { + const sparql = await goldenSelect(() => + OrgShape.select((p) => + p.memberRoles.where((r) => r.label.equals('Admin')), + ), + ); + // Complex path in traversal + expect(sparql).toContain(`<${testBase}member>/<${testBase}role>`); + // Where filter with literal comparison + expect(sparql).toContain('"Admin"'); + }); +}); diff --git a/src/tests/property-path-normalize.test.ts b/src/tests/property-path-normalize.test.ts new file mode 100644 index 0000000..c78974b --- /dev/null +++ b/src/tests/property-path-normalize.test.ts @@ -0,0 +1,129 @@ +import {normalizePropertyPath, getSimplePathId} from '../paths/normalizePropertyPath'; + +describe('normalizePropertyPath', () => { + // ------ String inputs ------ + + it('preserves a simple prefixed name string', () => { + expect(normalizePropertyPath('ex:name')).toBe('ex:name'); + }); + + it('parses a string with sequence operator', () => { + expect(normalizePropertyPath('ex:friend/ex:name')).toEqual({ + seq: ['ex:friend', 'ex:name'], + }); + }); + + it('parses a string with alternative operator', () => { + expect(normalizePropertyPath('ex:a|ex:b')).toEqual({ + alt: ['ex:a', 'ex:b'], + }); + }); + + it('parses a string with inverse operator', () => { + expect(normalizePropertyPath('^ex:parent')).toEqual({inv: 'ex:parent'}); + }); + + it('parses a string with postfix operators', () => { + expect(normalizePropertyPath('ex:broader+')).toEqual({oneOrMore: 'ex:broader'}); + }); + + it('parses a complex string expression', () => { + expect(normalizePropertyPath('(ex:a|^ex:b)/ex:c*')).toEqual({ + seq: [ + {alt: ['ex:a', {inv: 'ex:b'}]}, + {zeroOrMore: 'ex:c'}, + ], + }); + }); + + // ------ {id} inputs ------ + + it('preserves {id: string} as PathRef', () => { + expect(normalizePropertyPath({id: 'http://example.org/name'})).toEqual({ + id: 'http://example.org/name', + }); + }); + + // ------ Array inputs (sequence shorthand) ------ + + it('converts array to seq', () => { + expect( + normalizePropertyPath([{id: 'http://ex.org/a'}, {id: 'http://ex.org/b'}]), + ).toEqual({ + seq: [{id: 'http://ex.org/a'}, {id: 'http://ex.org/b'}], + }); + }); + + it('unwraps single-element array', () => { + expect(normalizePropertyPath([{id: 'http://ex.org/a'}])).toEqual({ + id: 'http://ex.org/a', + }); + }); + + it('handles mixed string and {id} in array', () => { + expect(normalizePropertyPath(['ex:a', {id: 'http://ex.org/b'}])).toEqual({ + seq: ['ex:a', {id: 'http://ex.org/b'}], + }); + }); + + it('recursively normalizes array elements with operators', () => { + expect(normalizePropertyPath(['^ex:parent', 'ex:name'])).toEqual({ + seq: [{inv: 'ex:parent'}, 'ex:name'], + }); + }); + + // ------ PathExpr object inputs (passthrough) ------ + + it('passes through a seq PathExpr', () => { + const expr = {seq: ['ex:a', 'ex:b']}; + expect(normalizePropertyPath(expr)).toBe(expr); + }); + + it('passes through an alt PathExpr', () => { + const expr = {alt: ['ex:a', 'ex:b']}; + expect(normalizePropertyPath(expr)).toBe(expr); + }); + + it('passes through an inv PathExpr', () => { + const expr = {inv: 'ex:parent'}; + expect(normalizePropertyPath(expr)).toBe(expr); + }); + + it('passes through a zeroOrMore PathExpr', () => { + const expr = {zeroOrMore: 'ex:broader'}; + expect(normalizePropertyPath(expr)).toBe(expr); + }); + + it('passes through a negatedPropertySet PathExpr', () => { + const expr = {negatedPropertySet: ['ex:a', 'ex:b']}; + expect(normalizePropertyPath(expr)).toBe(expr); + }); + + // ------ Error cases ------ + + it('throws on null input', () => { + expect(() => normalizePropertyPath(null as any)).toThrow('Invalid property path input'); + }); + + it('throws on number input', () => { + expect(() => normalizePropertyPath(42 as any)).toThrow('Invalid property path input'); + }); +}); + +describe('getSimplePathId', () => { + it('returns string from a string PathRef', () => { + expect(getSimplePathId('ex:name')).toBe('ex:name'); + }); + + it('returns id from an {id} PathRef', () => { + expect(getSimplePathId({id: 'http://example.org/name'})).toBe('http://example.org/name'); + }); + + it('returns null for a complex PathExpr', () => { + expect(getSimplePathId({seq: ['ex:a', 'ex:b']})).toBeNull(); + }); + + it('returns null for inv PathExpr', () => { + expect(getSimplePathId({inv: 'ex:a'})).toBeNull(); + }); +}); diff --git a/src/tests/property-path-parser.test.ts b/src/tests/property-path-parser.test.ts new file mode 100644 index 0000000..f3651cd --- /dev/null +++ b/src/tests/property-path-parser.test.ts @@ -0,0 +1,173 @@ +import {parsePropertyPath, PathExpr} from '../paths/PropertyPathExpr'; + +describe('parsePropertyPath', () => { + // ------ Simple paths ------ + + it('parses a simple prefixed name', () => { + expect(parsePropertyPath('ex:name')).toBe('ex:name'); + }); + + it('parses a full IRI in angle brackets', () => { + expect(parsePropertyPath('')).toBe('http://example.org/name'); + }); + + // ------ Sequence paths ------ + + it('parses a sequence path', () => { + expect(parsePropertyPath('ex:friend/ex:name')).toEqual({ + seq: ['ex:friend', 'ex:name'], + }); + }); + + it('parses a three-element sequence', () => { + expect(parsePropertyPath('ex:a/ex:b/ex:c')).toEqual({ + seq: ['ex:a', 'ex:b', 'ex:c'], + }); + }); + + // ------ Alternative paths ------ + + it('parses an alternative path', () => { + expect(parsePropertyPath('ex:friend|ex:colleague')).toEqual({ + alt: ['ex:friend', 'ex:colleague'], + }); + }); + + it('parses a three-way alternative', () => { + expect(parsePropertyPath('ex:a|ex:b|ex:c')).toEqual({ + alt: ['ex:a', 'ex:b', 'ex:c'], + }); + }); + + // ------ Inverse paths ------ + + it('parses an inverse path', () => { + expect(parsePropertyPath('^ex:parent')).toEqual({inv: 'ex:parent'}); + }); + + it('parses double inverse', () => { + expect(parsePropertyPath('^^ex:parent')).toEqual({inv: {inv: 'ex:parent'}}); + }); + + // ------ Postfix operators ------ + + it('parses zeroOrMore', () => { + expect(parsePropertyPath('ex:broader*')).toEqual({zeroOrMore: 'ex:broader'}); + }); + + it('parses oneOrMore', () => { + expect(parsePropertyPath('ex:broader+')).toEqual({oneOrMore: 'ex:broader'}); + }); + + it('parses zeroOrOne', () => { + expect(parsePropertyPath('ex:middleName?')).toEqual({zeroOrOne: 'ex:middleName'}); + }); + + // ------ Grouped expressions ------ + + it('parses a grouped alternative in sequence', () => { + expect(parsePropertyPath('(ex:friend|ex:colleague)/ex:name')).toEqual({ + seq: [{alt: ['ex:friend', 'ex:colleague']}, 'ex:name'], + }); + }); + + it('parses nested groups', () => { + expect(parsePropertyPath('(ex:a/(ex:b|ex:c))')).toEqual({ + seq: ['ex:a', {alt: ['ex:b', 'ex:c']}], + }); + }); + + // ------ Negated property set ------ + + it('parses a single negated property', () => { + expect(parsePropertyPath('!ex:parent')).toEqual({ + negatedPropertySet: ['ex:parent'], + }); + }); + + it('parses a multi-item negated property set', () => { + expect(parsePropertyPath('!(ex:parent|ex:child)')).toEqual({ + negatedPropertySet: ['ex:parent', 'ex:child'], + }); + }); + + it('parses negated property set with inverse', () => { + expect(parsePropertyPath('!(ex:parent|^ex:child)')).toEqual({ + negatedPropertySet: ['ex:parent', {inv: 'ex:child'}], + }); + }); + + // ------ Operator precedence ------ + + it('gives / higher precedence than |', () => { + // ex:a/ex:b | ex:c should be alt(seq(a,b), c) + expect(parsePropertyPath('ex:a/ex:b|ex:c')).toEqual({ + alt: [{seq: ['ex:a', 'ex:b']}, 'ex:c'], + }); + }); + + it('gives postfix higher precedence than /', () => { + // ex:a/ex:b+ should be seq(a, oneOrMore(b)) + expect(parsePropertyPath('ex:a/ex:b+')).toEqual({ + seq: ['ex:a', {oneOrMore: 'ex:b'}], + }); + }); + + it('gives ^ higher precedence than /', () => { + // ^ex:a/ex:b should be seq(inv(a), b) + expect(parsePropertyPath('^ex:a/ex:b')).toEqual({ + seq: [{inv: 'ex:a'}, 'ex:b'], + }); + }); + + // ------ Complex combinations ------ + + it('parses (ex:a|^ex:b)/ex:c+', () => { + expect(parsePropertyPath('(ex:a|^ex:b)/ex:c+')).toEqual({ + seq: [ + {alt: ['ex:a', {inv: 'ex:b'}]}, + {oneOrMore: 'ex:c'}, + ], + }); + }); + + it('parses ^ex:parent/ex:name with full IRIs', () => { + expect(parsePropertyPath('/')).toEqual({ + seq: ['http://ex.org/parent', 'http://ex.org/name'], + }); + }); + + // ------ Whitespace handling ------ + + it('handles whitespace around operators', () => { + expect(parsePropertyPath('ex:a / ex:b | ex:c')).toEqual({ + alt: [{seq: ['ex:a', 'ex:b']}, 'ex:c'], + }); + }); + + it('handles leading/trailing whitespace', () => { + expect(parsePropertyPath(' ex:name ')).toBe('ex:name'); + }); + + // ------ Error cases ------ + + it('throws on empty input', () => { + expect(() => parsePropertyPath('')).toThrow('must not be empty'); + }); + + it('throws on whitespace-only input', () => { + expect(() => parsePropertyPath(' ')).toThrow('must not be empty'); + }); + + it('throws on unmatched parenthesis', () => { + expect(() => parsePropertyPath('(ex:a|ex:b')).toThrow("Expected ')'"); + }); + + it('throws on trailing operator', () => { + expect(() => parsePropertyPath('ex:a/')).toThrow(); + }); + + it('throws on leading |', () => { + expect(() => parsePropertyPath('|ex:a')).toThrow(); + }); +}); diff --git a/src/tests/property-path-shacl.test.ts b/src/tests/property-path-shacl.test.ts new file mode 100644 index 0000000..793a509 --- /dev/null +++ b/src/tests/property-path-shacl.test.ts @@ -0,0 +1,148 @@ +import {serializePathToSHACL, resetBlankNodeCounter} from '../paths/serializePathToSHACL'; +import type {PathExpr} from '../paths/PropertyPathExpr'; + +const SH = 'http://www.w3.org/ns/shacl#'; +const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + +beforeEach(() => { + resetBlankNodeCounter(); +}); + +describe('serializePathToSHACL', () => { + // ------ Simple predicate path ------ + + it('serializes a simple string ref as direct IRI', () => { + const result = serializePathToSHACL('ex:name'); + expect(result.root).toBe('ex:name'); + expect(result.triples).toEqual([]); + }); + + it('serializes an {id} ref as direct IRI', () => { + const result = serializePathToSHACL({id: 'http://example.org/name'}); + expect(result.root).toBe('http://example.org/name'); + expect(result.triples).toEqual([]); + }); + + // ------ Sequence path ------ + + it('serializes a sequence path as RDF list', () => { + const expr: PathExpr = {seq: ['ex:friend', 'ex:name']}; + const result = serializePathToSHACL(expr); + + // Root is the first list node + expect(result.root).toBe('_:b0'); + expect(result.triples).toEqual([ + {subject: '_:b0', predicate: `${RDF}first`, object: 'ex:friend'}, + {subject: '_:b0', predicate: `${RDF}rest`, object: '_:b1'}, + {subject: '_:b1', predicate: `${RDF}first`, object: 'ex:name'}, + {subject: '_:b1', predicate: `${RDF}rest`, object: `${RDF}nil`}, + ]); + }); + + // ------ Alternative path ------ + + it('serializes an alternative path with sh:alternativePath', () => { + const expr: PathExpr = {alt: ['ex:friend', 'ex:colleague']}; + const result = serializePathToSHACL(expr); + + // Root blank node with sh:alternativePath → RDF list + expect(result.root).toBe('_:b0'); + expect(result.triples).toContainEqual({ + subject: '_:b1', + predicate: `${RDF}first`, + object: 'ex:friend', + }); + expect(result.triples).toContainEqual({ + subject: '_:b0', + predicate: `${SH}alternativePath`, + object: '_:b1', + }); + }); + + // ------ Inverse path ------ + + it('serializes an inverse path with sh:inversePath', () => { + const expr: PathExpr = {inv: 'ex:parent'}; + const result = serializePathToSHACL(expr); + + expect(result.root).toBe('_:b0'); + expect(result.triples).toEqual([ + {subject: '_:b0', predicate: `${SH}inversePath`, object: 'ex:parent'}, + ]); + }); + + // ------ Repetition paths ------ + + it('serializes zeroOrMore with sh:zeroOrMorePath', () => { + const expr: PathExpr = {zeroOrMore: 'ex:broader'}; + const result = serializePathToSHACL(expr); + + expect(result.root).toBe('_:b0'); + expect(result.triples).toEqual([ + {subject: '_:b0', predicate: `${SH}zeroOrMorePath`, object: 'ex:broader'}, + ]); + }); + + it('serializes oneOrMore with sh:oneOrMorePath', () => { + const expr: PathExpr = {oneOrMore: 'ex:broader'}; + const result = serializePathToSHACL(expr); + + expect(result.root).toBe('_:b0'); + expect(result.triples).toEqual([ + {subject: '_:b0', predicate: `${SH}oneOrMorePath`, object: 'ex:broader'}, + ]); + }); + + it('serializes zeroOrOne with sh:zeroOrOnePath', () => { + const expr: PathExpr = {zeroOrOne: 'ex:middleName'}; + const result = serializePathToSHACL(expr); + + expect(result.root).toBe('_:b0'); + expect(result.triples).toEqual([ + {subject: '_:b0', predicate: `${SH}zeroOrOnePath`, object: 'ex:middleName'}, + ]); + }); + + // ------ Nested/complex paths ------ + + it('serializes inverse within sequence', () => { + // ^ex:parent / ex:name + const expr: PathExpr = {seq: [{inv: 'ex:parent'}, 'ex:name']}; + const result = serializePathToSHACL(expr); + + // Root is the first RDF list node + expect(result.root).toBe('_:b0'); + // The inverse produces a blank node + expect(result.triples).toContainEqual({ + subject: '_:b1', + predicate: `${SH}inversePath`, + object: 'ex:parent', + }); + // The list references the inverse blank node + expect(result.triples).toContainEqual({ + subject: '_:b0', + predicate: `${RDF}first`, + object: '_:b1', + }); + }); + + it('serializes alternative within sequence', () => { + // (ex:a | ex:b) / ex:c + const expr: PathExpr = {seq: [{alt: ['ex:a', 'ex:b']}, 'ex:c']}; + const result = serializePathToSHACL(expr); + + // Should have an RDF list for the sequence, with first element being + // a blank node for the alternative + expect(result.root).toMatch(/^_:b/); + expect(result.triples.length).toBeGreaterThan(0); + }); + + // ------ Negated property set (error) ------ + + it('throws for negatedPropertySet', () => { + const expr: PathExpr = {negatedPropertySet: ['ex:parent']}; + expect(() => serializePathToSHACL(expr)).toThrow( + 'negatedPropertySet cannot be serialized to SHACL', + ); + }); +}); diff --git a/src/tests/property-path-sparql.test.ts b/src/tests/property-path-sparql.test.ts new file mode 100644 index 0000000..6c7448f --- /dev/null +++ b/src/tests/property-path-sparql.test.ts @@ -0,0 +1,286 @@ +import {pathExprToSparql, collectPathUris} from '../paths/pathExprToSparql'; +import type {PathExpr} from '../paths/PropertyPathExpr'; +import {selectPlanToSparql} from '../sparql/algebraToString'; +import type {SparqlSelectPlan} from '../sparql/SparqlAlgebra'; + +// Ensure rdf prefix is registered for prefix-shortening tests +import '../ontologies/rdf'; + +describe('pathExprToSparql', () => { + // ------ Simple refs ------ + + it('renders a prefixed name', () => { + expect(pathExprToSparql('ex:name')).toBe('ex:name'); + }); + + it('renders a full IRI string in angle brackets', () => { + expect(pathExprToSparql('http://example.org/name')).toBe(''); + }); + + it('renders an {id} ref in angle brackets', () => { + expect(pathExprToSparql({id: 'http://example.org/name'})).toBe(''); + }); + + // ------ Sequence ------ + + it('renders a sequence path', () => { + expect(pathExprToSparql({seq: ['ex:friend', 'ex:name']})).toBe('ex:friend/ex:name'); + }); + + it('renders a three-element sequence', () => { + expect(pathExprToSparql({seq: ['ex:a', 'ex:b', 'ex:c']})).toBe('ex:a/ex:b/ex:c'); + }); + + // ------ Alternative ------ + + it('renders an alternative path', () => { + expect(pathExprToSparql({alt: ['ex:friend', 'ex:colleague']})).toBe('ex:friend|ex:colleague'); + }); + + // ------ Inverse ------ + + it('renders an inverse path', () => { + expect(pathExprToSparql({inv: 'ex:parent'})).toBe('^ex:parent'); + }); + + it('renders inverse of full IRI', () => { + expect(pathExprToSparql({inv: {id: 'http://example.org/parent'}})).toBe('^'); + }); + + // ------ Repetition ------ + + it('renders zeroOrMore', () => { + expect(pathExprToSparql({zeroOrMore: 'ex:broader'})).toBe('ex:broader*'); + }); + + it('renders oneOrMore', () => { + expect(pathExprToSparql({oneOrMore: 'ex:broader'})).toBe('ex:broader+'); + }); + + it('renders zeroOrOne', () => { + expect(pathExprToSparql({zeroOrOne: 'ex:middleName'})).toBe('ex:middleName?'); + }); + + // ------ Negated property set ------ + + it('renders single negated property', () => { + expect(pathExprToSparql({negatedPropertySet: ['ex:parent']})).toBe('!ex:parent'); + }); + + it('renders multi-item negated property set', () => { + expect(pathExprToSparql({negatedPropertySet: ['ex:parent', 'ex:child']})).toBe('!(ex:parent|ex:child)'); + }); + + it('renders negated property set with inverse', () => { + expect(pathExprToSparql({negatedPropertySet: ['ex:parent', {inv: 'ex:child'}]})).toBe('!(ex:parent|^ex:child)'); + }); + + // ------ Precedence / grouping ------ + + it('parenthesizes alt within seq', () => { + // (ex:a | ex:b) / ex:c + const expr: PathExpr = {seq: [{alt: ['ex:a', 'ex:b']}, 'ex:c']}; + expect(pathExprToSparql(expr)).toBe('(ex:a|ex:b)/ex:c'); + }); + + it('does not parenthesize seq within alt', () => { + // ex:a/ex:b | ex:c — seq binds tighter than alt + const expr: PathExpr = {alt: [{seq: ['ex:a', 'ex:b']}, 'ex:c']}; + expect(pathExprToSparql(expr)).toBe('ex:a/ex:b|ex:c'); + }); + + it('parenthesizes seq under postfix operator', () => { + // (ex:a / ex:b)* + const expr: PathExpr = {zeroOrMore: {seq: ['ex:a', 'ex:b']}}; + expect(pathExprToSparql(expr)).toBe('(ex:a/ex:b)*'); + }); + + it('parenthesizes alt under postfix operator', () => { + // (ex:a | ex:b)+ + const expr: PathExpr = {oneOrMore: {alt: ['ex:a', 'ex:b']}}; + expect(pathExprToSparql(expr)).toBe('(ex:a|ex:b)+'); + }); + + // ------ Complex combinations ------ + + it('renders (ex:a|^ex:b)/ex:c+', () => { + const expr: PathExpr = { + seq: [ + {alt: ['ex:a', {inv: 'ex:b'}]}, + {oneOrMore: 'ex:c'}, + ], + }; + expect(pathExprToSparql(expr)).toBe('(ex:a|^ex:b)/ex:c+'); + }); + + it('renders ^ex:parent/ex:name', () => { + const expr: PathExpr = {seq: [{inv: 'ex:parent'}, 'ex:name']}; + expect(pathExprToSparql(expr)).toBe('^ex:parent/ex:name'); + }); + + it('renders full IRI sequence', () => { + const expr: PathExpr = { + seq: [ + {id: 'http://example.org/friend'}, + {id: 'http://example.org/name'}, + ], + }; + expect(pathExprToSparql(expr)).toBe('/'); + }); +}); + +describe('pathExprToSparql with IRTraversePattern integration', () => { + // These tests verify that the pathExprToSparql output is correct + // for use as a SPARQL property path predicate in triple patterns. + // The integration with irToAlgebra is tested indirectly via the + // IRTraversePattern.pathExpr → SparqlTerm {kind: 'path'} flow. + + it('produces valid SPARQL for inverse traversal', () => { + const sparql = pathExprToSparql({inv: {id: 'http://ex.org/parent'}}); + expect(sparql).toBe('^'); + // This would appear in SPARQL as: ?a0 ^ ?a1 + }); + + it('produces valid SPARQL for alternative traversal', () => { + const sparql = pathExprToSparql({ + alt: [{id: 'http://ex.org/friend'}, {id: 'http://ex.org/colleague'}], + }); + expect(sparql).toBe('|'); + }); + + it('produces valid SPARQL for zero-or-more with IRI', () => { + const sparql = pathExprToSparql({zeroOrMore: {id: 'http://ex.org/broader'}}); + expect(sparql).toBe('*'); + }); +}); + +describe('pathExprToSparql prefix shortening', () => { + // rdf prefix is registered via import '../ontologies/rdf' above + + it('shortens full IRI to prefixed form when prefix is registered', () => { + const sparql = pathExprToSparql({id: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'}); + expect(sparql).toBe('rdf:type'); + }); + + it('shortens {id} refs in a sequence path', () => { + const expr: PathExpr = { + seq: [ + {id: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'}, + {id: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#value'}, + ], + }; + expect(pathExprToSparql(expr)).toBe('rdf:type/rdf:value'); + }); + + it('shortens full IRI string refs when prefix is registered', () => { + expect(pathExprToSparql('http://www.w3.org/1999/02/22-rdf-syntax-ns#type')).toBe('rdf:type'); + }); + + it('preserves prefixed-name string refs as-is', () => { + expect(pathExprToSparql('rdf:type')).toBe('rdf:type'); + }); + + it('falls back to when no prefix matches', () => { + expect(pathExprToSparql({id: 'http://unknown.org/foo'})).toBe(''); + }); +}); + +describe('collectPathUris', () => { + it('collects full IRI from string ref', () => { + expect(collectPathUris('http://example.org/name')).toEqual(['http://example.org/name']); + }); + + it('collects full IRI from {id} ref', () => { + expect(collectPathUris({id: 'http://example.org/name'})).toEqual(['http://example.org/name']); + }); + + it('does not collect prefixed-name string ref', () => { + expect(collectPathUris('ex:name')).toEqual([]); + }); + + it('collects all IRIs from sequence', () => { + const expr: PathExpr = { + seq: [{id: 'http://ex.org/a'}, {id: 'http://ex.org/b'}], + }; + expect(collectPathUris(expr)).toEqual(['http://ex.org/a', 'http://ex.org/b']); + }); + + it('collects IRIs from nested expressions', () => { + const expr: PathExpr = { + seq: [ + {inv: {id: 'http://ex.org/parent'}}, + {oneOrMore: {id: 'http://ex.org/child'}}, + ], + }; + expect(collectPathUris(expr)).toEqual(['http://ex.org/parent', 'http://ex.org/child']); + }); + + it('collects IRIs from alternative', () => { + const expr: PathExpr = {alt: [{id: 'http://ex.org/a'}, {id: 'http://ex.org/b'}]}; + expect(collectPathUris(expr)).toEqual(['http://ex.org/a', 'http://ex.org/b']); + }); + + it('collects IRIs from negatedPropertySet', () => { + const expr: PathExpr = { + negatedPropertySet: [{id: 'http://ex.org/a'}, {inv: {id: 'http://ex.org/b'}}], + }; + expect(collectPathUris(expr)).toEqual(['http://ex.org/a', 'http://ex.org/b']); + }); + + it('skips prefixed names in mixed expression', () => { + const expr: PathExpr = {seq: ['ex:a', {id: 'http://ex.org/b'}]}; + expect(collectPathUris(expr)).toEqual(['http://ex.org/b']); + }); +}); + +describe('path term PREFIX block integration', () => { + // Verify that URIs inside path terms produce PREFIX declarations + // when serialized through algebraToString. + + it('includes PREFIX declarations for path URIs', () => { + const plan: SparqlSelectPlan = { + type: 'select', + projection: [{kind: 'variable', name: 'a0'}, {kind: 'variable', name: 'a1'}], + algebra: { + type: 'bgp', + triples: [{ + subject: {kind: 'variable', name: 'a0'}, + predicate: { + kind: 'path', + value: 'rdf:type/rdf:value', + uris: [ + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#value', + ], + }, + object: {kind: 'variable', name: 'a1'}, + }], + }, + }; + const sparql = selectPlanToSparql(plan); + expect(sparql).toContain('PREFIX rdf: '); + expect(sparql).toContain('?a0 rdf:type/rdf:value ?a1'); + }); + + it('does not produce PREFIX block when path has no registered prefixes', () => { + const plan: SparqlSelectPlan = { + type: 'select', + projection: [{kind: 'variable', name: 'a0'}, {kind: 'variable', name: 'a1'}], + algebra: { + type: 'bgp', + triples: [{ + subject: {kind: 'variable', name: 'a0'}, + predicate: { + kind: 'path', + value: '/', + uris: ['http://unknown.org/a', 'http://unknown.org/b'], + }, + object: {kind: 'variable', name: 'a1'}, + }], + }, + }; + const sparql = selectPlanToSparql(plan); + expect(sparql).not.toContain('PREFIX'); + expect(sparql).toContain('?a0 / ?a1'); + }); +}); diff --git a/src/tests/shacl-constraints.test.ts b/src/tests/shacl-constraints.test.ts new file mode 100644 index 0000000..b598394 --- /dev/null +++ b/src/tests/shacl-constraints.test.ts @@ -0,0 +1,229 @@ +import {createPropertyShape} from '../shapes/SHACL'; +import type {LiteralPropertyShapeConfig, ObjectPropertyShapeConfig} from '../shapes/SHACL'; + +const EX_NS = 'http://example.org/'; + +// --------------------------------------------------------------------------- +// hasValue — literal values +// --------------------------------------------------------------------------- + +describe('hasValue constraint', () => { + it('stores a literal string as-is (not wrapped as {id})', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}status`}, hasValue: 'active'} as LiteralPropertyShapeConfig, + 'status', + ); + expect(ps.hasValueConstraint).toBe('active'); + }); + + it('stores a number as-is', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}priority`}, hasValue: 42} as LiteralPropertyShapeConfig, + 'priority', + ); + expect(ps.hasValueConstraint).toBe(42); + }); + + it('stores a boolean as-is', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}active`}, hasValue: true} as LiteralPropertyShapeConfig, + 'active', + ); + expect(ps.hasValueConstraint).toBe(true); + }); + + it('stores false correctly (falsy literal)', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}active`}, hasValue: false} as LiteralPropertyShapeConfig, + 'active', + ); + expect(ps.hasValueConstraint).toBe(false); + }); + + it('stores 0 correctly (falsy literal)', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}count`}, hasValue: 0} as LiteralPropertyShapeConfig, + 'count', + ); + expect(ps.hasValueConstraint).toBe(0); + }); + + it('stores empty string correctly (falsy literal)', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}label`}, hasValue: ''} as LiteralPropertyShapeConfig, + 'label', + ); + expect(ps.hasValueConstraint).toBe(''); + }); + + it('stores an IRI node reference via {id}', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}type`}, hasValue: {id: `${EX_NS}Person`}} as LiteralPropertyShapeConfig, + 'type', + ); + expect(ps.hasValueConstraint).toEqual({id: `${EX_NS}Person`}); + }); + + it('exposes hasValue through getResult()', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}status`}, hasValue: 'active'} as LiteralPropertyShapeConfig, + 'status', + ); + const result = ps.getResult(); + expect(result.hasValue).toBe('active'); + }); + + it('exposes falsy hasValue through getResult()', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}flag`}, hasValue: false} as LiteralPropertyShapeConfig, + 'flag', + ); + const result = ps.getResult(); + expect(result.hasValue).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// in — literal and IRI values +// --------------------------------------------------------------------------- + +describe('in constraint', () => { + it('stores literal strings as-is', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}status`}, in: ['ACTIVE', 'PENDING', 'CLOSED']} as LiteralPropertyShapeConfig, + 'status', + ); + expect(ps.in).toEqual(['ACTIVE', 'PENDING', 'CLOSED']); + }); + + it('stores IRI node references via {id}', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}type`}, + in: [{id: `${EX_NS}TypeA`}, {id: `${EX_NS}TypeB`}], + } as LiteralPropertyShapeConfig, + 'type', + ); + expect(ps.in).toEqual([{id: `${EX_NS}TypeA`}, {id: `${EX_NS}TypeB`}]); + }); + + it('stores mixed IRIs and literals', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}value`}, + in: [{id: `${EX_NS}Foo`}, 'bar', 42], + } as LiteralPropertyShapeConfig, + 'value', + ); + expect(ps.in).toEqual([{id: `${EX_NS}Foo`}, 'bar', 42]); + }); + + it('stores numbers', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}rating`}, in: [1, 2, 3, 4, 5]} as LiteralPropertyShapeConfig, + 'rating', + ); + expect(ps.in).toEqual([1, 2, 3, 4, 5]); + }); + + it('stores booleans', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}flag`}, in: [true, false]} as LiteralPropertyShapeConfig, + 'flag', + ); + expect(ps.in).toEqual([true, false]); + }); + + it('exposes in through getResult()', () => { + const ps = createPropertyShape( + {path: {id: `${EX_NS}status`}, in: ['ACTIVE', 'PENDING']} as LiteralPropertyShapeConfig, + 'status', + ); + const result = ps.getResult(); + expect(result.in).toEqual(['ACTIVE', 'PENDING']); + }); +}); + +// --------------------------------------------------------------------------- +// lessThan and lessThanOrEquals — wired up +// --------------------------------------------------------------------------- + +describe('lessThan constraint', () => { + it('stores lessThan property reference', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}startDate`}, + lessThan: {id: `${EX_NS}endDate`}, + } as LiteralPropertyShapeConfig, + 'startDate', + ); + expect(ps.lessThan).toEqual({id: `${EX_NS}endDate`}); + }); + + it('exposes lessThan through getResult()', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}startDate`}, + lessThan: {id: `${EX_NS}endDate`}, + } as LiteralPropertyShapeConfig, + 'startDate', + ); + const result = ps.getResult(); + expect(result.lessThan).toEqual({id: `${EX_NS}endDate`}); + }); +}); + +describe('lessThanOrEquals constraint', () => { + it('stores lessThanOrEquals property reference', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}minPrice`}, + lessThanOrEquals: {id: `${EX_NS}maxPrice`}, + } as LiteralPropertyShapeConfig, + 'minPrice', + ); + expect(ps.lessThanOrEquals).toEqual({id: `${EX_NS}maxPrice`}); + }); + + it('exposes lessThanOrEquals through getResult()', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}minPrice`}, + lessThanOrEquals: {id: `${EX_NS}maxPrice`}, + } as LiteralPropertyShapeConfig, + 'minPrice', + ); + const result = ps.getResult(); + expect(result.lessThanOrEquals).toEqual({id: `${EX_NS}maxPrice`}); + }); +}); + +// --------------------------------------------------------------------------- +// equals and disjoint — always IRI, no change but verify +// --------------------------------------------------------------------------- + +describe('equals constraint', () => { + it('stores property IRI reference', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}name`}, + equals: {id: `${EX_NS}givenName`}, + } as LiteralPropertyShapeConfig, + 'name', + ); + expect(ps.equalsConstraint).toEqual({id: `${EX_NS}givenName`}); + }); +}); + +describe('disjoint constraint', () => { + it('stores property IRI reference', () => { + const ps = createPropertyShape( + { + path: {id: `${EX_NS}name`}, + disjoint: {id: `${EX_NS}familyName`}, + } as LiteralPropertyShapeConfig, + 'name', + ); + expect(ps.disjoint).toEqual({id: `${EX_NS}familyName`}); + }); +}); diff --git a/src/utils/NodeReference.ts b/src/utils/NodeReference.ts index 8991d65..27ab12d 100644 --- a/src/utils/NodeReference.ts +++ b/src/utils/NodeReference.ts @@ -1,9 +1,47 @@ +import {Prefix} from './Prefix.js'; + export type NodeReferenceValue = {id: string}; export type NodeReferenceInput = NodeReferenceValue | string; +/** + * Resolve a string that looks like a prefixed name (e.g. 'foaf:knows') to its full IRI. + * - Strings containing '://' are full IRIs — returned as-is. + * - Strings without ':' are plain IDs — returned as-is. + * - Strings matching 'prefix:local' are resolved via Prefix.toFullIfPossible() + * (returns original string if prefix is not registered). + * + * @example + * resolvePrefixedUri('foaf:Person') // 'http://xmlns.com/foaf/0.1/Person' + * resolvePrefixedUri('http://example.org/x') // 'http://example.org/x' (unchanged) + * resolvePrefixedUri('my-id') // 'my-id' (unchanged, no colon) + */ +export function resolvePrefixedUri(str: string): string { + if (str.includes('://')) return str; + if (!str.includes(':')) return str; + return Prefix.toFullIfPossible(str); +} + +/** + * Resolve a URI string strictly: must be either a full IRI (contains '://') + * or a registered prefixed name ('foaf:Person'). Throws if the prefix is unknown. + * + * Use this at API boundaries where strings are always URIs (never plain IDs). + */ +export function resolveUriOrThrow(str: string): string { + if (str.includes('://')) return str; + return Prefix.toFull(str); +} + +/** + * Convert a NodeReferenceInput to a NodeReferenceValue. + * Simple wrap — no prefix resolution. Use resolvePrefixedUri() for that. + */ export function toNodeReference(value: NodeReferenceInput): NodeReferenceValue { - return typeof value === 'string' ? {id: value} : value; + if (typeof value === 'string') { + return {id: value}; + } + return {id: value.id}; } export function isNodeReferenceValue(value: unknown): value is NodeReferenceValue { diff --git a/src/utils/ShapeClass.ts b/src/utils/ShapeClass.ts index beda06d..09afa70 100644 --- a/src/utils/ShapeClass.ts +++ b/src/utils/ShapeClass.ts @@ -6,7 +6,8 @@ import {NodeReferenceValue} from './NodeReference.js'; const resolveTargetClassId = ( targetClass?: NodeReferenceValue | null, ): string | null => { - return targetClass?.id ?? null; + if (!targetClass) return null; + return targetClass.id ?? null; }; let subShapesSpecificityCache: Map = new Map();