Skip to content

Commit

Permalink
feat: Support composite PATCH updates
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Jan 18, 2021
1 parent e72117a commit 36761e8
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 34 deletions.
30 changes: 24 additions & 6 deletions src/ldp/permissions/SparqlPatchPermissionsExtractor.ts
Expand Up @@ -22,8 +22,8 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
if (!this.isSparql(body)) {
throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.');
}
if (!this.isDeleteInsert(body.algebra)) {
throw new NotImplementedHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.');
if (!this.isSupported(body.algebra)) {
throw new NotImplementedHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.');
}
}

Expand All @@ -42,15 +42,33 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
return Boolean((data as SparqlUpdatePatch).algebra);
}

private isSupported(op: Algebra.Operation): boolean {
if (op.type === Algebra.types.DELETE_INSERT) {
return true;
}
if (op.type === Algebra.types.COMPOSITE_UPDATE) {
return (op as Algebra.CompositeUpdate).updates.every((update): boolean => this.isSupported(update));
}
return false;
}

private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
}

private needsAppend(update: Algebra.DeleteInsert): boolean {
return Boolean(update.insert && update.insert.length > 0);
private needsAppend(update: Algebra.Operation): boolean {
if (this.isDeleteInsert(update)) {
return Boolean(update.insert && update.insert.length > 0);
}

return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsAppend(op));
}

private needsWrite(update: Algebra.DeleteInsert): boolean {
return Boolean(update.delete && update.delete.length > 0);
private needsWrite(update: Algebra.Operation): boolean {
if (this.isDeleteInsert(update)) {
return Boolean(update.delete && update.delete.length > 0);
}

return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsWrite(op));
}
}
92 changes: 75 additions & 17 deletions src/storage/patch/SparqlUpdatePatchHandler.ts
Expand Up @@ -41,11 +41,43 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
// Verify the patch
const { identifier, patch } = input;
const op = patch.algebra;
if (!this.isDeleteInsert(op)) {
this.validateUpdate(op);

const lock = await this.locker.acquire(identifier);
try {
await this.applyPatch(identifier, op);
} finally {
await lock.release();
}
}

private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
}

private isComposite(op: Algebra.Operation): op is Algebra.CompositeUpdate {
return op.type === Algebra.types.COMPOSITE_UPDATE;
}

/**
* Checks if the input operation is of a supported type (DELETE/INSERT or composite of those)
*/
private validateUpdate(op: Algebra.Operation): void {
if (this.isDeleteInsert(op)) {
this.validateDeleteInsert(op);
} else if (this.isComposite(op)) {
this.validateComposite(op);
} else {
this.logger.warn(`Unsupported operation: ${op.type}`);
throw new NotImplementedHttpError('Only DELETE/INSERT SPARQL update operations are supported');
}
}

/**
* Checks if the input DELETE/INSERT is supported.
* This means: no GRAPH statements, no DELETE WHERE.
*/
private validateDeleteInsert(op: Algebra.DeleteInsert): void {
const def = defaultGraph();
const deletes = op.delete ?? [];
const inserts = op.insert ?? [];
Expand All @@ -62,24 +94,21 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
this.logger.warn('WHERE statements are not supported');
throw new NotImplementedHttpError('WHERE statements are not supported');
}

const lock = await this.locker.acquire(identifier);
try {
await this.applyPatch(identifier, deletes, inserts);
} finally {
await lock.release();
}
}

private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
return op.type === Algebra.types.DELETE_INSERT;
/**
* Checks if the composite update only contains supported update components.
*/
private validateComposite(op: Algebra.CompositeUpdate): void {
for (const update of op.updates) {
this.validateUpdate(update);
}
}

/**
* Applies the given deletes and inserts to the resource.
* Apply the given algebra operation to the given identifier.
*/
private async applyPatch(identifier: ResourceIdentifier, deletes: Algebra.Pattern[], inserts: Algebra.Pattern[]):
Promise<void> {
private async applyPatch(identifier: ResourceIdentifier, op: Algebra.Operation): Promise<void> {
const store = new Store<BaseQuad>();
try {
// Read the quads of the current representation
Expand All @@ -100,13 +129,42 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
this.logger.debug(`Patching new resource ${identifier.path}.`);
}

// Apply the patch
store.removeQuads(deletes);
store.addQuads(inserts);
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads to ${identifier.path}.`);
this.applyOperation(store, op);
this.logger.debug(`${store.size} quads will be stored to ${identifier.path}.`);

// Write the result
await this.source.setRepresentation(identifier, new BasicRepresentation(store.match() as Readable, INTERNAL_QUADS));
}

/**
* Apply the given algebra update operation to the store of quads.
*/
private applyOperation(store: Store<BaseQuad>, op: Algebra.Operation): void {
if (this.isDeleteInsert(op)) {
this.applyDeleteInsert(store, op);
// Only other options is Composite after passing `validateUpdate`
} else {
this.applyComposite(store, op as Algebra.CompositeUpdate);
}
}

/**
* Apply the given composite update operation to the store of quads.
*/
private applyComposite(store: Store<BaseQuad>, op: Algebra.CompositeUpdate): void {
for (const update of op.updates) {
this.applyOperation(store, update);
}
}

/**
* Apply the given DELETE/INSERT update operation to the store of quads.
*/
private applyDeleteInsert(store: Store<BaseQuad>, op: Algebra.DeleteInsert): void {
const deletes = op.delete ?? [];
const inserts = op.insert ?? [];
store.removeQuads(deletes);
store.addQuads(inserts);
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads.`);
}
}
41 changes: 32 additions & 9 deletions test/integration/LpdHandlerOperations.test.ts
Expand Up @@ -98,21 +98,14 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
expect(response._getData()).toHaveLength(0);

