diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts index a3c495ef5..8e8f48e57 100644 --- a/src/ldp/representation/RepresentationMetadata.ts +++ b/src/ldp/representation/RepresentationMetadata.ts @@ -63,7 +63,7 @@ export class RepresentationMetadata { public constructor(contentType?: string); /** - * @param overrides - Metadata values (defaulting to content type if a string) + * @param metadata - Metadata values (defaulting to content type if a string) */ public constructor(metadata?: RepresentationMetadata | MetadataRecord | string); diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts index b6eaf3e70..39e75fe9a 100644 --- a/src/storage/DataAccessorBasedStore.ts +++ b/src/storage/DataAccessorBasedStore.ts @@ -94,9 +94,13 @@ export class DataAccessorBasedStore implements ResourceStore { await this.auxiliaryStrategy.addMetadata(metadata); if (isContainerPath(metadata.identifier.value)) { - // Remove containment references of auxiliary resources - const auxContains = this.getContainedAuxiliaryResources(metadata); - metadata.remove(LDP.terms.contains, auxContains); + // Add containment triples of non-auxiliary resources + for await (const child of this.accessor.getChildren(identifier)) { + if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) { + metadata.addQuads(child.quads()); + metadata.add(LDP.terms.contains, child.identifier as NamedNode); + } + } // Generate a container representation from the metadata const data = metadata.quads(); @@ -139,7 +143,7 @@ export class DataAccessorBasedStore implements ResourceStore { // using the HTTP Slug header as defined in [RFC5023]. // Clients who want the server to assign a URI of a resource, MUST use the POST request." // https://solid.github.io/specification/protocol#resource-type-heuristics - const newID = this.createSafeUri(container, representation.metadata, parentMetadata); + const newID = await this.createSafeUri(container, representation.metadata); // Write the data. New containers should never be made for a POST request. await this.writeData(newID, representation, isContainerIdentifier(newID), false); @@ -205,8 +209,7 @@ export class DataAccessorBasedStore implements ResourceStore { // https://solid.github.io/specification/protocol#deleting-resources if (isContainerIdentifier(identifier)) { // Auxiliary resources are not counted when deleting a container since they will also be deleted - const auxContains = this.getContainedAuxiliaryResources(metadata); - if (metadata.getAll(LDP.contains).length > auxContains.length) { + if (await this.hasProperChildren(identifier)) { throw new ConflictHttpError('Can only delete empty containers.'); } } @@ -399,10 +402,9 @@ export class DataAccessorBasedStore implements ResourceStore { * * @param container - Identifier of the target container. * @param metadata - Metadata of the new resource. - * @param parentMetadata - Metadata of the parent container. */ - protected createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata, - parentMetadata: RepresentationMetadata): ResourceIdentifier { + protected async createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata): + Promise { // Get all values needed for naming the resource const isContainer = this.isNewContainer(metadata); const slug = metadata.get(HTTP.slug)?.value; @@ -418,11 +420,9 @@ export class DataAccessorBasedStore implements ResourceStore { } // Make sure we don't already have a resource with this exact name (or with differing trailing slash) - const withSlash = ensureTrailingSlash(newID.path); - const withoutSlash = trimTrailingSlashes(newID.path); - const exists = parentMetadata.getAll(LDP.contains).some((term): boolean => - term.value === withSlash || term.value === withoutSlash); - if (exists) { + const withSlash = { path: ensureTrailingSlash(newID.path) }; + const withoutSlash = { path: trimTrailingSlashes(newID.path) }; + if (await this.resourceExists(withSlash) || await this.resourceExists(withoutSlash)) { newID = this.createURI(container, isContainer); } @@ -458,11 +458,15 @@ export class DataAccessorBasedStore implements ResourceStore { } /** - * Extracts the identifiers of all auxiliary resources contained within the given metadata. + * Checks if the given container has any non-auxiliary resources. */ - protected getContainedAuxiliaryResources(metadata: RepresentationMetadata): NamedNode[] { - return metadata.getAll(LDP.terms.contains).filter((object): boolean => - this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: object.value })) as NamedNode[]; + protected async hasProperChildren(container: ResourceIdentifier): Promise { + for await (const child of this.accessor.getChildren(container)) { + if (!this.auxiliaryStrategy.isAuxiliaryIdentifier({ path: child.identifier.value })) { + return true; + } + } + return false; } /** diff --git a/src/storage/accessors/DataAccessor.ts b/src/storage/accessors/DataAccessor.ts index 616fb2482..a197a5c1d 100644 --- a/src/storage/accessors/DataAccessor.ts +++ b/src/storage/accessors/DataAccessor.ts @@ -11,7 +11,7 @@ import type { Guarded } from '../../util/GuardedStream'; * * If the input identifier ends with a slash, it should be assumed the identifier is targeting a container. * * Similarly, if there is no trailing slash it should assume a document. * * It should always throw a NotFoundHttpError if it does not have data matching the input identifier. - * * DataAccessors are responsible for generating the relevant containment triples for containers. + * * DataAccessors should not generate containment triples. This will be done externally using `getChildren`. */ export interface DataAccessor { /** @@ -36,6 +36,20 @@ export interface DataAccessor { */ getMetadata: (identifier: ResourceIdentifier) => Promise; + /** + * Returns metadata for all resources in the requested container. + * This should not be all metadata of those resources (but it can be), + * but instead the main metadata you want to show in situations + * where all these resources are presented simultaneously. + * Generally this would be metadata that is present for all of these resources, + * such as resource type or last modified date. + * + * It can be safely assumed that the incoming identifier will always correspond to a container. + * + * @param identifier - Identifier of the parent container. + */ + getChildren: (identifier: ResourceIdentifier) => AsyncIterableIterator; + /** * Writes data and metadata for a document. * If any data and/or metadata exist for the given identifier, it should be overwritten. diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index bdccaa674..33241d3aa 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -14,7 +14,7 @@ import { guardStream } from '../../util/GuardedStream'; import type { Guarded } from '../../util/GuardedStream'; import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil'; import { parseQuads, pushQuad, serializeQuads } from '../../util/QuadUtil'; -import { generateContainmentQuads, generateResourceQuads } from '../../util/ResourceUtil'; +import { generateResourceQuads } from '../../util/ResourceUtil'; import { toLiteral } from '../../util/TermUtil'; import { CONTENT_TYPE, DC, LDP, POSIX, RDF, XSD } from '../../util/Vocabularies'; import type { FileIdentifierMapper, ResourceLink } from '../mapping/FileIdentifierMapper'; @@ -70,6 +70,11 @@ export class FileDataAccessor implements DataAccessor { throw new NotFoundHttpError(); } + public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + const link = await this.resourceMapper.mapUrlToFilePath(identifier); + yield* this.getChildMetadata(link); + } + /** * Writes the given data as a file (and potential metadata as additional file). * The metadata file will be written first and will be deleted if something goes wrong writing the actual data. @@ -194,8 +199,7 @@ export class FileDataAccessor implements DataAccessor { */ private async getDirectoryMetadata(link: ResourceLink, stats: Stats): Promise { - return (await this.getBaseMetadata(link, stats, true)) - .addQuads(await this.getChildMetadataQuads(link)); + return await this.getBaseMetadata(link, stats, true); } /** @@ -277,46 +281,38 @@ export class FileDataAccessor implements DataAccessor { } /** - * Generate all containment related triples for a container. - * These include the actual containment triples and specific triples for every child resource. + * Generate metadata for all children in a container. * * @param link - Path related metadata. */ - private async getChildMetadataQuads(link: ResourceLink): Promise { - const quads: Quad[] = []; - const childURIs: string[] = []; - const files = await fsPromises.readdir(link.filePath); + private async* getChildMetadata(link: ResourceLink): AsyncIterableIterator { + const dir = await fsPromises.opendir(link.filePath); // For every child in the container we want to generate specific metadata - for (const childName of files) { - // Hide metadata files from containment triples + for await (const entry of dir) { + const childName = entry.name; + // Hide metadata files if (this.isMetadataPath(childName)) { continue; } // Ignore non-file/directory entries in the folder - const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName)); - if (!childStats.isFile() && !childStats.isDirectory()) { + if (!entry.isFile() && !entry.isDirectory()) { continue; } // Generate the URI corresponding to the child resource const childLink = await this.resourceMapper - .mapFilePathToUrl(joinFilePath(link.filePath, childName), childStats.isDirectory()); + .mapFilePathToUrl(joinFilePath(link.filePath, childName), entry.isDirectory()); // Generate metadata of this specific child const subject = DataFactory.namedNode(childLink.identifier.path); + const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName)); + const quads: Quad[] = []; quads.push(...generateResourceQuads(subject, childStats.isDirectory())); quads.push(...this.generatePosixQuads(subject, childStats)); - childURIs.push(childLink.identifier.path); + yield new RepresentationMetadata(subject).addQuads(quads); } - - // Generate containment metadata - const containsQuads = generateContainmentQuads( - DataFactory.namedNode(link.identifier.path), childURIs, - ); - - return quads.concat(containsQuads); } /** diff --git a/src/storage/accessors/InMemoryDataAccessor.ts b/src/storage/accessors/InMemoryDataAccessor.ts index e748646b8..ca1c337b9 100644 --- a/src/storage/accessors/InMemoryDataAccessor.ts +++ b/src/storage/accessors/InMemoryDataAccessor.ts @@ -1,13 +1,11 @@ import type { Readable } from 'stream'; import arrayifyStream from 'arrayify-stream'; -import type { NamedNode } from 'rdf-js'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { InternalServerError } from '../../util/errors/InternalServerError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import type { Guarded } from '../../util/GuardedStream'; import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; -import { generateContainmentQuads } from '../../util/ResourceUtil'; import { guardedStreamFrom } from '../../util/StreamUtil'; import type { DataAccessor } from './DataAccessor'; @@ -46,7 +44,15 @@ export class InMemoryDataAccessor implements DataAccessor { public async getMetadata(identifier: ResourceIdentifier): Promise { const entry = this.getEntry(identifier); - return this.generateMetadata(entry); + return new RepresentationMetadata(entry.metadata); + } + + public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + const entry = this.getEntry(identifier); + if (!this.isDataEntry(entry)) { + const childNames = Object.keys(entry.entries); + yield* childNames.map((name): RepresentationMetadata => new RepresentationMetadata({ path: name })); + } } public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): @@ -141,14 +147,4 @@ export class InMemoryDataAccessor implements DataAccessor { } return entry; } - - private generateMetadata(entry: CacheEntry): RepresentationMetadata { - const metadata = new RepresentationMetadata(entry.metadata); - if (!this.isDataEntry(entry)) { - const childNames = Object.keys(entry.entries); - const quads = generateContainmentQuads(metadata.identifier as NamedNode, childNames); - metadata.addQuads(quads); - } - return metadata; - } } diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts index d31125941..91d99989a 100644 --- a/src/storage/accessors/SparqlDataAccessor.ts +++ b/src/storage/accessors/SparqlDataAccessor.ts @@ -4,16 +4,15 @@ import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint'; import { DataFactory } from 'n3'; import type { NamedNode, Quad } from 'rdf-js'; import type { - ConstructQuery, GraphPattern, + ConstructQuery, + GraphPattern, GraphQuads, InsertDeleteOperation, SparqlGenerator, Update, UpdateOperation, } from 'sparqljs'; -import { - Generator, -} from 'sparqljs'; +import { Generator } from 'sparqljs'; import type { Representation } from '../../ldp/representation/Representation'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; @@ -83,9 +82,7 @@ export class SparqlDataAccessor implements DataAccessor { */ public async getMetadata(identifier: ResourceIdentifier): Promise { const name = namedNode(identifier.path); - const query = isContainerIdentifier(identifier) ? - this.sparqlConstructContainer(name) : - this.sparqlConstruct(this.getMetadataNode(name)); + const query = this.sparqlConstruct(this.getMetadataNode(name)); const stream = await this.sendSparqlConstruct(query); const quads = await arrayifyStream(stream); @@ -101,6 +98,15 @@ export class SparqlDataAccessor implements DataAccessor { return metadata; } + public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + // Only triples that have a container identifier as subject are the containment triples + const name = namedNode(identifier.path); + const stream = await this.sendSparqlConstruct(this.sparqlConstruct(name)); + for await (const entry of stream) { + yield new RepresentationMetadata((entry as Quad).object as NamedNode); + } + } + /** * Writes the given metadata for the container. */ @@ -186,23 +192,6 @@ export class SparqlDataAccessor implements DataAccessor { }; } - private sparqlConstructContainer(name: NamedNode): ConstructQuery { - const pattern = quad(variable('s'), variable('p'), variable('o')); - return { - queryType: 'CONSTRUCT', - template: [ pattern ], - where: [{ - type: 'union', - patterns: [ - this.sparqlSelectGraph(name, [ pattern ]), - this.sparqlSelectGraph(this.getMetadataNode(name), [ pattern ]), - ], - }], - type: 'query', - prefixes: {}, - }; - } - private sparqlSelectGraph(name: NamedNode, triples: Quad[]): GraphPattern { return { type: 'graph', @@ -215,8 +204,8 @@ export class SparqlDataAccessor implements DataAccessor { * Creates an update query that overwrites the data and metadata of a resource. * If there are no triples we assume it's a container (so don't overwrite the main graph with containment triples). * @param name - Name of the resource to update. - * @param parent - Name of the parent to update the containment triples. * @param metadata - New metadata of the resource. + * @param parent - Name of the parent to update the containment triples. * @param triples - New data of the resource. */ private sparqlInsert(name: NamedNode, metadata: RepresentationMetadata, parent?: NamedNode, triples?: Quad[]): diff --git a/src/util/ResourceUtil.ts b/src/util/ResourceUtil.ts index b93769c71..f461465fe 100644 --- a/src/util/ResourceUtil.ts +++ b/src/util/ResourceUtil.ts @@ -1,5 +1,4 @@ import arrayifyStream from 'arrayify-stream'; -import { DataFactory } from 'n3'; import type { NamedNode, Quad } from 'rdf-js'; import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; import type { Representation } from '../ldp/representation/Representation'; @@ -27,18 +26,6 @@ export function generateResourceQuads(subject: NamedNode, isContainer: boolean): return quads; } -/** - * Helper function to generate the quads describing that the resource URIs are children of the container URI. - * @param containerURI - The URI of the container. - * @param childURIs - The URI of the child resources. - * - * @returns The generated quads. - */ -export function generateContainmentQuads(containerURI: NamedNode, childURIs: string[]): Quad[] { - return new RepresentationMetadata(containerURI, - { [LDP.contains]: childURIs.map(DataFactory.namedNode) }).quads(); -} - /** * Helper function to clone a representation, the original representation can still be used. * This function loads the entire stream in memory. diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index eb41b4b3a..405941d69 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -19,6 +19,7 @@ import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; import type { Guarded } from '../../../src/util/GuardedStream'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { trimTrailingSlashes } from '../../../src/util/PathUtil'; import * as quadUtil from '../../../src/util/QuadUtil'; import { guardedStreamFrom } from '../../../src/util/StreamUtil'; import { CONTENT_TYPE, HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies'; @@ -56,6 +57,15 @@ class SimpleDataAccessor implements DataAccessor { return this.data[identifier.path].metadata; } + public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + // Find all keys that look like children of the container + const children = Object.keys(this.data).filter((name): boolean => + name.startsWith(identifier.path) && + name.length > identifier.path.length && + !trimTrailingSlashes(name.slice(identifier.path.length)).includes('/')); + yield* children.map((name): RepresentationMetadata => new RepresentationMetadata({ path: name })); + } + public async modifyResource(): Promise { throw new Error('modify'); } @@ -100,7 +110,7 @@ class SimpleSuffixStrategy implements AuxiliaryStrategy { public async addMetadata(metadata: RepresentationMetadata): Promise { const identifier = { path: metadata.identifier.value }; // Random triple to test on - metadata.add(identifier.path, this.getAuxiliaryIdentifier(identifier).path); + metadata.add(namedNode('AUXILIARY'), this.getAuxiliaryIdentifier(identifier).path); } public async validate(): Promise { @@ -157,7 +167,7 @@ describe('A DataAccessorBasedStore', (): void => { expect(result).toMatchObject({ binary: true }); expect(await arrayifyStream(result.data)).toEqual([ resourceData ]); expect(result.metadata.contentType).toEqual('text/plain'); - expect(result.metadata.get(resourceID.path)?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path); + expect(result.metadata.get('AUXILIARY')?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path); }); it('will return a data stream that matches the metadata for containers.', async(): Promise => { @@ -170,22 +180,20 @@ describe('A DataAccessorBasedStore', (): void => { expect(result).toMatchObject({ binary: false }); expect(await arrayifyStream(result.data)).toBeRdfIsomorphic(metaMirror.quads()); expect(result.metadata.contentType).toEqual(INTERNAL_QUADS); - expect(result.metadata.get(resourceID.path)?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path); + expect(result.metadata.get('AUXILIARY')?.value).toBe(auxStrategy.getAuxiliaryIdentifier(resourceID).path); }); it('will remove containment triples referencing auxiliary resources.', async(): Promise => { const resourceID = { path: `${root}container/` }; containerMetadata.identifier = namedNode(resourceID.path); - containerMetadata.add(LDP.terms.contains, [ - DataFactory.namedNode(`${root}container/.dummy`), - DataFactory.namedNode(`${root}container/resource`), - DataFactory.namedNode(`${root}container/resource.dummy`), - ]); accessor.data[resourceID.path] = { metadata: containerMetadata } as Representation; + accessor.data[`${resourceID.path}.dummy`] = representation; + accessor.data[`${resourceID.path}resource`] = representation; + accessor.data[`${resourceID.path}resource.dummy`] = representation; const result = await store.getRepresentation(resourceID); const contains = result.metadata.getAll(LDP.terms.contains); expect(contains).toHaveLength(1); - expect(contains[0].value).toEqual(`${root}container/resource`); + expect(contains[0].value).toEqual(`${resourceID.path}resource`); }); }); @@ -280,7 +288,6 @@ describe('A DataAccessorBasedStore', (): void => { const resourceID = { path: root }; representation.metadata.add(HTTP.slug, 'newName'); accessor.data[`${root}newName`] = representation; - accessor.data[root].metadata.add(LDP.contains, DataFactory.namedNode(`${root}newName`)); const result = await store.addResource(resourceID, representation); expect(result).not.toEqual({ path: `${root}newName`, @@ -522,7 +529,7 @@ describe('A DataAccessorBasedStore', (): void => { it('will error when deleting non-empty containers.', async(): Promise => { accessor.data[`${root}container/`] = representation; - accessor.data[`${root}container/`].metadata.add(LDP.contains, DataFactory.namedNode(`${root}otherThing`)); + accessor.data[`${root}container/otherThing`] = representation; const result = store.deleteResource({ path: `${root}container/` }); await expect(result).rejects.toThrow(ConflictHttpError); await expect(result).rejects.toThrow('Can only delete empty containers.'); diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts index 7042d97a2..bc5858058 100644 --- a/test/unit/storage/accessors/FileDataAccessor.test.ts +++ b/test/unit/storage/accessors/FileDataAccessor.test.ts @@ -111,7 +111,7 @@ describe('A FileDataAccessor', (): void => { expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime)); }); - it('generates the metadata for a container and its non-meta children.', async(): Promise => { + it('generates the metadata for a container.', async(): Promise => { cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}}; metadata = await accessor.getMetadata({ path: `${base}container/` }); expect(metadata.identifier.value).toBe(`${base}container/`); @@ -121,18 +121,22 @@ describe('A FileDataAccessor', (): void => { expect(metadata.get(POSIX.size)).toBeUndefined(); expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime)); expect(metadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), XSD.terms.integer)); - expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray( - [ namedNode(`${base}container/resource`), namedNode(`${base}container/container2/`) ], - ); + }); - const childQuads = metadata.quads().filter((quad): boolean => - quad.subject.value === `${base}container/resource`); - const childMetadata = new RepresentationMetadata({ path: `${base}container/resource` }).addQuads(childQuads); - expect(childMetadata.get(RDF.type)?.value).toBe(LDP.Resource); - expect(childMetadata.get(POSIX.size)).toEqualRdfTerm(toLiteral('data'.length, XSD.terms.integer)); - expect(childMetadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime)); - expect(childMetadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), - XSD.terms.integer)); + it('generates metadata for container child resources.', async(): Promise => { + cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}}; + const children = []; + for await (const child of accessor.getChildren({ path: `${base}container/` })) { + children.push(child); + } + expect(children).toHaveLength(2); + for (const child of children) { + expect([ `${base}container/resource`, `${base}container/container2/` ]).toContain(child.identifier.value); + expect(child.getAll(RDF.type)!.some((type): boolean => type.equals(LDP.terms.Resource))).toBe(true); + expect(child.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime)); + expect(child.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), + XSD.terms.integer)); + } }); it('adds stored metadata when requesting metadata.', async(): Promise => { diff --git a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts index d8dc6d57c..f7e360210 100644 --- a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts +++ b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts @@ -1,6 +1,5 @@ import 'jest-rdf'; import type { Readable } from 'stream'; -import { DataFactory } from 'n3'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; import { InMemoryDataAccessor } from '../../../../src/storage/accessors/InMemoryDataAccessor'; @@ -85,16 +84,20 @@ describe('An InMemoryDataAccessor', (): void => { expect(metadata.quads()).toHaveLength(0); }); - it('generates the containment metadata for a container.', async(): Promise => { + it('generates the children for a container.', async(): Promise => { await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined(); await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata)) .resolves.toBeUndefined(); await expect(accessor.writeContainer({ path: `${base}container/container2/` }, metadata)) .resolves.toBeUndefined(); - metadata = await accessor.getMetadata({ path: `${base}container/` }); - expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray( - [ DataFactory.namedNode(`${base}container/resource`), DataFactory.namedNode(`${base}container/container2/`) ], - ); + + const children = []; + for await (const child of accessor.getChildren({ path: `${base}container/` })) { + children.push(child); + } + expect(children).toHaveLength(2); + expect(children[0].identifier.value).toEqual(`${base}container/resource`); + expect(children[1].identifier.value).toEqual(`${base}container/container2/`); }); it('adds stored metadata when requesting document metadata.', async(): Promise => { @@ -136,10 +139,16 @@ describe('An InMemoryDataAccessor', (): void => { metadata = await accessor.getMetadata(identifier); expect(metadata.identifier.value).toBe(`${base}container/`); const quads = metadata.quads(); - expect(quads).toHaveLength(3); + expect(quads).toHaveLength(2); expect(metadata.getAll(RDF.type).map((term): string => term.value)) .toEqual([ LDP.Container, LDP.BasicContainer ]); - expect(metadata.get(LDP.contains)?.value).toEqual(`${base}container/resource`); + + const children = []; + for await (const child of accessor.getChildren({ path: `${base}container/` })) { + children.push(child); + } + expect(children).toHaveLength(1); + expect(children[0].identifier.value).toEqual(`${base}container/resource`); await expect(accessor.getMetadata({ path: `${base}container/resource` })) .resolves.toBeInstanceOf(RepresentationMetadata); @@ -147,7 +156,7 @@ describe('An InMemoryDataAccessor', (): void => { }); it('can write to the root container without overriding its children.', async(): Promise => { - const identifier = { path: `${base}` }; + const identifier = { path: base }; const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: LDP.terms.Container }); await expect(accessor.writeContainer(identifier, inputMetadata)).resolves.toBeUndefined(); const resourceMetadata = new RepresentationMetadata(); @@ -158,21 +167,39 @@ describe('An InMemoryDataAccessor', (): void => { metadata = await accessor.getMetadata(identifier); expect(metadata.identifier.value).toBe(`${base}`); const quads = metadata.quads(); - expect(quads).toHaveLength(2); + expect(quads).toHaveLength(1); expect(metadata.getAll(RDF.type)).toHaveLength(1); - expect(metadata.getAll(LDP.contains)).toHaveLength(1); + + const children = []; + for await (const child of accessor.getChildren(identifier)) { + children.push(child); + } + expect(children).toHaveLength(1); + expect(children[0].identifier.value).toEqual(`${base}resource`); await expect(accessor.getMetadata({ path: `${base}resource` })) .resolves.toBeInstanceOf(RepresentationMetadata); expect(await readableToString(await accessor.getData({ path: `${base}resource` }))).toBe('data'); }); - it('errors when writing to an invalid container path..', async(): Promise => { + it('errors when writing to an invalid container path.', async(): Promise => { await expect(accessor.writeDocument({ path: `${base}resource/` }, data, metadata)).resolves.toBeUndefined(); await expect(accessor.writeContainer({ path: `${base}resource/container` }, metadata)) .rejects.toThrow('Invalid path.'); }); + + it('returns no children for documents.', async(): Promise => { + const identifier = { path: `${base}resource` }; + const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: LDP.terms.Resource }); + await expect(accessor.writeDocument(identifier, data, inputMetadata)).resolves.toBeUndefined(); + + const children = []; + for await (const child of accessor.getChildren(identifier)) { + children.push(child); + } + expect(children).toHaveLength(0); + }); }); describe('deleting a resource', (): void => { diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts index f1742b55b..8ddb37dfa 100644 --- a/test/unit/storage/accessors/SparqlDataAccessor.test.ts +++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts @@ -103,7 +103,7 @@ describe('A SparqlDataAccessor', (): void => { )); }); - it('requests container data for generating its metadata.', async(): Promise => { + it('does not set the content-type for container metadata.', async(): Promise => { metadata = await accessor.getMetadata({ path: 'http://container/' }); expect(metadata.quads()).toBeRdfIsomorphic([ quad(namedNode('this'), namedNode('a'), namedNode('triple')), @@ -111,13 +111,25 @@ describe('A SparqlDataAccessor', (): void => { expect(fetchTriples).toHaveBeenCalledTimes(1); expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); - expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([ - 'CONSTRUCT { ?s ?p ?o. } WHERE {', - ' { GRAPH { ?s ?p ?o. } }', - ' UNION', - ' { GRAPH { ?s ?p ?o. } }', - '}', - ])); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery( + 'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH { ?s ?p ?o. } }', + )); + }); + + it('requests the container data to find its children.', async(): Promise => { + triples = [ quad(namedNode('http://container/'), LDP.terms.contains, namedNode('http://container/child')) ]; + const children = []; + for await (const child of accessor.getChildren({ path: 'http://container/' })) { + children.push(child); + } + expect(children).toHaveLength(1); + expect(children[0].identifier.value).toBe('http://container/child'); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery( + 'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH { ?s ?p ?o. } }', + )); }); it('throws 404 if no metadata was found.', async(): Promise => { diff --git a/test/util/Util.ts b/test/util/Util.ts index 11d4f0325..e2f6b694f 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -1,4 +1,5 @@ -import type { Stats } from 'fs'; +import type { Dirent, Stats } from 'fs'; + import { PassThrough } from 'stream'; import streamifyArray from 'streamify-array'; import type { SystemError } from '../../src/util/errors/SystemError'; @@ -149,6 +150,19 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { } return Object.keys(folder[name]); }, + async* opendir(path: string): AsyncIterableIterator { + const { folder, name } = getFolder(path); + if (!folder[name]) { + throwSystemError('ENOENT'); + } + for (const child of Object.keys(folder[name])) { + yield { + name: child, + isFile: (): boolean => typeof folder[name][child] === 'string', + isDirectory: (): boolean => typeof folder[name][child] === 'object', + } as Dirent; + } + }, mkdir(path: string): void { const { folder, name } = getFolder(path); if (folder[name]) {