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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dispatch-registry-circular-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@_linked/core": minor
---

**Breaking:** `QueryParser` has been removed. If you imported `QueryParser` directly, replace with `getQueryDispatch()` from `@_linked/core/queries/queryDispatch`. The Shape DSL (`Shape.select()`, `.create()`, `.update()`, `.delete()`) and `SelectQuery.exec()` are unchanged.

**New:** `getQueryDispatch()` and `setQueryDispatch()` are now exported, allowing custom query dispatch implementations (e.g. for testing or alternative storage backends) without subclassing `LinkedStorage`.
30 changes: 27 additions & 3 deletions docs/ideas/002-storage-config-and-graph-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,43 @@ packages: [core, sparql]

This ideation doc is a placeholder for future design work. The storage config layer will determine how shapes, named graphs, datasets, and graph databases relate to each other.

## Prerequisite work completed

The dispatch registry refactoring (plan 001) broke the circular dependency `Shape → QueryParser → LinkedStorage → Shape` and established a clean architectural boundary:

```
Shape ──────────→ queryDispatch.ts ←──────── LinkedStorage
(calls dispatch) (leaf) (registers as dispatch)

LinkedStorage ──→ ShapeClass.ts (string-based shape lookups)
```

Key changes relevant to storage config:

- **LinkedStorage no longer imports Shape**. Store routing uses `Function` prototype-chain walking + `ShapeClass.getShapeClass()` for string-based shape resolution. This means routing metadata (targetGraph, targetDataset) can be added to `ShapeClass` or to `NodeShape`/`PropertyShape` objects without touching Shape or the dispatch boundary.
- **QueryParser removed**. All query dispatch goes through `queryDispatch.ts`, a single interception point where routing decisions can be made.
- **`shapeToStore` map accepts `Function` keys**. Currently maps shape classes to `IQuadStore` instances. This map (or a parallel one) can be extended to include graph/dataset metadata per shape.

## Key questions to explore

1. **Shape → Graph mapping**: How does the config declare which shapes live in which named graphs?
1. **Shape → Graph mapping**: How does the config declare which shapes live in which named graphs? The `ShapeClass` registry already maps shape IDs to classes — could extend with graph URIs.
2. **Graph → Dataset mapping**: How do named graphs compose into datasets?
3. **Dataset → Store mapping**: How does a dataset map to a physical graph database (Fuseki endpoint, Virtuoso, etc.)?
3. **Dataset → Store mapping**: How does a dataset map to a physical graph database (Fuseki endpoint, Virtuoso, etc.)? `LinkedStorage.shapeToStore` already does shape→store; this would add dataset as an intermediate level.
4. **Inference rules**: Which engines support inference and how does the config express that?
5. **GRAPH clause generation**: Given the storage config, how do the SPARQL conversion utilities decide when and how to emit `GRAPH <uri> { ... }` blocks?
6. **Cross-graph queries**: Queries that span multiple named graphs — how does the config support this?
7. **Default graph behavior**: Different engines treat the default graph differently (union of all named graphs vs empty vs explicit). How does the config handle this?

## Where routing logic would live

The dispatch registry provides a natural interception point. Two viable approaches:

- **Option A: Routing in LinkedStorage** — `LinkedStorage.selectQuery()` already resolves the store via `resolveStoreForQueryShape()`. Extend this to also resolve graph/dataset metadata and pass it through to the store. The dispatch boundary doesn't change.
- **Option B: Routing in dispatch** — Replace the simple dispatch with a routing-aware dispatch that resolves graph/dataset/store before calling the store. This would let different dispatch implementations handle routing differently (e.g. a SPARQL dispatch vs an in-memory dispatch).

## Relationship to SPARQL conversion (001)

The SPARQL conversion utilities (Decision 4 in 001) currently take an optional `defaultGraph` in `SparqlOptions`. Once this storage config is designed, that option will be driven by the config rather than manually passed by each store. For now, SPARQL conversion generates no GRAPH wrapping by default.
The SPARQL conversion utilities (Decision 4 in plan 001) currently take an optional `defaultGraph` in `SparqlOptions`. Once this storage config is designed, that option will be driven by the config rather than manually passed by each store. For now, SPARQL conversion generates no GRAPH wrapping by default.

