Skip to content
Draft
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: 6 additions & 1 deletion core/src/links/Links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,23 @@ export class LinkExpression extends ExpressionGeneric(Link) {
return hash;
}
status?: LinkStatus;

graph?: string;
};
export class LinkExpressionInput extends ExpressionGenericInput(LinkInput) {
hash: () => number;
status?: LinkStatus;

graph?: string;
};

export function linkEqual(l1: LinkExpression, l2: LinkExpression): boolean {
return l1.author == l2.author &&
l1.timestamp == l2.timestamp &&
l1.data.source == l2.data.source &&
l1.data.predicate == l2.data.predicate &&
l1.data.target == l2.data.target
l1.data.target == l2.data.target &&
(l1.graph ?? '') == (l2.graph ?? '')
}

export function isLink(l: any): boolean {
Expand Down
104 changes: 93 additions & 11 deletions core/src/model/Ad4mModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ export class Ad4mModel {
className,
properties: propertiesMetadata,
relations: relationsMetadata,
graph: !!(this as any)._graphRooted,
};
}

Expand All @@ -536,6 +537,13 @@ export class Ad4mModel {
* const recipe = new Recipe(perspective, "literal:...");
* ```
*/
/**
* The resolved named graph IRI for this instance.
* Set during `create()` to propagate parent graph context to child entities.
* @private
*/
private _resolvedGraphIri?: string;

constructor(perspective: PerspectiveProxy, baseExpression?: string) {
this._baseExpression = baseExpression ? baseExpression : Literal.from(makeRandomId(24)).toUrl();
this._perspective = perspective;
Expand All @@ -556,6 +564,28 @@ export class Ad4mModel {
return this._perspective;
}

/**
* Returns the named graph IRI for this instance.
* Priority: explicit _resolvedGraphIri (set during create with parent context) >
* model-level graph: true > undefined (default graph).
*/
get graphIri(): string | undefined {
if (this._resolvedGraphIri) return this._resolvedGraphIri;
const ctor = this.constructor as typeof Ad4mModel;
if ((ctor as any)._graphRooted) {
return `ad4m://graph/${this._baseExpression}`;
}
return undefined;
}

/**
* Compute the named graph IRI for a given base expression.
* Only meaningful for graph-rooted models.
*/
static graphIriFor(baseExpression: string): string {
return `ad4m://graph/${baseExpression}`;
}

