import { AlbumBaseWithRefs, AlbumWithRefsWithId, ArtistBaseWithRefs, ArtistWithRefsWithId, DBDataFormat, PostAlbumRequest, PostArtistRequest, PostTagRequest, PostTrackRequest, TagBaseWithRefs, TagWithRefsWithId, TrackBaseWithRefs, TrackWithDetails, TrackWithRefsWithId } from "../../../client/src/api/api"; import { makeNotFoundError } from "../../db/common"; import { DBError, DBErrorKind } from "../../endpoints/types"; import filterInPlace from "../../lib/filterInPlace"; let _ = require('lodash'); // The mock reference database is in the same format as // the JSON import/export format, for multiple users. export type ReferenceDatabase = Record type ObjectsType = "tracks" | "artists" | "tags" | "albums"; // Get a fresh ID for a new object. function getNewId(db: ReferenceDatabase, objectsType: ObjectsType): number { let highest: number = 1; for (const data of Object.values(db)) { data[objectsType].forEach((obj: any) => highest = Math.max(highest, obj.id)); } return highest + 1; } // Check a (set of) IDs for presence in the objects array. // All have to exist for it to return true. function checkExists(objects: any[], ids: number[]) { return ids.reduce((prev: boolean, id: number) => { return prev && objects.find((object: any) => object.id === id); }, true); } // If not in the array, put the number in the array. function ensureInSet(n: number, s: number[]) { if (!s.includes(n)) { s.push(n); } } // For a set of objects, ensure they point to another object. function ensureLinked(fromObjects: number[], fromObjectsType: ObjectsType, toId: number, toObjectsType: ObjectsType, exact: boolean, data: DBDataFormat) { if (toObjectsType === 'tracks') { (data[fromObjectsType] as any).forEach((fromObject: AlbumWithRefsWithId | ArtistWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { ensureInSet(toId, fromObject.trackIds); } else if (exact) { fromObject.trackIds = fromObject.trackIds.filter((id: number) => id !== toId) } }); } else if (toObjectsType === 'artists') { (data[fromObjectsType] as any).forEach((fromObject: AlbumWithRefsWithId | TrackWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { ensureInSet(toId, fromObject.artistIds); } else if (exact) { fromObject.artistIds = fromObject.artistIds.filter((id: number) => id !== toId) } }); } else if (toObjectsType === 'albums' && fromObjectsType === 'artists') { (data[fromObjectsType] as any).forEach((fromObject: ArtistWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { ensureInSet(toId, fromObject.albumIds); } else if (exact) { fromObject.albumIds = fromObject.albumIds.filter((id: number) => id !== toId) } }); } else if (toObjectsType === 'albums' && fromObjectsType === 'tracks') { (data[fromObjectsType] as any).forEach((fromObject: TrackWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { fromObject.albumId = toId; } else if (exact && fromObject.albumId === toId) { fromObject.albumId = null; } }); } else if (toObjectsType === 'tags' && fromObjectsType === 'tags') { (data[fromObjectsType] as any).forEach((fromObject: TagWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { fromObject.parentId = toId; } else if (exact && fromObject.parentId === toId) { fromObject.parentId = null; } }); } else if (toObjectsType === 'tags') { (data[fromObjectsType] as any).forEach((fromObject: AlbumWithRefsWithId | TrackWithRefsWithId | ArtistWithRefsWithId) => { if (fromObjects.includes(fromObject.id)) { ensureInSet(toId, fromObject.tagIds); } else if (exact) { fromObject.tagIds = fromObject.tagIds.filter((id: number) => id !== toId) } }); } } // Create a new object. // The general procedure for this is: // - check that any existing objects referenced in the new object actually exist // - generate a new ID and insert the object // - add reverse references into any existing object referenced by the new object. export interface ReferencingField { field: string, otherObjectType: ObjectsType, doReverseReference: boolean }; export function createObject( userId: number, object: any, objectType: ObjectsType, singularReverseRefs: ReferencingField[], pluralReverseRefs: ReferencingField[], db: ReferenceDatabase ): { id: number } { // Existence checks if (!(userId in db)) { throw makeNotFoundError() } singularReverseRefs.forEach((f: ReferencingField) => { if (!checkExists(db[userId][f.otherObjectType], object[f.field] ? [object[f.field]] : [])) { throw makeNotFoundError(); } }); pluralReverseRefs.forEach((f: ReferencingField) => { if (!checkExists(db[userId][f.otherObjectType], object[f.field] || [])) { throw makeNotFoundError(); } }); // Create an ID and the object let id = getNewId(db, objectType); db[userId][objectType].push({ ...object, id: id, }) // reverse links singularReverseRefs.forEach((f: ReferencingField) => { f.doReverseReference && ensureLinked(object[f.field] ? [object[f.field]] : [], f.otherObjectType, id, objectType, true, db[userId]); }); pluralReverseRefs.forEach((f: ReferencingField) => { f.doReverseReference && ensureLinked(object[f.field] || [], f.otherObjectType, id, objectType, true, db[userId]); }); return { id: id }; } // Delete an object. // The general procedure for this is: // - check that the to-be-deleted object exists // - remove any references that exist to the object in other objects // - delete the object export function deleteObject( userId: number, objectId: number, objectType: ObjectsType, singularRefsToThisObject: ReferencingField[], pluralRefsToThisObject: ReferencingField[], db: ReferenceDatabase ): void { // Existence checks if (!(userId in db)) { throw makeNotFoundError() } // Find the object to delete. let idx = db[userId][objectType].findIndex((o: any) => 'id' in o && o.id === objectId); if (idx < 0) { // Not found throw makeNotFoundError(); } // Remove references to this object pluralRefsToThisObject.forEach((f: ReferencingField) => { f.doReverseReference && db[userId][f.otherObjectType].forEach((other: any) => { filterInPlace(other[f.field], (oid: number) => oid !== objectId) }) }); singularRefsToThisObject.forEach((f: ReferencingField) => { f.doReverseReference && db[userId][f.otherObjectType].forEach((other: any) => { if (other[f.field] === objectId) { other[f.field] = null; } }) }); // Delete the object db[userId][objectType].splice(idx, 1); } // Modify an existing object. // This can be a complete replacement or a partial change. export function modifyObject( userId: number, objectId: number, objectUpdates: any, objectType: ObjectsType, singularReverseRefs: ReferencingField[], pluralReverseRefs: ReferencingField[], db: ReferenceDatabase ): void { // Existence checks if (!(userId in db)) { throw makeNotFoundError() } let object = (db[userId][objectType] as any[]).find((o: any) => 'id' in o && o.id === objectId); if (!object) { // Not found throw makeNotFoundError(); } singularReverseRefs.forEach((f: ReferencingField) => { if (f.field in objectUpdates && !checkExists(db[userId][f.otherObjectType], objectUpdates[f.field] ? [objectUpdates[f.field]] : [])) { throw makeNotFoundError(); } }); pluralReverseRefs.forEach((f: ReferencingField) => { if (f.field in objectUpdates && !checkExists(db[userId][f.otherObjectType], objectUpdates[f.field] || [])) { throw makeNotFoundError(); } }); // Update the object _.extend(object, objectUpdates); // reverse links singularReverseRefs.forEach((f: ReferencingField) => { f.doReverseReference && ensureLinked(object[f.field] ? [object[f.field]] : [], f.otherObjectType, objectId, objectType, true, db[userId]); }); pluralReverseRefs.forEach((f: ReferencingField) => { f.doReverseReference && ensureLinked(object[f.field] || [], f.otherObjectType, objectId, objectType, true, db[userId]); }); } // Create a new track. export function createTrack(userId: number, track: PostTrackRequest, db: ReferenceDatabase): { id: number } { return createObject( userId, track, 'tracks', [{ field: 'albumId', otherObjectType: 'albums', doReverseReference: true }], [ { field: 'artistIds', otherObjectType: 'artists', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db ); } // Create a new album. export function createAlbum(userId: number, album: PostAlbumRequest, db: ReferenceDatabase): { id: number } { return createObject( userId, album, 'albums', [], [ { field: 'artistIds', otherObjectType: 'artists', doReverseReference: true }, { field: 'trackIds', otherObjectType: 'tracks', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db ); } // Create a new artist. export function createArtist(userId: number, artist: PostArtistRequest, db: ReferenceDatabase): { id: number } { return createObject( userId, artist, 'artists', [], [ { field: 'albumIds', otherObjectType: 'albums', doReverseReference: true }, { field: 'trackIds', otherObjectType: 'tracks', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db ); } // Create a new tag. export function createTag(userId: number, tag: PostTagRequest, db: ReferenceDatabase): { id: number } { return createObject( userId, tag, 'tags', [{ field: 'parentId', otherObjectType: 'tags', doReverseReference: false },], [], db ); } // Delete a track. export function deleteTrack(userId: number, id: number, db: ReferenceDatabase): void { return deleteObject( userId, id, 'tracks', [], [ { field: 'trackIds', otherObjectType: 'albums', doReverseReference: true }, { field: 'trackIds', otherObjectType: 'artists', doReverseReference: true }, ], db ); } // Delete an artist. export function deleteArtist(userId: number, id: number, db: ReferenceDatabase): void { return deleteObject( userId, id, 'artists', [], [ { field: 'artistIds', otherObjectType: 'tracks', doReverseReference: true }, { field: 'artistIds', otherObjectType: 'albums', doReverseReference: true }, ], db ); } // Delete a track. export function deleteAlbum(userId: number, id: number, db: ReferenceDatabase): void { return deleteObject( userId, id, 'albums', [{ field: 'albumId', otherObjectType: 'tracks', doReverseReference: true }], [{ field: 'albumIds', otherObjectType: 'artists', doReverseReference: true },], db ); } // Delete a tag. export function deleteTag(userId: number, id: number, db: ReferenceDatabase): void { // Tags are special in that deleting them also deletes their children. Implement that here // with recursive calls. if (!(userId in db)) { throw makeNotFoundError() } let tag = db[userId].tags.find((o: any) => 'id' in o && o.id === id); if (!tag) { throw makeNotFoundError(); } let children = db[userId].tags.filter((t: TagWithRefsWithId) => t.parentId === id); children.forEach((child: TagWithRefsWithId) => { deleteTag(userId, child.id, db) }) // Do the actual deletion of this tag. return deleteObject( userId, id, 'tags', [], [ { field: 'tagIds', otherObjectType: 'albums', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'artists', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tracks', doReverseReference: true }, ], db ); } // Modify a track. export function modifyTrack(userId: number, id: number, updates: TrackBaseWithRefs, db: ReferenceDatabase): void { return modifyObject(userId, id, updates, 'tracks', [{ field: 'albumId', otherObjectType: 'albums', doReverseReference: true }], [ { field: 'artistIds', otherObjectType: 'artists', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db); } // Modify an artist. export function modifyArtist(userId: number, id: number, updates: ArtistBaseWithRefs, db: ReferenceDatabase): void { return modifyObject(userId, id, updates, 'artists', [], [ { field: 'albumIds', otherObjectType: 'albums', doReverseReference: true }, { field: 'trackIds', otherObjectType: 'tracks', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db); } // Modify an album. export function modifyAlbum(userId: number, id: number, updates: AlbumBaseWithRefs, db: ReferenceDatabase): void { return modifyObject(userId, id, updates, 'albums', [], [ { field: 'artistIds', otherObjectType: 'artists', doReverseReference: true }, { field: 'trackIds', otherObjectType: 'tracks', doReverseReference: true }, { field: 'tagIds', otherObjectType: 'tags', doReverseReference: false }, ], db); } // Modify a tag. export function modifyTag(userId: number, id: number, updates: TagBaseWithRefs, db: ReferenceDatabase): void { // A special case for tracks is that we do not allow "parent cycles", which could happen // through modification. Catch that here. if (!(userId in db)) { throw makeNotFoundError() } let tag = (db[userId].tags as any[]).find((o: any) => 'id' in o && o.id === id); if (!tag) { throw makeNotFoundError() } let getChildrenRecursive: (tag: TagWithRefsWithId) => number[] = (tag: TagWithRefsWithId) => { let directChildren: TagWithRefsWithId[] = db[userId].tags .filter((t: TagWithRefsWithId) => t.parentId === tag.id); let indirectChildren: number[][] = directChildren.map((child: TagWithRefsWithId) => getChildrenRecursive(child)) return [...(directChildren.map((t: TagWithRefsWithId) => t.id)), ...indirectChildren.flat()]; } if (updates.parentId && [...getChildrenRecursive(tag), id].includes(updates.parentId)) { const e: DBError = { name: "DBError", kind: DBErrorKind.ResourceConflict, message: 'Modifying this tag would cause a tag parent cycle.', }; throw e; } return modifyObject(userId, id, updates, 'tags', [{ field: 'parentId', otherObjectType: 'tags', doReverseReference: false },], [], db); }