Skip to content

Commit

Permalink
#124: Node shapes with target class for statutes (not validating yet)
Browse files Browse the repository at this point in the history
  • Loading branch information
gsvarovsky committed Feb 13, 2023
1 parent e86c224 commit da4d5f5
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 33 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
"types": "ext/index.d.ts",
"exports": {
".": "./ext/index.js",
"./ext/ns": "./ext/ns/index.js",
"./ext/orm": "./ext/orm/index.js",
"./ext/mqtt": "./ext/mqtt/index.js",
"./ext/socket.io": "./ext/socket.io/index.js",
"./ext/socket.io-server": "./ext/socket.io/server/index.js",
"./ext/socket.io/server": "./ext/socket.io/server/index.js",
"./ext/ably": "./ext/ably/index.js",
"./ext/wrtc": "./ext/wrtc/index.js",
"./ext/security": "./ext/security/index.js",
Expand Down
3 changes: 2 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,8 @@ export class MeldError extends Error {
readonly status: MeldErrorStatus;

constructor(status: keyof typeof MeldErrorStatus | MeldErrorStatus, detail?: any) {
super((typeof status == 'string' ? status : MeldErrorStatus[status]) + (detail != null ? `: ${detail}` : ''));
super((typeof status == 'string' ? status :
MeldErrorStatus[status]) + (detail != null ? `: ${detail}` : ''));
this.status = typeof status == 'string' ? MeldErrorStatus[status] : status;
}

Expand Down
4 changes: 3 additions & 1 deletion src/engine/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export function flatten<T>(bumpy: T[][]): T[] {
* @param pump the map function that inflates the input to an observable output
*/
export function inflate<T extends ObservableInput<any>, O extends ObservableInput<any>>(
input: O, pump: (p: ObservedValueOf<O>) => T): Observable<ObservedValueOf<T>> {
input: O,
pump: (p: ObservedValueOf<O>) => T
): Observable<ObservedValueOf<T>> {
return from(input).pipe(mergeMap(pump));
}

Expand Down
68 changes: 68 additions & 0 deletions src/shacl/NodeShape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { array, uuid } from '../util';
import { GraphSubject, GraphSubjects, GraphUpdate, InterimUpdate, MeldReadState } from '../api';
import { Shape, ShapeSpec, ValidationResult } from './Shape';
import { inflate } from '../engine/util';
import { firstValueFrom } from 'rxjs';
import { filter, toArray } from 'rxjs/operators';
import { SubjectGraph } from '../engine/SubjectGraph';

/**
* @see https://www.w3.org/TR/shacl/#node-shapes
* @category Experimental
* @experimental
*/
export class NodeShape extends Shape {
/**
* Shape declaration. Insert into the domain data to install the shape. For
* example (assuming a **m-ld** `clone` object):
*
* ```typescript
* clone.write(NodeShape.declare({ path: 'name', count: 1 }));
* ```
* @param spec shape specification object
*/
static declare = ShapeSpec.declareShape;

constructor(spec?: ShapeSpec) {
const src = spec?.src ?? { '@id': uuid() };
super(src);
this.initSrcProperties(src, {
targetClass: this.targetClassAccess(spec)
});
}

async affected(state: MeldReadState, update: GraphUpdate): Promise<GraphUpdate> {
const loaded: { [id: string]: GraphSubject | undefined } = {};
async function loadType(subject: GraphSubject) {
return subject['@id'] in loaded ? loaded[subject['@id']] :
loaded[subject['@id']] = await state.get(subject['@id'], '@type');
}
// Find all updated subjects that have the target classes
const filterUpdate = async (updateElement: GraphSubjects) =>
new SubjectGraph(await firstValueFrom(inflate(
updateElement, async subject =>
this.hasTargetClass(subject) ||
this.hasTargetClass(await loadType(subject)) ? subject : null
).pipe(filter((s): s is GraphSubject => s != null), toArray())));
return {
'@delete': await filterUpdate(update['@delete']),
'@insert': await filterUpdate(update['@insert'])
};
}

private hasTargetClass(subject: GraphSubject | undefined) {
return subject && array(subject['@type']).some(type => this.targetClass.has(type));
}

async check(state: MeldReadState, interim: InterimUpdate): Promise<ValidationResult[]> {
return []; // TODO Constraint checking applies nested property shapes
}

async apply(state: MeldReadState, interim: InterimUpdate): Promise<ValidationResult[]> {
return []; // TODO Constraint checking applies nested property shapes
}

toString(): string {
return `[Node Shape] target=${this.targetClass.toString()}`;
}
}
23 changes: 8 additions & 15 deletions src/shacl/PropertyShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { sortValues, SubjectPropertyValues } from '../subjects';
import { drain } from 'rx-flowable';
import { SubjectGraph } from '../engine/SubjectGraph';
import { Shape, ValidationResult } from './Shape';
import { Shape, ShapeSpec, ValidationResult } from './Shape';
import { SH } from '../ns';
import { mapIter } from '../engine/util';
import { ConstraintComponent } from '../ns/sh';
Expand All @@ -22,10 +22,8 @@ export type PropertyCardinality = {
maxCount?: number;
}
/** Convenience specification for a property shape */
export type PropertyShapeSpec = {
src?: GraphSubject,
export type PropertyShapeSpec = ShapeSpec & {
path: Iri;
targetClass?: Iri | Iri[];
name?: string | string[];
} & PropertyCardinality;

Expand Down Expand Up @@ -63,11 +61,10 @@ export class PropertyShape extends Shape {
} : spec;
return {
[SH.path]: { '@vocab': spec.path },
[SH.targetClass]: array(spec.targetClass).map(iri => ({ '@vocab': iri })),
[SH.name]: spec.name,
[SH.minCount]: minCount,
[SH.maxCount]: maxCount,
...spec.src
...ShapeSpec.declareShape(spec)
};
};

Expand All @@ -81,7 +78,7 @@ export class PropertyShape extends Shape {
init: spec?.path ? { '@vocab': spec.path } : undefined
},
name: { init: spec?.name },
targetClass: { init: spec?.targetClass },
targetClass: this.targetClassAccess(spec),
minCount: { init: spec != null && 'count' in spec ? spec.count : spec?.minCount },
maxCount: { init: spec != null && 'count' in spec ? spec.count : spec?.maxCount }
});
Expand Down Expand Up @@ -119,14 +116,10 @@ export class PropertyShape extends Shape {
const old = await this.loadSubjectValues(id);
return updated[id] = { old, final: old.clone() };
};
await Promise.all(mapIter(this.shape.genSubjectValues(update['@delete']), async del => {
const { subject: { '@id': id }, values } = del;
(await loadUpdated(id)).final.delete(...values);
}));
await Promise.all(mapIter(this.shape.genSubjectValues(update['@insert']), async ins => {
const { subject: { '@id': id }, values } = ins;
(await loadUpdated(id)).final.insert(...values);
}));
await Promise.all(mapIter(this.shape.genSubjectValues(update['@delete']), async del =>
(await loadUpdated(del.subject['@id'])).final.delete(...del.values)));
await Promise.all(mapIter(this.shape.genSubjectValues(update['@insert']), async ins =>
(await loadUpdated(ins.subject['@id'])).final.insert(...ins.values)));
return Object.values(updated);
}