/**
* Get property metadata from decorator.
* @private
Expand Down Expand Up @@ -1086,7 +1116,25 @@ export class Ad4mModel {
query, classNameOverride,
);

const result = await perspective.modelQuery(className, queryJson, shapeJson);
// Resolve graph scoping from the parent's model metadata.
// If the parent model is graph-rooted, scope queries to the parent's graph.
// If the queried model itself is graph-rooted and has a parent, the parent
// provides the graph scope. Without a parent, queries are unscoped (union all).
const metadata = this.getModelMetadata();
let graphIris: string[] | undefined;
if (query.parent?.id) {
if ('model' in query.parent && query.parent.model) {
const parentMeta = (query.parent.model as typeof Ad4mModel).getModelMetadata?.();
if (parentMeta?.graph) {
graphIris = [`ad4m://graph/${query.parent.id}`];
}
} else if (metadata.graph) {
// No parent model info, but queried model is graph-rooted — scope to parent's graph
graphIris = [`ad4m://graph/${query.parent.id}`];
}
}

const result = await perspective.modelQuery(className, queryJson, shapeJson, graphIris);

// Convert JSON instances to model class instances, recursively constructing
// class instances for any included relations resolved by Rust.
Expand Down Expand Up @@ -1297,7 +1345,7 @@ export class Ad4mModel {
value = await this._perspective.createExpression(value, resolveLanguage);
}

await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value }], batchId);
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value }], batchId, this.graphIri);
}

/** Resolve a relation argument to a plain string ID. Accepts either a raw
Expand All @@ -1324,10 +1372,11 @@ export class Ad4mModel {
actions,
this._baseExpression,
value.map((v) => ({ name: "value", value: this.resolveRelationId(v) })),
batchId
batchId,
this.graphIri
);
} else {
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId);
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId, this.graphIri);
}
}
}
Expand All @@ -1346,11 +1395,11 @@ export class Ad4mModel {
if (Array.isArray(value)) {
await Promise.all(
value.map((v) =>
this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(v) }], batchId)
this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(v) }], batchId, this.graphIri)
)
);
} else {
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId);
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId, this.graphIri);
}
}
}
Expand All @@ -1369,11 +1418,11 @@ export class Ad4mModel {
if (Array.isArray(value)) {
await Promise.all(
value.map((v) =>
this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(v) }], batchId)
this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(v) }], batchId, this.graphIri)
)
);
} else {
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId);
await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value: this.resolveRelationId(value) }], batchId, this.graphIri);
}
}
}
Expand Down Expand Up @@ -1470,7 +1519,8 @@ export class Ad4mModel {
className,
this._baseExpression,
initialValues,
batchId
batchId,
this.graphIri
);
}

Expand Down Expand Up @@ -1754,6 +1804,15 @@ export class Ad4mModel {
*/
async delete(batchId?: string) {
const metadata = (this.constructor as typeof Ad4mModel).getModelMetadata();

// Fast path: graph-rooted models can drop the entire named graph.
// The executor's removeGraph handles cross-graph reference cleanup atomically
// (removes incoming links from other graphs that target subjects in this graph).
if (metadata.graph && this.graphIri) {
await this._perspective.removeGraph(this.graphIri);
return;
}

const hasDestructor = Object.values(metadata.properties).some(
(p) => p.required || p.flag || p.initial !== undefined
);
Expand Down Expand Up @@ -1851,6 +1910,29 @@ export class Ad4mModel {
const instance = new this(perspective) as T;
Object.assign(instance, data);

// Resolve the graph IRI for this instance's links.
// Priority: model is graph-rooted → own graph; parent is graph-rooted → parent's graph.
const metadata = (this as typeof Ad4mModel).getModelMetadata();
if (metadata.graph) {
// This model roots its own graph — use own base expression
instance._resolvedGraphIri = Ad4mModel.graphIriFor(instance._baseExpression);
} else if (options?.parent && 'model' in options.parent) {
// Check if the parent model is graph-rooted
const parentMeta = (options.parent.model as typeof Ad4mModel).getModelMetadata?.();
if (parentMeta?.graph) {
instance._resolvedGraphIri = Ad4mModel.graphIriFor(options.parent.id);
}
}

// Resolve the graph for the parent→child link (uses PARENT's graph, not child's).
let parentGraphIri: string | undefined;
if (options?.parent && 'model' in options.parent) {
const parentMeta = (options.parent.model as typeof Ad4mModel).getModelMetadata?.();
if (parentMeta?.graph) {
parentGraphIri = Ad4mModel.graphIriFor(options.parent.id);
}
}

// When a parent scope is provided without a caller-supplied batch, open a
// new batch ourselves so that the instance creation and the parent→child
// link are committed atomically. If either step throws, commitBatch is
Expand All @@ -1864,7 +1946,7 @@ export class Ad4mModel {
predicate,
target: instance.id,
});
await perspective.add(link, 'shared', batchId);
await perspective.add(link, 'shared', batchId, parentGraphIri);
await perspective.commitBatch(batchId);
// Hydrate the instance now that the batch has been committed (mirrors the
// behaviour of save() when it manages its own batch).
Expand All @@ -1882,7 +1964,7 @@ export class Ad4mModel {
predicate,
target: instance.id,
});
await perspective.add(link, 'shared', options?.batchId);
await perspective.add(link, 'shared', options?.batchId, parentGraphIri);
}

return instance;
Expand Down
8 changes: 8 additions & 0 deletions core/src/model/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,11 @@ export interface ModelConfig {
* The name of the entity.
*/
name: string;
/**
* When true, instances are stored in a named graph keyed by their base expression.
* This enables efficient bulk deletion (drop entire graph) and scoped queries.
*/
graph?: boolean;
}

