import type { EntityConfiguration, FieldTransformerMap } from '@expo/entity'; import { computeIfAbsent, getDatabaseFieldForEntityField, IntField, mapMap, StringField, transformFieldsToDatabaseObject, } from '@expo/entity'; import invariant from 'invariant'; import { v7 as uuidv7 } from 'uuid'; import type { TableFieldMultiValueEqualityCondition, TableFieldSingleValueEqualityCondition, TableOrderByClause, TableQuerySelectionModifiers, } from '../../BasePostgresEntityDatabaseAdapter.ts'; import { BasePostgresEntityDatabaseAdapter, NullsOrdering, OrderByOrdering, } from '../../BasePostgresEntityDatabaseAdapter.ts'; import type { SQLFragment } from '../../SQLOperator.ts'; export class StubPostgresDatabaseAdapter< TFields extends Record, TIDField extends keyof TFields, > extends BasePostgresEntityDatabaseAdapter { constructor( private readonly entityConfiguration2: EntityConfiguration, private readonly dataStore: Map[]>, ) { super(entityConfiguration2); } public static convertFieldObjectsToDataStore< TFields extends Record, TIDField extends keyof TFields, >( entityConfiguration: EntityConfiguration, dataStore: Map[]>, ): Map[]> { return mapMap(dataStore, (objectsForTable) => objectsForTable.map((objectForTable) => transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable), ), ); } public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] { return computeIfAbsent(this.dataStore, tableName, () => []); } protected getFieldTransformerMap(): FieldTransformerMap { return new Map(); } private static uniqBy(a: T[], keyExtractor: (k: T) => string): T[] { const seen = new Set(); return a.filter((item) => { const k = keyExtractor(item); if (seen.has(k)) { return false; } seen.add(k); return true; }); } protected async fetchManyWhereInternalAsync( _queryInterface: any, tableName: string, tableColumns: readonly string[], tableTuples: (readonly any[])[], ): Promise { const objectCollection = this.getObjectCollectionForTable(tableName); const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) => JSON.stringify(tuple), ).reduce( (acc, tableTuple) => { return acc.concat( objectCollection.filter((obj) => { return tableColumns.every((tableColumn, index) => { return obj[tableColumn] === tableTuple[index]; }); }), ); }, [] as { [key: string]: any }[], ); return [...results]; } protected async fetchOneWhereInternalAsync( queryInterface: any, tableName: string, tableColumns: readonly string[], tableTuple: readonly any[], ): Promise { const results = await this.fetchManyWhereInternalAsync( queryInterface, tableName, tableColumns, [tableTuple], ); return results[0] ?? null; } private static compareByOrderBys>( orderBys: TableOrderByClause[], objectA: { [key: string]: any }, objectB: { [key: string]: any }, ): 0 | 1 | -1 { if (orderBys.length === 0) { return 0; } const currentOrderBy = orderBys[0]!; if (!('columnName' in currentOrderBy)) { throw new Error('SQL fragment order by not supported for StubDatabaseAdapter'); } const aField = objectA[currentOrderBy.columnName]; const bField = objectB[currentOrderBy.columnName]; // Determine effective nulls ordering: // - If explicitly set, use that // - Otherwise use PostgreSQL defaults: NULLS LAST for ASC, NULLS FIRST for DESC const nullsFirst = currentOrderBy.nulls !== undefined ? currentOrderBy.nulls === NullsOrdering.FIRST : currentOrderBy.order === OrderByOrdering.DESCENDING; if (aField === null && bField === null) { return this.compareByOrderBys(orderBys.slice(1), objectA, objectB); } else if (aField === null) { return nullsFirst ? -1 : 1; } else if (bField === null) { return nullsFirst ? 1 : -1; } switch (currentOrderBy.order) { case OrderByOrdering.DESCENDING: { return aField > bField ? -1 : aField < bField ? 1 : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); } case OrderByOrdering.ASCENDING: { return bField > aField ? -1 : bField < aField ? 1 : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); } } } protected async fetchManyByFieldEqualityConjunctionInternalAsync( _queryInterface: any, tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let filteredObjects = this.getObjectCollectionForTable(tableName); for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); } for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); } const orderBy = querySelectionModifiers.orderBy; if (orderBy !== undefined) { filteredObjects = filteredObjects.sort((a, b) => StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b), ); } const offset = querySelectionModifiers.offset; if (offset !== undefined) { filteredObjects = filteredObjects.slice(offset); } const limit = querySelectionModifiers.limit; if (limit !== undefined) { filteredObjects = filteredObjects.slice(0, 0 + limit); } return filteredObjects; } protected fetchManyBySQLFragmentInternalAsync( _queryInterface: any, _tableName: string, _sqlFragment: SQLFragment, _querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { throw new Error('SQL fragments not supported for StubDatabaseAdapter'); } protected async countByFieldEqualityConjunctionInternalAsync( queryInterface: any, tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], ): Promise { const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync( queryInterface, tableName, tableFieldSingleValueEqualityOperands, tableFieldMultiValueEqualityOperands, { orderBy: undefined, offset: undefined, limit: undefined }, ); return results.length; } protected countBySQLFragmentInternalAsync( _queryInterface: any, _tableName: string, _sqlFragment: SQLFragment, ): Promise { throw new Error('SQL fragment count not supported for StubDatabaseAdapter'); } private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( idSchemaField, `No schema field found for ${String(this.entityConfiguration2.idField)}`, ); if (idSchemaField instanceof StringField) { return uuidv7(); } else if (idSchemaField instanceof IntField) { return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); } else { throw new Error( `Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`, ); } } protected async insertInternalAsync( _queryInterface: any, tableName: string, object: object, ): Promise { const objectCollection = this.getObjectCollectionForTable(tableName); const idField = getDatabaseFieldForEntityField( this.entityConfiguration2, this.entityConfiguration2.idField, ); const objectToInsert = { [idField]: this.generateRandomID(), ...object, }; objectCollection.push(objectToInsert); return [objectToInsert]; } protected async updateInternalAsync( _queryInterface: any, tableName: string, tableIdField: string, id: any, object: object, ): Promise<{ updatedRowCount: number }> { // SQL does not support empty updates, mirror behavior here for better test simulation if (Object.keys(object).length === 0) { throw new Error(`Empty update (${tableIdField} = ${id})`); } const objectCollection = this.getObjectCollectionForTable(tableName); const objectIndex = objectCollection.findIndex((obj) => { return obj[tableIdField] === id; }); // SQL updates to a nonexistent row succeed but affect 0 rows, // mirror that behavior here for better test simulation if (objectIndex < 0) { return { updatedRowCount: 0 }; } objectCollection[objectIndex] = { ...objectCollection[objectIndex], ...object, }; return { updatedRowCount: 1 }; } protected async deleteInternalAsync( _queryInterface: any, tableName: string, tableIdField: string, id: any, ): Promise { const objectCollection = this.getObjectCollectionForTable(tableName); const objectIndex = objectCollection.findIndex((obj) => { return obj[tableIdField] === id; }); // SQL deletes to a nonexistent row succeed and affect 0 rows, // mirror that behavior here for better test simulation if (objectIndex < 0) { return 0; } objectCollection.splice(objectIndex, 1); return 1; } }