Expand Down
58 changes: 48 additions & 10 deletions src/shacl/Shape.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { ExtensionSubject, OrmSubject, OrmUpdating } from '../orm/index';
import { OrmSubject } from '../orm/index';
import { property } from '../orm/OrmSubject';
import { JsType } from '../js-support';
import { VocabReference } from '../jrql-support';
import { Subject, VocabReference } from '../jrql-support';
import {
Assertions, GraphSubject, GraphUpdate, InterimUpdate, MeldConstraint, MeldReadState
} from '../api';
import { SH } from '../ns';
import { ConstraintComponent } from '../ns/sh';
import { Iri } from '@m-ld/jsonld';
import { mapIter } from '../engine/util';
import { array } from '../util';

/** Convenience specification for a shape */
export interface ShapeSpec {
src?: GraphSubject,
targetClass?: Iri | Iri[];
}

export namespace ShapeSpec {
export const declareShape = (spec: ShapeSpec): Subject => {
return {
[SH.targetClass]: array(spec.targetClass).map(id => ({ '@vocab': id })),
...spec.src
};
};
}

/**
* Shapes are used to define patterns of data, which can be used to match or
Expand All @@ -18,7 +36,7 @@ import { ConstraintComponent } from '../ns/sh';
* While this class implements `MeldConstraint`, shape checking is semantically
* weaker than constraint {@link check checking}, as a violation
* (non-conformance) does not produce an exception but instead resolves a
* validation result. However, the constraint {@link apply} will attempt a
* validation result. However, the constraint {@link apply} _will_ attempt a
* correction in response to a non-conformance.
*
* @see https://www.w3.org/TR/shacl/#constraints-section
Expand All @@ -28,24 +46,44 @@ import { ConstraintComponent } from '../ns/sh';
*/
export abstract class Shape extends OrmSubject implements MeldConstraint {
/** @internal */
@property(JsType.for(Set, VocabReference), SH.targetClass)
targetClass: Set<VocabReference>;
@property(JsType.for(Array, VocabReference), SH.targetClass)
targetClass: Set<Iri>;

/** @see https://www.w3.org/TR/shacl/#terminology */
static from(src: GraphSubject, orm: OrmUpdating): Shape | Promise<Shape> {
static from(src: GraphSubject): Shape | Promise<Shape> {
if (SH.path in src) {
const { PropertyShape } = require('./PropertyShape');
return new PropertyShape({ src });
} else {
return ExtensionSubject.instance(src, orm);
const { NodeShape } = require('./NodeShape');
return new NodeShape({ src });
}
}

/**
* Capture precisely the data being affected by the given update which matches
* this shape, either before or after the update is applied to the state.
* Convenience for declaration of target class access in `initSrcProperty`
* @internal
*/
protected targetClassAccess(spec?: ShapeSpec) {
return {
get: () => [...mapIter(this.targetClass, id => ({ '@vocab': id }))],
set: (v: VocabReference[]) => this.targetClass = new Set(v.map(ref => ref['@vocab'])),
init: array(spec?.targetClass).map(id => ({ '@vocab': id }))
};
}

/**
* Capture precisely the data being affected by the given update which
* correspond to this shape, either before or after the update is applied to
* the state.
*
* In respect of SHACL concepts, correspondence is based on **targets** and
* **paths** (if applicable), but not constraints; so that non-validating
* state (either before or after the update) _is_ included.
*
* @returns filtered updates where the affected subject matches this shape
* @returns filtered updates where the affected subject corresponds to this shape
* @see https://www.w3.org/TR/shacl/#targets
* @see https://www.w3.org/TR/shacl/#property-paths
*/
abstract affected(state: MeldReadState, update: GraphUpdate): Promise<GraphUpdate>;

Expand Down
3 changes: 2 additions & 1 deletion src/shacl/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Shape } from './Shape';
export { ShapeConstrained } from './ShapeConstrained';
export { PropertyShape } from './PropertyShape';
export { PropertyShape } from './PropertyShape';
export { NodeShape } from './NodeShape';
109 changes: 109 additions & 0 deletions test/NodeShape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { NodeShape, Shape } from '../src/shacl/index';
import { SH } from '../src/ns';
import { MockGraphState } from './testClones';
import { SubjectGraph } from '../src/engine/SubjectGraph';

describe('SHACL Node Shape', () => {
test('create from a subject', () => {
const shape = Shape.from({
'@id': 'http://test.m-ld.org/flintstoneShape',
[SH.targetClass]: { '@vocab': 'http://test.m-ld.org/#Flintstone' }
});
expect(shape).toBeInstanceOf(NodeShape);
expect((<NodeShape>shape).targetClass)
.toEqual(new Set(['http://test.m-ld.org/#Flintstone']));
});

test('create from target class', () => {
const shape = new NodeShape({ targetClass: 'http://test.m-ld.org/#Flintstone' });
expect((<NodeShape>shape).targetClass)
.toEqual(new Set(['http://test.m-ld.org/#Flintstone']));
});

test('declare a node shape', () => {
const write = NodeShape.declare({
targetClass: 'http://test.m-ld.org/#Flintstone'
});
expect(write).toMatchObject({
[SH.targetClass]: [{ '@vocab': 'http://test.m-ld.org/#Flintstone' }]
});
});

describe('affected', () => {
let state: MockGraphState;

beforeEach(async () => {
state = await MockGraphState.create();
});

afterEach(() => state.close());

test('insert with no matching type', async () => {
const shape = new NodeShape({ targetClass: 'http://test.m-ld.org/#Flintstone' });
await expect(shape.affected(state.graph.asReadState, {
'@delete': new SubjectGraph([]),
'@insert': new SubjectGraph([{
'@id': 'http://test.m-ld.org/fred', 'http://test.m-ld.org/#name': 'Fred'
}])
})).resolves.toEqual({
'@delete': [], '@insert': []
});
});

test('insert with matching type in update', async () => {
const shape = new NodeShape({ targetClass: 'http://test.m-ld.org/#Flintstone' });
await expect(shape.affected(state.graph.asReadState, {
'@delete': new SubjectGraph([]),
'@insert': new SubjectGraph([{
'@id': 'http://test.m-ld.org/fred',
'@type': 'http://test.m-ld.org/#Flintstone',
'http://test.m-ld.org/#name': 'Fred'
}])
})).resolves.toEqual({
'@delete': [],
'@insert': [{
'@id': 'http://test.m-ld.org/fred',
'@type': 'http://test.m-ld.org/#Flintstone',
'http://test.m-ld.org/#name': 'Fred'
}]
});
});

test('insert with matching type in state', async () => {
await state.write({ '@id': 'fred', '@type': 'http://test.m-ld.org/#Flintstone' });
const shape = new NodeShape({ targetClass: 'http://test.m-ld.org/#Flintstone' });
await expect(shape.affected(state.graph.asReadState, {
'@delete': new SubjectGraph([]),
'@insert': new SubjectGraph([{
'@id': 'http://test.m-ld.org/fred',
'http://test.m-ld.org/#name': 'Fred'
}])
})).resolves.toEqual({
'@delete': [],
'@insert': [{
'@id': 'http://test.m-ld.org/fred',
'http://test.m-ld.org/#name': 'Fred'
}]
});
});

test('delete with matching type in state', async () => {
await state.write({ '@id': 'fred', '@type': 'http://test.m-ld.org/#Flintstone' });
const shape = new NodeShape({ targetClass: 'http://test.m-ld.org/#Flintstone' });
await expect(shape.affected(state.graph.asReadState, {
'@delete': new SubjectGraph([{
'@id': 'http://test.m-ld.org/fred',
'http://test.m-ld.org/#name': 'Fred'
}]),
'@insert': new SubjectGraph([])
})).resolves.toEqual({
'@delete': [{
'@id': 'http://test.m-ld.org/fred',
'http://test.m-ld.org/#name': 'Fred'
}],
'@insert': []
});
});

});
});
Loading

0 comments on commit da4d5f5

Please sign in to comment.