/**
Expand Down Expand Up @@ -530,6 +535,9 @@ export function Model(opts: ModelConfig) {
return function (target: any) {
target.prototype.className = opts.name;
target.className = opts.name;
if (opts.graph) {
target._graphRooted = true;
}

target.generateSDNA = function() {
return buildSDNA(
Expand Down
6 changes: 3 additions & 3 deletions core/src/model/relation-filtering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ describe("compileWhereClause()", () => {
it("should compile simple equality to SPARQL condition", () => {
const conditions = compileWhereClause(
{ status: "active" },
{ properties: { status: { name: "status", predicate: "test://status", required: false, readOnly: false } }, relations: {}, className: "Test" }
{ properties: { status: { name: "status", predicate: "test://status", required: false, readOnly: false } }, relations: {}, className: "Test", graph: false }
);
expect(conditions).toHaveLength(1);
expect(conditions[0]).toContain("test://status");
Expand All @@ -773,7 +773,7 @@ describe("compileWhereClause()", () => {
it("should compile not operator", () => {
const conditions = compileWhereClause(
{ status: { not: "archived" } },
{ properties: { status: { name: "status", predicate: "test://status", required: false, readOnly: false } }, relations: {}, className: "Test" }
{ properties: { status: { name: "status", predicate: "test://status", required: false, readOnly: false } }, relations: {}, className: "Test", graph: false }
);
expect(conditions).toHaveLength(1);
expect(conditions[0]).toContain("NOT EXISTS"); // negation
Expand All @@ -783,7 +783,7 @@ describe("compileWhereClause()", () => {
it("should compile array values to IN clause", () => {
const conditions = compileWhereClause(
{ category: ["food", "drink"] },
{ properties: { category: { name: "category", predicate: "test://category", required: false, readOnly: false } }, relations: {}, className: "Test" }
{ properties: { category: { name: "category", predicate: "test://category", required: false, readOnly: false } }, relations: {}, className: "Test", graph: false }
);
expect(conditions).toHaveLength(1);
expect(conditions[0]).toContain("food");
Expand Down
5 changes: 5 additions & 0 deletions core/src/model/shacl-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export function buildSHACL(
}
}

// Set graph-rooting flag from @Model({ graph: true })
if (target._graphRooted) {
shape.hasGraph = true;
}

// ── Constructor / Destructor actions ────────────────────────────────
let constructorActions: any[] = [];
if (obj.subjectConstructor && obj.subjectConstructor.length) {
Expand Down
2 changes: 2 additions & 0 deletions core/src/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,6 @@ export interface ModelMetadata {
properties: Record<string, PropertyMetadata>;
/** Map of relation name to metadata */
relations: Record<string, RelationMetadata>;
/** Whether instances are stored in named graphs */
graph: boolean;
}
1 change: 1 addition & 0 deletions core/src/perspectives/PerspectiveClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe('PerspectiveClient RPC operations', () => {
link: { source: 'a', target: 'b', predicate: 'c' },
status: 'shared',
batchId: undefined,
graph: null,
})
})

Expand Down
38 changes: 24 additions & 14 deletions core/src/perspectives/PerspectiveClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,15 @@ export class PerspectiveClient {
return JSON.parse(result)
}

