From 36761e81249c9d7e787083de08135f1f22b5c23d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 18 Jan 2021 11:09:25 +0100 Subject: [PATCH] feat: Support composite PATCH updates --- .../SparqlPatchPermissionsExtractor.ts | 30 ++++-- src/storage/patch/SparqlUpdatePatchHandler.ts | 92 +++++++++++++++---- test/integration/LpdHandlerOperations.test.ts | 41 +++++++-- .../SparqlPatchPermissionsExtractor.test.ts | 38 +++++++- .../patch/SparqlUpdatePatchHandler.test.ts | 41 +++++++++ 5 files changed, 208 insertions(+), 34 deletions(-) diff --git a/src/ldp/permissions/SparqlPatchPermissionsExtractor.ts b/src/ldp/permissions/SparqlPatchPermissionsExtractor.ts index c4f1ab5b37..820f584137 100644 --- a/src/ldp/permissions/SparqlPatchPermissionsExtractor.ts +++ b/src/ldp/permissions/SparqlPatchPermissionsExtractor.ts @@ -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.'); } } @@ -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)); } } diff --git a/src/storage/patch/SparqlUpdatePatchHandler.ts b/src/storage/patch/SparqlUpdatePatchHandler.ts index d9c8529a37..53a7ce1872 100644 --- a/src/storage/patch/SparqlUpdatePatchHandler.ts +++ b/src/storage/patch/SparqlUpdatePatchHandler.ts @@ -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 ?? []; @@ -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 { + private async applyPatch(identifier: ResourceIdentifier, op: Algebra.Operation): Promise { const store = new Store(); try { // Read the quads of the current representation @@ -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, 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, 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, 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.`); + } } diff --git a/test/integration/LpdHandlerOperations.test.ts b/test/integration/LpdHandlerOperations.test.ts index 57f5bd59e9..5d1aa45c03 100644 --- a/test/integration/LpdHandlerOperations.test.ts +++ b/test/integration/LpdHandlerOperations.test.ts @@ -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( ' .', ); 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'), @@ -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 { }; ', + 'INSERT DATA { }', + ], + ); + 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 => { diff --git a/test/unit/ldp/permissions/SparqlPatchPermissionsExtractor.test.ts b/test/unit/ldp/permissions/SparqlPatchPermissionsExtractor.test.ts index d4f79bb41c..a3f2dbeff9 100644 --- a/test/unit/ldp/permissions/SparqlPatchPermissionsExtractor.test.ts +++ b/test/unit/ldp/permissions/SparqlPatchPermissionsExtractor.test.ts @@ -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 => { + it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise => { 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 })) @@ -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 => { @@ -49,4 +52,35 @@ describe('A SparqlPatchPermissionsExtractor', (): void => { write: true, }); }); + + it('requires append for composite operations with an insert.', async(): Promise => { + const operation = { + method: 'PATCH', + body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]) ]) }, + } 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 => { + const operation = { + method: 'PATCH', + body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]), + factory.createDeleteInsert([ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]) ]) }, + } as unknown as Operation; + await expect(extractor.handle(operation)).resolves.toEqual({ + read: false, + append: true, + write: true, + }); + }); }); diff --git a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts index d16e969f61..22f4274f39 100644 --- a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts +++ b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts @@ -148,6 +148,47 @@ describe('A SparqlUpdatePatchHandler', (): void => { ])).toBe(true); }); + it('handles composite INSERT/DELETE updates.', async(): Promise => { + await handler.handle({ identifier: { path: 'path' }, + patch: { algebra: translate( + 'INSERT DATA { . ' + + ' };' + + 'DELETE WHERE { .' + + ' }', + { 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 => { + await handler.handle({ identifier: { path: 'path' }, + patch: { algebra: translate( + 'DELETE DATA { .' + + ' };' + + 'INSERT DATA { . ' + + ' }', + { 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 => { const handle = handler.handle({ identifier: { path: 'path' }, patch: { algebra: translate(