## Prior art

Expand Down
37 changes: 37 additions & 0 deletions docs/reports/007-dispatch-registry-break-circular-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 007 — Dispatch Registry: Break Circular Dependencies

## Problem

Circular import: `Shape.ts → QueryParser.ts → LinkedStorage.ts → Shape.ts`

- Shape imported QueryParser to execute queries
- QueryParser imported LinkedStorage to dispatch built IR to stores
- LinkedStorage imported Shape for the `shapeToStore` map key type and walk-stop sentinel

## Solution

Created a leaf dispatch module (`queryDispatch.ts`) that defines a `QueryDispatch` interface and a mutable slot. Shape calls `getQueryDispatch()` to execute queries. LinkedStorage registers itself as the dispatch provider in `setDefaultStore()`. Neither imports the other.

```
Shape ──────────→ queryDispatch.ts ←──────── LinkedStorage
(calls dispatch) (leaf) (registers as dispatch)
```

## Changes

| File | Change |
|------|--------|
| `src/queries/queryDispatch.ts` | New leaf module — dispatch interface + get/set registry |
| `src/shapes/Shape.ts` | Uses dispatch; inlines mutation factory creation |
| `src/queries/SelectQuery.ts` | `exec()` uses dispatch |
| `src/utils/LinkedStorage.ts` | Removed Shape import; registers as dispatch in `setDefaultStore()` |
| `src/queries/QueryParser.ts` | Deleted — was a pass-through with no logic |
| `src/test-helpers/query-capture-store.ts` | Capture via dispatch; added `captureRawQuery` for pre-pipeline tests |
| `src/index.ts` | Replaced QueryParser export with queryDispatch |
| 7 test files | Adjusted for dispatch-based capture |

## Verification

- `tsc --noEmit` passes
- 18/18 test suites, 477 tests green
- Zero circular imports between Shape, LinkedStorage, and query modules
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import * as UpdateQuery from './queries/UpdateQuery.js';
import * as MutationQuery from './queries/MutationQuery.js';
import * as DeleteQuery from './queries/DeleteQuery.js';
import * as CreateQuery from './queries/CreateQuery.js';
import * as QueryParser from './queries/QueryParser.js';
import * as queryDispatch from './queries/queryDispatch.js';
import * as QueryFactory from './queries/QueryFactory.js';
import * as IntermediateRepresentation from './queries/IntermediateRepresentation.js';
import * as NameSpace from './utils/NameSpace.js';
Expand Down Expand Up @@ -64,7 +64,7 @@ export function initModularApp() {
MutationQuery,
DeleteQuery,
CreateQuery,
QueryParser,
queryDispatch,
QueryFactory,
IntermediateRepresentation,
SHACLShapes,
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/IQuadStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
* (SPARQL endpoint, SQL database, in-memory store, etc.).
*
* Each method receives a canonical IR query object and returns the result.
* The calling layer (LinkedStorage / QueryParser) threads the precise
* The calling layer (LinkedStorage via queryDispatch) threads the precise
* DSL-level TypeScript result type back to the caller — the store only
* needs to produce data that matches the structural result types.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/queries/IntermediateRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export type IRFieldValue =
// Store result types
// ---------------------------------------------------------------------------
// These types describe what an IQuadStore implementation should return.
// The calling layer (LinkedStorage / QueryParser) threads the precise
// The calling layer (LinkedStorage via queryDispatch) threads the precise
// DSL-level TypeScript result type back to the caller, so the store
// only needs to produce data that satisfies these structural contracts.
// ---------------------------------------------------------------------------
Expand Down
79 changes: 0 additions & 79 deletions src/queries/QueryParser.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/queries/SelectQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {xsd} from '../ontologies/xsd.js';
import {
buildSelectQuery,
} from './IRPipeline.js';
import {QueryParser} from './QueryParser.js';
import {getQueryDispatch} from './queryDispatch.js';
import type {RawSelectInput} from './IRDesugar.js';
import type {IRSelectQuery} from './IntermediateRepresentation.js';

Expand Down Expand Up @@ -1715,7 +1715,7 @@ export class SelectQueryFactory<
}

