|
| 1 | +import { |
| 2 | + computeIfAbsent, |
| 3 | + EntityConfiguration, |
| 4 | + FieldTransformerMap, |
| 5 | + getDatabaseFieldForEntityField, |
| 6 | + IntField, |
| 7 | + mapMap, |
| 8 | + StringField, |
| 9 | + transformFieldsToDatabaseObject, |
| 10 | +} from '@expo/entity'; |
| 11 | +import { |
| 12 | + BasePostgresEntityDatabaseAdapter, |
| 13 | + OrderByOrdering, |
| 14 | + TableFieldMultiValueEqualityCondition, |
| 15 | + TableFieldSingleValueEqualityCondition, |
| 16 | + TableQuerySelectionModifiers, |
| 17 | +} from '@expo/entity-database-adapter-knex'; |
| 18 | +import invariant from 'invariant'; |
| 19 | +import { v7 as uuidv7 } from 'uuid'; |
| 20 | + |
| 21 | +export class StubPostgresDatabaseAdapter< |
| 22 | + TFields extends Record<string, any>, |
| 23 | + TIDField extends keyof TFields, |
| 24 | +> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> { |
| 25 | + constructor( |
| 26 | + private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>, |
| 27 | + private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>, |
| 28 | + ) { |
| 29 | + super(entityConfiguration2); |
| 30 | + } |
| 31 | + |
| 32 | + public static convertFieldObjectsToDataStore< |
| 33 | + TFields extends Record<string, any>, |
| 34 | + TIDField extends keyof TFields, |
| 35 | + >( |
| 36 | + entityConfiguration: EntityConfiguration<TFields, TIDField>, |
| 37 | + dataStore: Map<string, Readonly<TFields>[]>, |
| 38 | + ): Map<string, Readonly<{ [key: string]: any }>[]> { |
| 39 | + return mapMap(dataStore, (objectsForTable) => |
| 40 | + objectsForTable.map((objectForTable) => |
| 41 | + transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable), |
| 42 | + ), |
| 43 | + ); |
| 44 | + } |
| 45 | + |
| 46 | + public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] { |
| 47 | + return computeIfAbsent(this.dataStore, tableName, () => []); |
| 48 | + } |
| 49 | + |
| 50 | + protected getFieldTransformerMap(): FieldTransformerMap { |
| 51 | + return new Map(); |
| 52 | + } |
| 53 | + |
| 54 | + private static uniqBy<T>(a: T[], keyExtractor: (k: T) => string): T[] { |
| 55 | + const seen = new Set(); |
| 56 | + return a.filter((item) => { |
| 57 | + const k = keyExtractor(item); |
| 58 | + if (seen.has(k)) { |
| 59 | + return false; |
| 60 | + } |
| 61 | + seen.add(k); |
| 62 | + return true; |
| 63 | + }); |
| 64 | + } |
| 65 | + |
| 66 | + protected async fetchManyWhereInternalAsync( |
| 67 | + _queryInterface: any, |
| 68 | + tableName: string, |
| 69 | + tableColumns: readonly string[], |
| 70 | + tableTuples: (readonly any[])[], |
| 71 | + ): Promise<object[]> { |
| 72 | + const objectCollection = this.getObjectCollectionForTable(tableName); |
| 73 | + const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) => |
| 74 | + tuple.join(':'), |
| 75 | + ).reduce( |
| 76 | + (acc, tableTuple) => { |
| 77 | + return acc.concat( |
| 78 | + objectCollection.filter((obj) => { |
| 79 | + return tableColumns.every((tableColumn, index) => { |
| 80 | + return obj[tableColumn] === tableTuple[index]; |
| 81 | + }); |
| 82 | + }), |
| 83 | + ); |
| 84 | + }, |
| 85 | + [] as { [key: string]: any }[], |
| 86 | + ); |
| 87 | + return [...results]; |
| 88 | + } |
| 89 | + |
| 90 | + private static compareByOrderBys( |
| 91 | + orderBys: { |
| 92 | + columnName: string; |
| 93 | + order: OrderByOrdering; |
| 94 | + }[], |
| 95 | + objectA: { [key: string]: any }, |
| 96 | + objectB: { [key: string]: any }, |
| 97 | + ): 0 | 1 | -1 { |
| 98 | + if (orderBys.length === 0) { |
| 99 | + return 0; |
| 100 | + } |
| 101 | + |
| 102 | + const currentOrderBy = orderBys[0]!; |
| 103 | + const aField = objectA[currentOrderBy.columnName]; |
| 104 | + const bField = objectB[currentOrderBy.columnName]; |
| 105 | + switch (currentOrderBy.order) { |
| 106 | + case OrderByOrdering.DESCENDING: { |
| 107 | + // simulate NULLS FIRST for DESC |
| 108 | + if (aField === null && bField === null) { |
| 109 | + return 0; |
| 110 | + } else if (aField === null) { |
| 111 | + return -1; |
| 112 | + } else if (bField === null) { |
| 113 | + return 1; |
| 114 | + } |
| 115 | + |
| 116 | + return aField > bField |
| 117 | + ? -1 |
| 118 | + : aField < bField |
| 119 | + ? 1 |
| 120 | + : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); |
| 121 | + } |
| 122 | + case OrderByOrdering.ASCENDING: { |
| 123 | + // simulate NULLS LAST for ASC |
| 124 | + if (aField === null && bField === null) { |
| 125 | + return 0; |
| 126 | + } else if (bField === null) { |
| 127 | + return -1; |
| 128 | + } else if (aField === null) { |
| 129 | + return 1; |
| 130 | + } |
| 131 | + |
| 132 | + return bField > aField |
| 133 | + ? -1 |
| 134 | + : bField < aField |
| 135 | + ? 1 |
| 136 | + : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + protected async fetchManyByFieldEqualityConjunctionInternalAsync( |
| 142 | + _queryInterface: any, |
| 143 | + tableName: string, |
| 144 | + tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], |
| 145 | + tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], |
| 146 | + querySelectionModifiers: TableQuerySelectionModifiers, |
| 147 | + ): Promise<object[]> { |
| 148 | + let filteredObjects = this.getObjectCollectionForTable(tableName); |
| 149 | + for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { |
| 150 | + filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue); |
| 151 | + } |
| 152 | + |
| 153 | + for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) { |
| 154 | + filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField])); |
| 155 | + } |
| 156 | + |
| 157 | + const orderBy = querySelectionModifiers.orderBy; |
| 158 | + if (orderBy !== undefined) { |
| 159 | + filteredObjects = filteredObjects.sort((a, b) => |
| 160 | + StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b), |
| 161 | + ); |
| 162 | + } |
| 163 | + |
| 164 | + const offset = querySelectionModifiers.offset; |
| 165 | + if (offset !== undefined) { |
| 166 | + filteredObjects = filteredObjects.slice(offset); |
| 167 | + } |
| 168 | + |
| 169 | + const limit = querySelectionModifiers.limit; |
| 170 | + if (limit !== undefined) { |
| 171 | + filteredObjects = filteredObjects.slice(0, 0 + limit); |
| 172 | + } |
| 173 | + |
| 174 | + return filteredObjects; |
| 175 | + } |
| 176 | + |
| 177 | + protected fetchManyByRawWhereClauseInternalAsync( |
| 178 | + _queryInterface: any, |
| 179 | + _tableName: string, |
| 180 | + _rawWhereClause: string, |
| 181 | + _bindings: object | any[], |
| 182 | + _querySelectionModifiers: TableQuerySelectionModifiers, |
| 183 | + ): Promise<object[]> { |
| 184 | + throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); |
| 185 | + } |
| 186 | + |
| 187 | + private generateRandomID(): any { |
| 188 | + const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); |
| 189 | + invariant( |
| 190 | + idSchemaField, |
| 191 | + `No schema field found for ${String(this.entityConfiguration2.idField)}`, |
| 192 | + ); |
| 193 | + if (idSchemaField instanceof StringField) { |
| 194 | + return uuidv7(); |
| 195 | + } else if (idSchemaField instanceof IntField) { |
| 196 | + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); |
| 197 | + } else { |
| 198 | + throw new Error( |
| 199 | + `Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`, |
| 200 | + ); |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + protected async insertInternalAsync( |
| 205 | + _queryInterface: any, |
| 206 | + tableName: string, |
| 207 | + object: object, |
| 208 | + ): Promise<object[]> { |
| 209 | + const objectCollection = this.getObjectCollectionForTable(tableName); |
| 210 | + |
| 211 | + const idField = getDatabaseFieldForEntityField( |
| 212 | + this.entityConfiguration2, |
| 213 | + this.entityConfiguration2.idField, |
| 214 | + ); |
| 215 | + const objectToInsert = { |
| 216 | + [idField]: this.generateRandomID(), |
| 217 | + ...object, |
| 218 | + }; |
| 219 | + objectCollection.push(objectToInsert); |
| 220 | + return [objectToInsert]; |
| 221 | + } |
| 222 | + |
| 223 | + protected async updateInternalAsync( |
| 224 | + _queryInterface: any, |
| 225 | + tableName: string, |
| 226 | + tableIdField: string, |
| 227 | + id: any, |
| 228 | + object: object, |
| 229 | + ): Promise<object[]> { |
| 230 | + // SQL does not support empty updates, mirror behavior here for better test simulation |
| 231 | + if (Object.keys(object).length === 0) { |
| 232 | + throw new Error(`Empty update (${tableIdField} = ${id})`); |
| 233 | + } |
| 234 | + |
| 235 | + const objectCollection = this.getObjectCollectionForTable(tableName); |
| 236 | + |
| 237 | + const objectIndex = objectCollection.findIndex((obj) => { |
| 238 | + return obj[tableIdField] === id; |
| 239 | + }); |
| 240 | + |
| 241 | + // SQL updates to a nonexistent row succeed but affect 0 rows, |
| 242 | + // mirror that behavior here for better test simulation |
| 243 | + if (objectIndex < 0) { |
| 244 | + return []; |
| 245 | + } |
| 246 | + |
| 247 | + objectCollection[objectIndex] = { |
| 248 | + ...objectCollection[objectIndex], |
| 249 | + ...object, |
| 250 | + }; |
| 251 | + return [objectCollection[objectIndex]]; |
| 252 | + } |
| 253 | + |
| 254 | + protected async deleteInternalAsync( |
| 255 | + _queryInterface: any, |
| 256 | + tableName: string, |
| 257 | + tableIdField: string, |
| 258 | + id: any, |
| 259 | + ): Promise<number> { |
| 260 | + const objectCollection = this.getObjectCollectionForTable(tableName); |
| 261 | + |
| 262 | + const objectIndex = objectCollection.findIndex((obj) => { |
| 263 | + return obj[tableIdField] === id; |
| 264 | + }); |
| 265 | + |
| 266 | + // SQL deletes to a nonexistent row succeed and affect 0 rows, |
| 267 | + // mirror that behavior here for better test simulation |
| 268 | + if (objectIndex < 0) { |
| 269 | + return 0; |
| 270 | + } |
| 271 | + |
| 272 | + objectCollection.splice(objectIndex, 1); |
| 273 | + return 1; |
| 274 | + } |
| 275 | +} |
0 commit comments