diff --git a/src/integrations/database/Database.ts b/src/integrations/database/Database.ts index 74e76044..123acc49 100644 --- a/src/integrations/database/Database.ts +++ b/src/integrations/database/Database.ts @@ -37,6 +37,16 @@ export default class Database implements Driver return this.#driver.readRecord(type, id, fields); } + findRecord(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort): Promise + { + return this.#driver.findRecord(type, query, fields, sort); + } + + searchRecords(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort, limit?: number, offset?: number): Promise + { + return this.#driver.searchRecords(type, query, fields, sort, limit, offset); + } + updateRecord(type: RecordType, id: RecordId, data: RecordData): Promise { const cleanData = sanitize(data); @@ -44,19 +54,21 @@ export default class Database implements Driver return this.#driver.updateRecord(type, id, cleanData); } - deleteRecord(type: RecordType, id: RecordId): Promise + updateRecords(type: RecordType, query: RecordQuery, data: RecordData): Promise { - return this.#driver.deleteRecord(type, id); + const cleanData = sanitize(data); + + return this.#driver.updateRecords(type, query, cleanData); } - findRecord(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort): Promise + deleteRecord(type: RecordType, id: RecordId): Promise { - return this.#driver.findRecord(type, query, fields, sort); + return this.#driver.deleteRecord(type, id); } - searchRecords(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort, limit?: number, offset?: number): Promise + deleteRecords(type: RecordType, query: RecordQuery): Promise { - return this.#driver.searchRecords(type, query, fields, sort, limit, offset); + return this.#driver.deleteRecords(type, query); } clear(): Promise diff --git a/src/integrations/database/definitions/interfaces.ts b/src/integrations/database/definitions/interfaces.ts index d5da0963..eae1f611 100644 --- a/src/integrations/database/definitions/interfaces.ts +++ b/src/integrations/database/definitions/interfaces.ts @@ -9,9 +9,11 @@ export interface Driver disconnect(): Promise; createRecord(type: RecordType, data: RecordData): Promise; readRecord(type: RecordType, id: RecordId, fields?: RecordField[]): Promise; - updateRecord(type: RecordType, id: RecordId, data: RecordData): Promise; - deleteRecord(type: RecordType, id: RecordId): Promise; findRecord(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort): Promise; searchRecords(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort, limit?: number, offset?: number): Promise; + updateRecord(type: RecordType, id: RecordId, data: RecordData): Promise; + updateRecords(type: RecordType, query: RecordQuery, data: RecordData): Promise; + deleteRecord(type: RecordType, id: RecordId): Promise; + deleteRecords(type: RecordType, query: RecordQuery): Promise; clear(): Promise; } diff --git a/src/integrations/database/errors/RecordsNotDeleted.ts b/src/integrations/database/errors/RecordsNotDeleted.ts new file mode 100644 index 00000000..2c2467a9 --- /dev/null +++ b/src/integrations/database/errors/RecordsNotDeleted.ts @@ -0,0 +1,10 @@ + +import DatabaseError from './DatabaseError'; + +export default class RecordsNotDeleted extends DatabaseError +{ + constructor(message?: string) + { + super(message ?? 'Records not deleted'); + } +} diff --git a/src/integrations/database/errors/RecordsNotUpdated.ts b/src/integrations/database/errors/RecordsNotUpdated.ts new file mode 100644 index 00000000..a22d6dca --- /dev/null +++ b/src/integrations/database/errors/RecordsNotUpdated.ts @@ -0,0 +1,10 @@ + +import DatabaseError from './DatabaseError'; + +export default class RecordsNotUpdated extends DatabaseError +{ + constructor(message?: string) + { + super(message ?? 'Records not updated'); + } +} diff --git a/src/integrations/database/implementations/memory/Memory.ts b/src/integrations/database/implementations/memory/Memory.ts index 41d42f6b..64304c32 100644 --- a/src/integrations/database/implementations/memory/Memory.ts +++ b/src/integrations/database/implementations/memory/Memory.ts @@ -57,8 +57,7 @@ export default class Memory implements Driver async readRecord(type: string, id: string, fields?: string[]): Promise { - const collection = this.#getCollection(type); - const record = collection.find(object => object.id === id); + const record = this.#fetchRecord(type, id); if (record === undefined) { @@ -68,26 +67,46 @@ export default class Memory implements Driver return this.#buildRecordData(record, fields); } + async findRecord(type: string, query: QueryStatement, fields?: string[], sort?: RecordSort): Promise + { + const result = await this.searchRecords(type, query, fields, sort, 1, 0); + + return result[0]; + } + + async searchRecords(type: string, query: QueryStatement, fields?: string[], sort?: RecordSort, limit?: number, offset?: number): Promise + { + const records = this.#fetchRecords(type, query); + + const sortedRecords = this.#sortRecords(records, sort); + const limitedRecords = this.#limitNumberOfRecords(sortedRecords, offset, limit); + + return limitedRecords.map(record => this.#buildRecordData(record, fields)); + } + async updateRecord(type: string, id: string, data: RecordData): Promise { - const collection = this.#getCollection(type); - const record = collection.find(object => object.id === id); + const record = this.#fetchRecord(type, id); if (record === undefined) { throw new RecordNotUpdated(); } - for (const key of Object.keys(data)) - { - record[key] = data[key]; - } + this.#updateRecordData(record, data); + } + + async updateRecords(type: string, query: QueryStatement, data: RecordData): Promise + { + const records = this.#fetchRecords(type, query); + + records.forEach(record => this.#updateRecordData(record, data)); } async deleteRecord(type: string, id: string): Promise { const collection = this.#getCollection(type); - const index = collection.findIndex(object => object.id === id); + const index = collection.findIndex(record => record.id === id); if (index === -1) { @@ -97,28 +116,44 @@ export default class Memory implements Driver collection.splice(index, 1); } - async findRecord(type: string, query: QueryStatement, fields?: string[], sort?: RecordSort): Promise + async deleteRecords(type: string, query: QueryStatement): Promise { - const result = await this.searchRecords(type, query, fields, sort, 1, 0); + const collection = this.#getCollection(type); + const records = this.#fetchRecords(type, query); - return result[0]; + const indexes = records + .map(fetchedRecord => collection.findIndex(collectionRecord => collectionRecord.id === fetchedRecord.id)) + .sort((a, b) => b - a); // Reverse the order of indexes to delete from the end to the beginning + + indexes.forEach(index => collection.splice(index, 1)); } - async searchRecords(type: string, query: QueryStatement, fields?: string[], sort?: RecordSort, limit?: number, offset?: number): Promise + async clear(): Promise + { + this.#memory.clear(); + } + + #fetchRecord(type: string, id: string) { const collection = this.#getCollection(type); - const filterFunction = this.#buildFilterFunction(query); - const result = collection.filter(filterFunction); - const sortedResult = this.#sortRecords(result, sort); - const limitedResult = this.#limitNumberOfRecords(sortedResult, offset, limit); + return collection.find(object => object.id === id); + } + + #fetchRecords(type: string, query: QueryStatement) + { + const collection = this.#getCollection(type); + const filterFunction = this.#buildFilterFunction(query); - return limitedResult.map(records => this.#buildRecordData(records, fields)); + return collection.filter(filterFunction); } - async clear(): Promise + #updateRecordData(record: RecordData, data: RecordData) { - this.#memory.clear(); + for (const key of Object.keys(data)) + { + record[key] = data[key]; + } } #limitNumberOfRecords(result: RecordData[], offset?: number, limit?: number): RecordData[] diff --git a/src/integrations/database/implementations/mongodb/MongoDb.ts b/src/integrations/database/implementations/mongodb/MongoDb.ts index 8de096bb..3c7389d7 100644 --- a/src/integrations/database/implementations/mongodb/MongoDb.ts +++ b/src/integrations/database/implementations/mongodb/MongoDb.ts @@ -13,6 +13,8 @@ import RecordNotCreated from '../../errors/RecordNotCreated'; import RecordNotDeleted from '../../errors/RecordNotDeleted'; import RecordNotFound from '../../errors/RecordNotFound'; import RecordNotUpdated from '../../errors/RecordNotUpdated'; +import RecordsNotDeleted from '../../errors/RecordsNotDeleted'; +import RecordsNotUpdated from '../../errors/RecordsNotUpdated'; const UNKNOWN_ERROR = 'Unknown error'; @@ -136,6 +138,25 @@ export default class MongoDB implements Driver return this.#buildRecordData(entry as Document, fields); } + async findRecord(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort): Promise + { + const result = await this.searchRecords(type, query, fields, sort, 1, 0); + + return result[0]; + } + + async searchRecords(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort, limit?: number, offset?: number): Promise + { + const mongoQuery = this.#buildMongoQuery(query); + const mongoSort = this.#buildMongoSort(sort); + + const collection = await this.#getCollection(type); + const cursor = collection.find(mongoQuery, { sort: mongoSort, limit: limit, skip: offset }); + const result = await cursor.toArray(); + + return result.map(data => this.#buildRecordData(data, fields)); + } + async updateRecord(type: RecordType, id: RecordId, data: RecordData): Promise { const collection = await this.#getCollection(type); @@ -147,34 +168,41 @@ export default class MongoDB implements Driver } } - async deleteRecord(type: RecordType, id: RecordId): Promise + async updateRecords(type: RecordType, query: RecordQuery, data: RecordData): Promise { + const mongoQuery = this.#buildMongoQuery(query); + const collection = await this.#getCollection(type); - const result = await collection.deleteOne({ _id: id }); + const result = await collection.updateMany(mongoQuery, { $set: data }); - if (result.deletedCount !== 1) + if (result.acknowledged === false) { - throw new RecordNotDeleted(); + throw new RecordsNotUpdated(); } } - async findRecord(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort): Promise + async deleteRecord(type: RecordType, id: RecordId): Promise { - const result = await this.searchRecords(type, query, fields, sort, 1, 0); + const collection = await this.#getCollection(type); + const result = await collection.deleteOne({ _id: id }); - return result[0]; + if (result.deletedCount !== 1) + { + throw new RecordNotDeleted(); + } } - async searchRecords(type: RecordType, query: RecordQuery, fields?: RecordField[], sort?: RecordSort, limit?: number, offset?: number): Promise + async deleteRecords(type: RecordType, query: RecordQuery): Promise { const mongoQuery = this.#buildMongoQuery(query); - const mongoSort = this.#buildMongoSort(sort); const collection = await this.#getCollection(type); - const cursor = collection.find(mongoQuery, { sort: mongoSort, limit: limit, skip: offset }); - const result = await cursor.toArray(); + const result = await collection.deleteMany(mongoQuery); - return result.map(data => this.#buildRecordData(data, fields)); + if (result.acknowledged === false) + { + throw new RecordsNotDeleted(); + } } async clear(): Promise diff --git a/test/integrations/database/fixtures/queries.fixture.ts b/test/integrations/database/fixtures/queries.fixture.ts index ca65cbe7..c006a9d3 100644 --- a/test/integrations/database/fixtures/queries.fixture.ts +++ b/test/integrations/database/fixtures/queries.fixture.ts @@ -7,6 +7,7 @@ const { CALZONE, VEGETARIAN, HAWAII } = RECORDS.PIZZAS; export const QUERIES: Record = { + UPDATED: { size: { EQUALS: 40 } }, EMPTY: {}, NO_MATCH: { name: { EQUALS: 'Not existing' } }, diff --git a/test/integrations/database/fixtures/values.fixture.ts b/test/integrations/database/fixtures/values.fixture.ts index 4f3074d7..288c5793 100644 --- a/test/integrations/database/fixtures/values.fixture.ts +++ b/test/integrations/database/fixtures/values.fixture.ts @@ -20,5 +20,15 @@ export const VALUES = UPDATES: { COUNTRY: 'France' + }, + + SIZE: + { + size: 40 + }, + + NO_MATCH_SIZE: + { + size: 99 } }; diff --git a/test/integrations/database/implementation.spec.ts b/test/integrations/database/implementation.spec.ts index 00e5a90c..3286687a 100644 --- a/test/integrations/database/implementation.spec.ts +++ b/test/integrations/database/implementation.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import type { RecordData } from '^/integrations/database'; import database, { RecordNotFound, RecordNotUpdated } from '^/integrations/database'; import { DATABASES, QUERIES, RECORDS, RECORD_TYPES, RESULTS, SORTS, VALUES } from './fixtures'; @@ -12,6 +13,7 @@ beforeEach(async () => describe('integrations/database/implementation', () => { + describe('.readRecord', () => { it('should read a full record by id', async () => @@ -33,48 +35,26 @@ describe('integrations/database/implementation', () => expect(result?.folded).toBeFalsy(); }); - it('should throw an error when a record is not found', async () => - { - const promise = database.readRecord(RECORD_TYPES.PIZZAS, VALUES.IDS.NON_EXISTING); - await expect(promise).rejects.toStrictEqual(new RecordNotFound()); - }); }); - describe('.deleteRecord', () => + describe('.findRecord', () => { - it('should delete a record by id', async () => - { - const id = RECORDS.FRUITS.APPLE.id as string; - - await database.deleteRecord(RECORD_TYPES.FRUITS, id); - - const promise = database.readRecord(RECORD_TYPES.FRUITS, id); - await expect(promise).rejects.toStrictEqual(new RecordNotFound()); - }); - - it('should throw an error when the record cannot be deleted', async () => + it('should return the first matched record', async () => { - const promise = database.deleteRecord(RECORD_TYPES.PIZZAS, VALUES.IDS.NON_EXISTING); - await expect(promise).rejects.toStrictEqual(new RecordNotFound()); + const result = await database.findRecord(RECORD_TYPES.PIZZAS, QUERIES.EMPTY); + expect(result).toMatchObject(RECORDS.PIZZAS.MARGHERITA); }); - }); - describe('.updateRecord', () => - { - it('should update a record by id', async () => + it('should return undefined when no match found', async () => { - const id = RECORDS.FRUITS.PEAR.id as string; - - await database.updateRecord(RECORD_TYPES.FRUITS, id, { country: VALUES.UPDATES.COUNTRY }); - - const result = await database.readRecord(RECORD_TYPES.FRUITS, id); - expect(result?.country).toBe(VALUES.UPDATES.COUNTRY); + const result = await database.findRecord(RECORD_TYPES.PIZZAS, QUERIES.NO_MATCH); + expect(result).toBe(undefined); }); - it('should throw an error when record cannot be updated', async () => + it('should throw an error when a record is not found', async () => { - const promise = database.updateRecord(RECORD_TYPES.FRUITS, VALUES.IDS.NON_EXISTING, {}); - await expect(promise).rejects.toStrictEqual(new RecordNotUpdated()); + const promise = database.readRecord(RECORD_TYPES.PIZZAS, VALUES.IDS.NON_EXISTING); + await expect(promise).rejects.toStrictEqual(new RecordNotFound()); }); }); @@ -213,34 +193,84 @@ describe('integrations/database/implementation', () => }); }); - describe('.sortRecords', () => + describe('.updateRecord', () => { - it('should sort records ascending', async () => + it('should update a record by id', async () => { - const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.ASCENDING); - expect(result).toHaveLength(5); - expect(result).toMatchObject(RESULTS.SORTED_ASCENDING); + const id = RECORDS.FRUITS.PEAR.id as string; + + await database.updateRecord(RECORD_TYPES.FRUITS, id, { country: VALUES.UPDATES.COUNTRY }); + + const result = await database.readRecord(RECORD_TYPES.FRUITS, id); + expect(result?.country).toBe(VALUES.UPDATES.COUNTRY); }); - it('should sort the records descending', async () => + it('should throw an error when record cannot be updated', async () => { - const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.DESCENDING); - expect(result).toHaveLength(5); - expect(result).toMatchObject(RESULTS.SORTED_DESCENDING); + const promise = database.updateRecord(RECORD_TYPES.FRUITS, VALUES.IDS.NON_EXISTING, {}); + await expect(promise).rejects.toStrictEqual(new RecordNotUpdated()); }); + }); - it('should sort the records by multiple fields in the same direction', async () => + describe('.updateRecords', () => + { + it('should update all records matching the query', async () => { - const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.MULTIPLE_SAME); - expect(result).toHaveLength(5); - expect(result).toMatchObject(RESULTS.SORTED_MULTIPLE_SAME); + const data: RecordData = VALUES.SIZE; + await database.updateRecords(RECORD_TYPES.PIZZAS, QUERIES.EQUALS, data); + + const records = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.UPDATED); + expect(records).toHaveLength(2); + expect(records[0].size).toBe(VALUES.SIZE.size); + expect(records[1].size).toBe(VALUES.SIZE.size); }); - it('should sort the records by multiple fields in different direction', async () => + it('should not throw an error when no records match the query', async () => { - const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.MULTIPLE_DIFFERENT); - expect(result).toHaveLength(5); - expect(result).toMatchObject(RESULTS.SORTED_MULTIPLE_DIFFERENT); + const data: RecordData = VALUES.NO_MATCH_SIZE; + + // This should not throw an error + await expect(database.updateRecords(RECORD_TYPES.PIZZAS, QUERIES.NO_MATCH, data)) + .resolves.toBeUndefined(); + }); + }); + + describe('.deleteRecord', () => + { + it('should delete a record by id', async () => + { + const id = RECORDS.FRUITS.APPLE.id as string; + + await database.deleteRecord(RECORD_TYPES.FRUITS, id); + + const promise = database.readRecord(RECORD_TYPES.FRUITS, id); + await expect(promise).rejects.toStrictEqual(new RecordNotFound()); + }); + + it('should throw an error when the record cannot be deleted', async () => + { + const promise = database.deleteRecord(RECORD_TYPES.PIZZAS, VALUES.IDS.NON_EXISTING); + await expect(promise).rejects.toStrictEqual(new RecordNotFound()); + }); + }); + + describe('.deleteRecords', () => + { + it('should delete all records matching the query', async () => + { + await database.deleteRecords(RECORD_TYPES.PIZZAS, QUERIES.EQUALS); + + const records = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EQUALS); + expect(records).toHaveLength(0); + }); + + it('should not throw an error when no records match the query', async () => + { + await expect(database.deleteRecords(RECORD_TYPES.PIZZAS, QUERIES.NO_MATCH)) + .resolves.toBeUndefined(); + + const records = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY); + expect(records).toHaveLength(5); }); }); @@ -260,4 +290,35 @@ describe('integrations/database/implementation', () => expect(result).toMatchObject(RESULTS.LIMITED_BY_OFFSET); }); }); + + describe('.sortRecords', () => + { + it('should sort records ascending', async () => + { + const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.ASCENDING); + expect(result).toHaveLength(5); + expect(result).toMatchObject(RESULTS.SORTED_ASCENDING); + }); + + it('should sort the records descending', async () => + { + const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.DESCENDING); + expect(result).toHaveLength(5); + expect(result).toMatchObject(RESULTS.SORTED_DESCENDING); + }); + + it('should sort the records by multiple fields in the same direction', async () => + { + const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.MULTIPLE_SAME); + expect(result).toHaveLength(5); + expect(result).toMatchObject(RESULTS.SORTED_MULTIPLE_SAME); + }); + + it('should sort the records by multiple fields in different direction', async () => + { + const result = await database.searchRecords(RECORD_TYPES.PIZZAS, QUERIES.EMPTY, undefined, SORTS.MULTIPLE_DIFFERENT); + expect(result).toHaveLength(5); + expect(result).toMatchObject(RESULTS.SORTED_MULTIPLE_DIFFERENT); + }); + }); });