// GET
requestUrl = new URL(id);
response = await performRequest(
handler,
requestUrl,
'GET',
{ accept: 'text/turtle' },
[],
);
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
expect(response.statusCode).toBe(200);
expect(response._getData()).toContain(
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.',
);
expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
const parser = new Parser();
const triples = parser.parse(response._getData());
let triples = parser.parse(response._getData());
expect(triples).toBeRdfIsomorphic([
quad(
namedNode('http://test.com/s2'),
Expand All @@ -125,6 +118,36 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
namedNode('http://test.com/o3'),
),
]);

// PATCH
response = await performRequest(
handler,
requestUrl,
'PATCH',
{ 'content-type': 'application/sparql-update', 'transfer-encoding': 'chunked' },
[ 'DELETE DATA { <s2> <http://test.com/p2> <http://test.com/o2> }; ',
'INSERT DATA {<s4> <http://test.com/p4> <http://test.com/o4>}',
],
);
expect(response.statusCode).toBe(205);
expect(response._getData()).toHaveLength(0);

// GET
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
expect(response.statusCode).toBe(200);
triples = parser.parse(response._getData());
expect(triples).toBeRdfIsomorphic([
quad(
namedNode('http://test.com/s3'),
namedNode('http://test.com/p3'),
namedNode('http://test.com/o3'),
),
quad(
namedNode('http://test.com/s4'),
namedNode('http://test.com/p4'),
namedNode('http://test.com/o4'),
),
]);
});

it('should overwrite the content on PUT request.', async(): Promise<void> => {
Expand Down
38 changes: 36 additions & 2 deletions test/unit/ldp/permissions/SparqlPatchPermissionsExtractor.test.ts
Expand Up @@ -8,9 +8,11 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
const extractor = new SparqlPatchPermissionsExtractor();
const factory = new Factory();

it('can only handle SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation;
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
(operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]);
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
await expect(extractor.canHandle({ ...operation, method: 'GET' }))
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of GET, only PATCH.'));
await expect(extractor.canHandle({ ...operation, body: undefined }))
Expand All @@ -19,7 +21,8 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of non-SPARQL patches.'));
await expect(extractor.canHandle({ ...operation,
body: { algebra: factory.createMove('DEFAULT', 'DEFAULT') } as unknown as SparqlUpdatePatch }))
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.'));
.rejects
.toThrow(new BadRequestHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.'));
});

it('requires append for INSERT operations.', async(): Promise<void> => {
Expand Down Expand Up @@ -49,4 +52,35 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
write: true,
});
});

it('requires append for composite operations with an insert.', async(): Promise<void> => {
const operation = {
method: 'PATCH',
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]) ]) },
} as unknown as Operation;
await expect(extractor.handle(operation)).resolves.toEqual({
read: false,
append: true,
write: false,
});
});

it('requires write for composite operations with a delete.', async(): Promise<void> => {
const operation = {
method: 'PATCH',
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]),
factory.createDeleteInsert([
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
]) ]) },
} as unknown as Operation;
await expect(extractor.handle(operation)).resolves.toEqual({
read: false,
append: true,
write: true,
});
});
});
41 changes: 41 additions & 0 deletions test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
Expand Up @@ -148,6 +148,47 @@ describe('A SparqlUpdatePatchHandler', (): void => {
])).toBe(true);
});

it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
await handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> };' +
'DELETE WHERE { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> }',
{ quads: true },
) } as SparqlUpdatePatch });
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
])).toBe(true);
});

it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
await handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(
'DELETE DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> };' +
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> }',
{ quads: true },
) } as SparqlUpdatePatch });
expect(await basicChecks([
quad(namedNode('http://test.com/startS2'),
namedNode('http://test.com/startP2'),
namedNode('http://test.com/startO2')),
quad(namedNode('http://test.com/s1'),
namedNode('http://test.com/p1'),
namedNode('http://test.com/o1')),
quad(namedNode('http://test.com/s2'),
namedNode('http://test.com/p2'),
namedNode('http://test.com/o2')),
])).toBe(true);
});

it('rejects GRAPH inserts.', async(): Promise<void> => {
const handle = handler.handle({ identifier: { path: 'path' },
patch: { algebra: translate(
Expand Down

0 comments on commit 36761e8

Please sign in to comment.