async querySparql(uuid: string, query: string): Promise<unknown> {
const result = await this.#apiClient.call<string>('perspective.querySparql', { uuid, engine: 'sparql', query })
async querySparql(uuid: string, query: string, graphs?: string[]): Promise<unknown> {
const result = await this.#apiClient.call<string>('perspective.querySparql', { uuid, engine: 'sparql', query, graphs: graphs || null })
return JSON.parse(result)
}

async namedGraphs(uuid: string): Promise<string[]> {
return this.#apiClient.call<string[]>('perspective.namedGraphs', { uuid })
}

async subscribeQuery(uuid: string, query: string): Promise<{ subscriptionId: string, result: AllInstancesResult }> {
const response = await this.#apiClient.call<{ subscriptionId: string, result: unknown }>(
'perspective.subscribeQuery', { uuid, query }
Expand Down Expand Up @@ -176,9 +180,9 @@ export class PerspectiveClient {
)
}

async modelQuery(uuid: string, className: string, queryJson: string, shapeJson?: string): Promise<any> {
async modelQuery(uuid: string, className: string, queryJson: string, shapeJson?: string, graphIris?: string[]): Promise<any> {
const resultJson = await this.#apiClient.call<string>(
'perspective.modelQuery', { uuid, class_name: className, query_json: queryJson, shape_json: shapeJson }
'perspective.modelQuery', { uuid, class_name: className, query_json: queryJson, shape_json: shapeJson, graph_iris: graphIris || null }
)
return JSON.parse(resultJson)
}
Expand All @@ -202,9 +206,9 @@ export class PerspectiveClient {
return JSON.parse(resultJson)
}

async modelSubscribe(uuid: string, className: string, queryJson: string, shapeJson?: string): Promise<{ subscriptionId: string, result: any }> {
async modelSubscribe(uuid: string, className: string, queryJson: string, shapeJson?: string, graphIris?: string[]): Promise<{ subscriptionId: string, result: any }> {
const response = await this.#apiClient.call<{ subscription_id: string, result: string }>(
'perspective.modelSubscribe', { uuid, class_name: className, query_json: queryJson, shape_json: shapeJson }
'perspective.modelSubscribe', { uuid, class_name: className, query_json: queryJson, shape_json: shapeJson, graph_iris: graphIris || null }
)
return {
subscriptionId: response.subscription_id,
Expand All @@ -227,15 +231,15 @@ export class PerspectiveClient {
return { perspectiveRemove: result }
}

async addLink(uuid: string, link: Link, status: LinkStatus = 'shared', batchId?: string): Promise<LinkExpression> {
async addLink(uuid: string, link: Link, status: LinkStatus = 'shared', batchId?: string, graph?: string): Promise<LinkExpression> {
return this.#apiClient.call<LinkExpression>(
'perspective.addLink', { uuid, link, status, batchId }
'perspective.addLink', { uuid, link, status, batchId, graph: graph || null }
)
}

async addLinks(uuid: string, links: Link[], status: LinkStatus = 'shared', batchId?: string): Promise<LinkExpression[]> {
async addLinks(uuid: string, links: Link[], status: LinkStatus = 'shared', batchId?: string, graph?: string): Promise<LinkExpression[]> {
return this.#apiClient.call<LinkExpression[]>(
'perspective.addLinks', { uuid, links, status, batchId }
'perspective.addLinks', { uuid, links, status, batchId, graph: graph || null }
)
}

Expand Down Expand Up @@ -285,15 +289,21 @@ export class PerspectiveClient {
)
}

async executeCommands(uuid: string, commands: string, expression: string, parameters: string, batchId?: string): Promise<boolean> {
async executeCommands(uuid: string, commands: string, expression: string, parameters: string, batchId?: string, graph?: string): Promise<boolean> {
return this.#apiClient.call<boolean>(
'perspective.executeCommands', { uuid, commands, expression, parameters, batchId, graph: graph || null }
)
}

async removeNamedGraph(uuid: string, graphIri: string): Promise<boolean> {
return this.#apiClient.call<boolean>(
'perspective.executeCommands', { uuid, commands, expression, parameters, batchId }
'perspective.removeNamedGraph', { uuid, graphIri }
)
}

async createSubject(uuid: string, subjectClass: string, expressionAddress: string, initialValues?: string, batchId?: string): Promise<boolean> {
async createSubject(uuid: string, subjectClass: string, expressionAddress: string, initialValues?: string, batchId?: string, graph?: string): Promise<boolean> {
return this.#apiClient.call<boolean>(
'perspective.createSubject', { uuid, subjectClass, expressionAddress, initialValues, batchId }
'perspective.createSubject', { uuid, subjectClass, expressionAddress, initialValues, batchId, graph: graph || null }
)
}

Expand Down
Loading