Skip to content

Commit abf6995

Browse files
committed
feat: add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter
1 parent 6fd729f commit abf6995

17 files changed

Lines changed: 1355 additions & 3 deletions

.ctirc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
"project": "packages/entity-testing-utils/tsconfig.json",
4444
"output": "packages/entity-testing-utils/src",
4545
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
46+
},
47+
{
48+
"project": "packages/entity-database-adapter-knex-testing-utils/tsconfig.json",
49+
"output": "packages/entity-database-adapter-knex-testing-utils/src",
50+
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
4651
}
4752
]
4853
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @expo/entity-database-adapter-knex-testing-utils
2+
3+
Testing utilities for applications using Entity with Knex database adapter.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@expo/entity-database-adapter-knex-testing-utils",
3+
"version": "0.55.0",
4+
"description": "Testing utilities for applications using Entity with Knex database adapter",
5+
"files": [
6+
"build",
7+
"!*.tsbuildinfo",
8+
"!__*",
9+
"src"
10+
],
11+
"main": "build/src/index.js",
12+
"types": "build/src/index.d.ts",
13+
"scripts": {
14+
"build": "tsc --build",
15+
"prepack": "rm -rf build && yarn build",
16+
"clean": "yarn build --clean",
17+
"lint": "yarn run --top-level eslint src",
18+
"lint-fix": "yarn run lint --fix",
19+
"test": "yarn test:all --rootDir $(pwd)"
20+
},
21+
"engines": {
22+
"node": ">=16"
23+
},
24+
"keywords": [
25+
"entity"
26+
],
27+
"author": "Expo",
28+
"license": "MIT",
29+
"dependencies": {
30+
"@expo/entity": "workspace:^",
31+
"@expo/entity-database-adapter-knex": "workspace:^",
32+
"invariant": "^2.2.4",
33+
"uuid": "^13.0.0"
34+
},
35+
"peerDependencies": {
36+
"@jest/globals": "*"
37+
},
38+
"devDependencies": {
39+
"@jest/globals": "30.2.0",
40+
"@types/invariant": "2.2.37",
41+
"@types/node": "24.10.9",
42+
"@types/uuid": "10.0.0",
43+
"typescript": "5.9.3"
44+
}
45+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {
2+
EntityConfiguration,
3+
EntityDatabaseAdapter,
4+
IEntityDatabaseAdapterProvider,
5+
} from '@expo/entity';
6+
import {
7+
installEntityCompanionExtensions,
8+
installEntityTableDataCoordinatorExtensions,
9+
installReadonlyEntityExtensions,
10+
installViewerScopedEntityCompanionExtensions,
11+
} from '@expo/entity-database-adapter-knex';
12+
13+
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
14+
15+
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
16+
getExtensionsKey(): string {
17+
return 'StubPostgresDatabaseAdapterProvider';
18+
}
19+
20+
installExtensions(): void {
21+
installEntityCompanionExtensions();
22+
installEntityTableDataCoordinatorExtensions();
23+
installViewerScopedEntityCompanionExtensions();
24+
installReadonlyEntityExtensions();
25+
}
26+
27+
private readonly objectCollection = new Map();
28+
29+
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
30+
entityConfiguration: EntityConfiguration<TFields, TIDField>,
31+
): EntityDatabaseAdapter<TFields, TIDField> {
32+
return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection);
33+
}
34+
}

0 commit comments

Comments
 (0)