exec(): Promise<QueryResponseToResultType<ResponseType>[]> {
return QueryParser.selectQuery(this);
return getQueryDispatch().selectQuery(this.build());
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/queries/queryDispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type {SelectQuery} from './SelectQuery.js';
import type {CreateQuery} from './CreateQuery.js';
import type {UpdateQuery} from './UpdateQuery.js';
import type {DeleteQuery, DeleteResponse} from './DeleteQuery.js';

/**
* Abstraction boundary between the DSL layer (Shape) and the storage layer
* (LinkedStorage / IQuadStore). Both sides import this leaf module; neither
* imports the other.
*
* Return types are intentionally `any` — the DSL layer threads precise
* result types through its own generics; the dispatch is a runtime bridge.
*/
export interface QueryDispatch {
selectQuery<R = any>(query: SelectQuery): Promise<R>;
createQuery<R = any>(query: CreateQuery): Promise<R>;
updateQuery<R = any>(query: UpdateQuery): Promise<R>;
deleteQuery(query: DeleteQuery): Promise<DeleteResponse>;
}

let dispatch: QueryDispatch | null = null;

export function setQueryDispatch(d: QueryDispatch): void {
dispatch = d;
}

export function getQueryDispatch(): QueryDispatch {
if (!dispatch) {
throw new Error(
'No query dispatch configured. Call LinkedStorage.setDefaultStore() first.',
);
}
return dispatch;
}
25 changes: 16 additions & 9 deletions src/shapes/Shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import {
SelectAllQueryResponse,
SelectQueryFactory,
} from '../queries/SelectQuery.js';
import {QueryParser} from '../queries/QueryParser.js';
import {getQueryDispatch} from '../queries/queryDispatch.js';
import {AddId, NodeReferenceValue, UpdatePartial} from '../queries/QueryFactory.js';
import {CreateResponse} from '../queries/CreateQuery.js';
import {DeleteResponse} from '../queries/DeleteQuery.js';
import {CreateQueryFactory, CreateResponse} from '../queries/CreateQuery.js';
import {DeleteQueryFactory, DeleteResponse} from '../queries/DeleteQuery.js';
import {NodeId} from '../queries/MutationQuery.js';
import {UpdateQueryFactory} from '../queries/UpdateQuery.js';
import {getPropertyShapeByLabel} from '../utils/ShapeClass.js';
import {ShapeSet} from '../collections/ShapeSet.js';

Expand Down Expand Up @@ -191,7 +192,7 @@ export abstract class Shape {
);
let p = new Promise<ResultType>((resolve, reject) => {
nextTick(() => {
QueryParser.selectQuery(query)
getQueryDispatch().selectQuery(query.build())
.then((result) => {
resolve(result as ResultType);
})
Expand Down Expand Up @@ -250,28 +251,34 @@ export abstract class Shape {
id: string | NodeReferenceValue | QShape<ShapeType>,
updateObjectOrFn?: U,
): Promise<AddId<U>> {
return QueryParser.updateQuery(
const factory = new UpdateQueryFactory<ShapeType, U>(
this as any as typeof Shape,
id,
updateObjectOrFn,
this as any as typeof Shape,
);
return getQueryDispatch().updateQuery(factory.build());
}

static create<ShapeType extends Shape, U extends UpdatePartial<ShapeType>>(
this: {new (...args: any[]): ShapeType; },
updateObjectOrFn?: U,
): Promise<CreateResponse<U>> {
return QueryParser.createQuery(
updateObjectOrFn,
const factory = new CreateQueryFactory<ShapeType, U>(
this as any as typeof Shape,
updateObjectOrFn,
);
return getQueryDispatch().createQuery(factory.build());
}

static delete<ShapeType extends Shape, U extends UpdatePartial<ShapeType>>(
this: {new (...args: any[]): ShapeType; },
id: NodeId | NodeId[] | NodeReferenceValue[],
): Promise<DeleteResponse> {
return QueryParser.deleteQuery(id, this as any as typeof Shape);
const factory = new DeleteQueryFactory<Shape, {}>(
this as any as typeof Shape,
id,
);
return getQueryDispatch().deleteQuery(factory.build());
}

static mapPropertyShapes<ShapeType extends Shape, ResponseType = unknown>(
Expand Down
Loading