From fe53e075d46ad56694853a48b5798a033f819378 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Sun, 1 Feb 2026 10:34:39 -0800 Subject: [PATCH] feat: add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter --- .ctirc | 5 + .../README.md | 3 + .../package.json | 45 ++ .../src/StubPostgresDatabaseAdapter.ts | 275 +++++++ .../StubPostgresDatabaseAdapterProvider.ts | 34 + .../src/__testfixtures__/DateIDTestEntity.ts | 62 ++ .../src/__testfixtures__/SimpleTestEntity.ts | 95 +++ .../src/__testfixtures__/TestEntity.ts | 130 ++++ .../__testfixtures__/TestEntityNumberKey.ts | 62 ++ ...tencyWithEntityDatabaseAdapterKnex-test.ts | 33 + .../StubPostgresDatabaseAdapter-test.ts | 726 ++++++++++++++++++ ...estPostgresEntityCompanionProvider-test.ts | 16 + ...UnitTestPostgresEntityCompanionProvider.ts | 42 + .../src/index.ts | 9 + .../tsconfig.json | 8 + .../fixtures/StubPostgresDatabaseAdapter.ts | 2 +- tsconfig.json | 3 +- yarn.lock | 27 +- 18 files changed, 1574 insertions(+), 3 deletions(-) create mode 100644 packages/entity-database-adapter-knex-testing-utils/README.md create mode 100644 packages/entity-database-adapter-knex-testing-utils/package.json create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapterProvider.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/DateIDTestEntity.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/SimpleTestEntity.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntity.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntityNumberKey.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__tests__/FileConsistencyWithEntityDatabaseAdapterKnex-test.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/__tests__/createUnitTestPostgresEntityCompanionProvider-test.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/createUnitTestPostgresEntityCompanionProvider.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/src/index.ts create mode 100644 packages/entity-database-adapter-knex-testing-utils/tsconfig.json diff --git a/.ctirc b/.ctirc index b0acb45f9..6992ac8ac 100644 --- a/.ctirc +++ b/.ctirc @@ -43,6 +43,11 @@ "project": "packages/entity-testing-utils/tsconfig.json", "output": "packages/entity-testing-utils/src", "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] + }, + { + "project": "packages/entity-database-adapter-knex-testing-utils/tsconfig.json", + "output": "packages/entity-database-adapter-knex-testing-utils/src", + "exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ] } ] } diff --git a/packages/entity-database-adapter-knex-testing-utils/README.md b/packages/entity-database-adapter-knex-testing-utils/README.md new file mode 100644 index 000000000..7585964a0 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/README.md @@ -0,0 +1,3 @@ +# @expo/entity-database-adapter-knex-testing-utils + +Testing utilities for applications using Entity with Knex database adapter. \ No newline at end of file diff --git a/packages/entity-database-adapter-knex-testing-utils/package.json b/packages/entity-database-adapter-knex-testing-utils/package.json new file mode 100644 index 000000000..f0c0cbffc --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/package.json @@ -0,0 +1,45 @@ +{ + "name": "@expo/entity-database-adapter-knex-testing-utils", + "version": "0.55.0", + "description": "Testing utilities for applications using Entity with Knex database adapter", + "files": [ + "build", + "!*.tsbuildinfo", + "!__*", + "src" + ], + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "scripts": { + "build": "tsc --build", + "prepack": "rm -rf build && yarn build", + "clean": "yarn build --clean", + "lint": "yarn run --top-level eslint src", + "lint-fix": "yarn run lint --fix", + "test": "yarn test:all --rootDir $(pwd)" + }, + "engines": { + "node": ">=16" + }, + "keywords": [ + "entity" + ], + "author": "Expo", + "license": "MIT", + "dependencies": { + "@expo/entity": "workspace:^", + "@expo/entity-database-adapter-knex": "workspace:^", + "invariant": "^2.2.4", + "uuid": "^13.0.0" + }, + "peerDependencies": { + "@jest/globals": "*" + }, + "devDependencies": { + "@jest/globals": "30.2.0", + "@types/invariant": "2.2.37", + "@types/node": "24.10.9", + "@types/uuid": "10.0.0", + "typescript": "5.9.3" + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts new file mode 100644 index 000000000..44c6b5f04 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -0,0 +1,275 @@ +import { + computeIfAbsent, + EntityConfiguration, + FieldTransformerMap, + getDatabaseFieldForEntityField, + IntField, + mapMap, + StringField, + transformFieldsToDatabaseObject, +} from '@expo/entity'; +import { + BasePostgresEntityDatabaseAdapter, + OrderByOrdering, + TableFieldMultiValueEqualityCondition, + TableFieldSingleValueEqualityCondition, + TableQuerySelectionModifiers, +} from '@expo/entity-database-adapter-knex'; +import invariant from 'invariant'; +import { v7 as uuidv7 } from 'uuid'; + +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) => + tuple.join(':'), + ).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]; + } + + private static compareByOrderBys( + orderBys: { + columnName: string; + order: OrderByOrdering; + }[], + objectA: { [key: string]: any }, + objectB: { [key: string]: any }, + ): 0 | 1 | -1 { + if (orderBys.length === 0) { + return 0; + } + + const currentOrderBy = orderBys[0]!; + const aField = objectA[currentOrderBy.columnName]; + const bField = objectB[currentOrderBy.columnName]; + switch (currentOrderBy.order) { + case OrderByOrdering.DESCENDING: { + // simulate NULLS FIRST for DESC + if (aField === null && bField === null) { + return 0; + } else if (aField === null) { + return -1; + } else if (bField === null) { + return 1; + } + + return aField > bField + ? -1 + : aField < bField + ? 1 + : this.compareByOrderBys(orderBys.slice(1), objectA, objectB); + } + case OrderByOrdering.ASCENDING: { + // simulate NULLS LAST for ASC + if (aField === null && bField === null) { + return 0; + } else if (bField === null) { + return -1; + } else if (aField === null) { + return 1; + } + + 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 fetchManyByRawWhereClauseInternalAsync( + _queryInterface: any, + _tableName: string, + _rawWhereClause: string, + _bindings: object | any[], + _querySelectionModifiers: TableQuerySelectionModifiers, + ): Promise { + throw new Error('Raw WHERE clauses 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 { + // 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 []; + } + + objectCollection[objectIndex] = { + ...objectCollection[objectIndex], + ...object, + }; + return [objectCollection[objectIndex]]; + } + + 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; + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapterProvider.ts b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapterProvider.ts new file mode 100644 index 000000000..f7f71d5c4 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapterProvider.ts @@ -0,0 +1,34 @@ +import { + EntityConfiguration, + EntityDatabaseAdapter, + IEntityDatabaseAdapterProvider, +} from '@expo/entity'; +import { + installEntityCompanionExtensions, + installEntityTableDataCoordinatorExtensions, + installReadonlyEntityExtensions, + installViewerScopedEntityCompanionExtensions, +} from '@expo/entity-database-adapter-knex'; + +import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter'; + +export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + getExtensionsKey(): string { + return 'StubPostgresDatabaseAdapterProvider'; + } + + installExtensions(): void { + installEntityCompanionExtensions(); + installEntityTableDataCoordinatorExtensions(); + installViewerScopedEntityCompanionExtensions(); + installReadonlyEntityExtensions(); + } + + private readonly objectCollection = new Map(); + + getDatabaseAdapter, TIDField extends keyof TFields>( + entityConfiguration: EntityConfiguration, + ): EntityDatabaseAdapter { + return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection); + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/DateIDTestEntity.ts b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/DateIDTestEntity.ts new file mode 100644 index 000000000..657bfd59c --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/DateIDTestEntity.ts @@ -0,0 +1,62 @@ +import { + AlwaysAllowPrivacyPolicyRule, + DateField, + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + ViewerContext, +} from '@expo/entity'; + +export type DateIDTestFields = { + id: Date; +}; + +export const dateIDTestEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'simple_test_entity_should_not_write_to_db', + schema: { + id: new DateField({ + columnName: 'custom_id', + cache: true, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +export class DateIDTestEntityPrivacyPolicy extends EntityPrivacyPolicy< + DateIDTestFields, + 'id', + ViewerContext, + DateIDTestEntity +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export class DateIDTestEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + DateIDTestFields, + 'id', + ViewerContext, + DateIDTestEntity, + DateIDTestEntityPrivacyPolicy + > { + return { + entityClass: DateIDTestEntity, + entityConfiguration: dateIDTestEntityConfiguration, + privacyPolicyClass: DateIDTestEntityPrivacyPolicy, + }; + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/SimpleTestEntity.ts b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/SimpleTestEntity.ts new file mode 100644 index 000000000..66f515a13 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/SimpleTestEntity.ts @@ -0,0 +1,95 @@ +import { + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + UUIDField, + ViewerContext, +} from '@expo/entity'; + +export type SimpleTestFields = { + id: string; +}; + +export type SimpleTestFieldSelection = keyof SimpleTestFields; + +export const simpleTestEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'simple_test_entity_should_not_write_to_db', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + cache: true, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestFieldSelection +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestFieldSelection + >(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestFieldSelection + >(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestFieldSelection + >(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestFieldSelection + >(), + ]; +} + +export class SimpleTestEntity extends Entity< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestFieldSelection +> { + static defineCompanionDefinition(): EntityCompanionDefinition< + SimpleTestFields, + 'id', + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + SimpleTestFieldSelection + > { + return { + entityClass: SimpleTestEntity, + entityConfiguration: simpleTestEntityConfiguration, + privacyPolicyClass: SimpleTestEntityPrivacyPolicy, + }; + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntity.ts b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntity.ts new file mode 100644 index 000000000..18182346b --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntity.ts @@ -0,0 +1,130 @@ +import { + AlwaysAllowPrivacyPolicyRule, + DateField, + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + IntField, + StringField, + UUIDField, + ViewerContext, +} from '@expo/entity'; +import { result, Result } from '@expo/results'; + +export type TestFields = { + customIdField: string; + testIndexedField: string; + stringField: string; + intField: number; + dateField: Date; + nullableField: string | null; +}; + +export const testEntityConfiguration = new EntityConfiguration({ + idField: 'customIdField', + tableName: 'test_entity_should_not_write_to_db', + schema: { + customIdField: new UUIDField({ + columnName: 'custom_id', + cache: true, + }), + testIndexedField: new StringField({ + columnName: 'test_index', + cache: true, + }), + stringField: new StringField({ + columnName: 'string_field', + }), + intField: new IntField({ + columnName: 'number_field', + }), + dateField: new DateField({ + columnName: 'date_field', + }), + nullableField: new StringField({ + columnName: 'nullable_field', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + compositeFieldDefinitions: [ + { compositeField: ['stringField', 'intField'], cache: false }, + { compositeField: ['stringField', 'testIndexedField'], cache: true }, + { compositeField: ['nullableField', 'testIndexedField'], cache: true }, + ], +}); + +export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy< + TestFields, + 'customIdField', + ViewerContext, + TestEntity +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export class TestEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: TestEntity, + entityConfiguration: testEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } + + getBlah(): string { + return 'Hello World!'; + } + + static async helloAsync( + viewerContext: ViewerContext, + testValue: string, + ): Promise> { + const fields = { + customIdField: testValue, + testIndexedField: 'hello', + stringField: 'hello', + intField: 1, + dateField: new Date(), + nullableField: null, + }; + return result( + new TestEntity({ + viewerContext, + id: testValue, + databaseFields: fields, + selectedFields: fields, + }), + ); + } + + static async returnErrorAsync(_viewerContext: ViewerContext): Promise> { + return result(new Error('return entity')); + } + + static async throwErrorAsync(_viewerContext: ViewerContext): Promise> { + throw new Error('threw entity'); + } + + static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise { + return testValue; + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntityNumberKey.ts b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntityNumberKey.ts new file mode 100644 index 000000000..3506c7e28 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__testfixtures__/TestEntityNumberKey.ts @@ -0,0 +1,62 @@ +import { + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + IntField, + ViewerContext, +} from '@expo/entity'; + +export type NumberKeyFields = { + id: number; +}; + +export const numberKeyEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'simple_test_entity_should_not_write_to_db', + schema: { + id: new IntField({ + columnName: 'custom_id', + cache: false, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +export class NumberKeyPrivacyPolicy extends EntityPrivacyPolicy< + NumberKeyFields, + 'id', + ViewerContext, + NumberKeyEntity +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +export class NumberKeyEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + NumberKeyFields, + 'id', + ViewerContext, + NumberKeyEntity, + NumberKeyPrivacyPolicy + > { + return { + entityClass: NumberKeyEntity, + entityConfiguration: numberKeyEntityConfiguration, + privacyPolicyClass: NumberKeyPrivacyPolicy, + }; + } +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/FileConsistencyWithEntityDatabaseAdapterKnex-test.ts b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/FileConsistencyWithEntityDatabaseAdapterKnex-test.ts new file mode 100644 index 000000000..729406618 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/FileConsistencyWithEntityDatabaseAdapterKnex-test.ts @@ -0,0 +1,33 @@ +import { expect, it } from '@jest/globals'; +import { readFile } from 'fs/promises'; +import path from 'path'; + +it.each([ + 'StubPostgresDatabaseAdapter', + 'StubPostgresDatabaseAdapterProvider', + 'createUnitTestPostgresEntityCompanionProvider', +])('%s is the same as in @expo/entity-database-adapter-knex', async (fileName) => { + // These stub adapters need to be shared for testing, but we can't have them in the main + // entity-database-adapter-knex package since they would be exposed in production. + // Therefore, we duplicate them and ensure they stay in sync. + + const fileContentsFromEntityDatabaseAdapterKnex = await readFile( + path.resolve( + __dirname, + `../../../entity-database-adapter-knex/src/__tests__/fixtures/${fileName}.ts`, + ), + 'utf-8', + ); + const fileContentsFromTestingUtils = await readFile( + path.resolve(__dirname, `../${fileName}.ts`), + 'utf-8', + ); + + const trimmedFiles = [ + fileContentsFromEntityDatabaseAdapterKnex, + fileContentsFromTestingUtils, + ].map((file) => file.substring(file.indexOf('export'))); + + expect(trimmedFiles[0]?.length).toBeGreaterThan(0); + expect(trimmedFiles[0]).toEqual(trimmedFiles[1]); +}); diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts new file mode 100644 index 000000000..54d49b562 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts @@ -0,0 +1,726 @@ +import { + CompositeFieldHolder, + CompositeFieldValueHolder, + EntityQueryContext, + SingleFieldHolder, + SingleFieldValueHolder, +} from '@expo/entity'; +import { OrderByOrdering } from '@expo/entity-database-adapter-knex'; +import { describe, expect, it, jest } from '@jest/globals'; +import { instance, mock } from 'ts-mockito'; +import { validate, version } from 'uuid'; + +import { StubPostgresDatabaseAdapter } from '../StubPostgresDatabaseAdapter'; +import { + DateIDTestFields, + dateIDTestEntityConfiguration, +} from '../__testfixtures__/DateIDTestEntity'; +import { + SimpleTestFields, + simpleTestEntityConfiguration, +} from '../__testfixtures__/SimpleTestEntity'; +import { TestFields, testEntityConfiguration } from '../__testfixtures__/TestEntity'; +import { + NumberKeyFields, + numberKeyEntityConfiguration, +} from '../__testfixtures__/TestEntityNumberKey'; + +// uuid keeps state internally for v7 generation, so we fix the time for all tests for consistent test results +const expectedTime = new Date('2024-06-03T20:16:33.761Z'); +jest.useFakeTimers({ + now: expectedTime, +}); + +describe(StubPostgresDatabaseAdapter, () => { + describe('fetchManyWhereAsync', () => { + it('fetches many where single', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'wat', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const results = await databaseAdapter.fetchManyWhereAsync( + queryContext, + new SingleFieldHolder('stringField'), + [new SingleFieldValueHolder('huh')], + ); + expect(results.get(new SingleFieldValueHolder('huh'))).toHaveLength(1); + }); + + it('fetches many where composite', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 5, + stringField: 'huh', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'wat', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const results = await databaseAdapter.fetchManyWhereAsync( + queryContext, + new CompositeFieldHolder(['stringField', 'intField']), + [new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })], + ); + expect( + results.get(new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })), + ).toHaveLength(1); + + const results2 = await databaseAdapter.fetchManyWhereAsync( + queryContext, + new CompositeFieldHolder(['stringField', 'intField']), + [new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })], + ); + expect( + results2.get(new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })), + ).toHaveLength(0); + }); + + it('handles duplicate tuples and filters them out', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'id1', + testIndexedField: 'h1', + intField: 5, + stringField: 'test1', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'id2', + testIndexedField: 'h2', + intField: 10, + stringField: 'test2', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + // Fetch with duplicate field values - this will cause uniqBy to filter duplicates + const results = await databaseAdapter.fetchManyWhereAsync( + queryContext, + new SingleFieldHolder('customIdField'), + [ + new SingleFieldValueHolder('id1'), + new SingleFieldValueHolder('id2'), + new SingleFieldValueHolder('id1'), + new SingleFieldValueHolder('id2'), + ], + ); + + // Should only get 2 unique results despite passing 4 values (2 duplicates) + expect(results.get(new SingleFieldValueHolder('id1'))).toHaveLength(1); + expect(results.get(new SingleFieldValueHolder('id2'))).toHaveLength(1); + + // Verify the actual objects returned + const id1Results = results.get(new SingleFieldValueHolder('id1')); + expect(id1Results?.[0]).toMatchObject({ customIdField: 'id1', stringField: 'test1' }); + + const id2Results = results.get(new SingleFieldValueHolder('id2')); + expect(id2Results?.[0]).toMatchObject({ customIdField: 'id2', stringField: 'test2' }); + }); + }); + + describe('fetchManyByFieldEqualityConjunctionAsync', () => { + it('supports conjuntions and query modifiers', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'b', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'c', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + [ + { + fieldName: 'customIdField', + fieldValues: ['hello', 'world'], + }, + { + fieldName: 'intField', + fieldValue: 3, + }, + ], + { + limit: 2, + offset: 1, + orderBy: [ + { + fieldName: 'stringField', + order: OrderByOrdering.DESCENDING, + }, + ], + }, + ); + + expect(results).toHaveLength(2); + expect(results.map((e) => e.stringField)).toEqual(['b', 'a']); + }); + + it('supports multiple order bys', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'b', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: 'world', + testIndexedField: 'h2', + intField: 3, + stringField: 'c', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + [ + { + fieldName: 'intField', + fieldValue: 3, + }, + ], + { + orderBy: [ + { + fieldName: 'intField', + order: OrderByOrdering.DESCENDING, + }, + { + fieldName: 'stringField', + order: OrderByOrdering.DESCENDING, + }, + ], + }, + ); + + expect(results).toHaveLength(3); + expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']); + }); + + it('supports null field values', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: '1', + testIndexedField: 'h1', + intField: 1, + stringField: 'a', + dateField: new Date(), + nullableField: 'a', + }, + { + customIdField: '2', + testIndexedField: 'h2', + intField: 2, + stringField: 'a', + dateField: new Date(), + nullableField: 'b', + }, + { + customIdField: '3', + testIndexedField: 'h3', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: '4', + testIndexedField: 'h4', + intField: 4, + stringField: 'b', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + [{ fieldName: 'nullableField', fieldValue: null }], + {}, + ); + expect(results).toHaveLength(2); + expect(results[0]!.nullableField).toBeNull(); + + const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync( + queryContext, + [ + { fieldName: 'nullableField', fieldValues: ['a', null] }, + { fieldName: 'stringField', fieldValue: 'a' }, + ], + { + orderBy: [ + { + fieldName: 'nullableField', + order: OrderByOrdering.DESCENDING, + }, + ], + }, + ); + expect(results2).toHaveLength(2); + expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']); + }); + }); + + describe('fetchManyByRawWhereClauseAsync', () => { + it('throws because it is unsupported', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + await expect( + databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}), + ).rejects.toThrow(); + }); + }); + + describe('insertAsync', () => { + it('inserts a record', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + const result = await databaseAdapter.insertAsync(queryContext, { + stringField: 'hello', + }); + expect(result).toMatchObject({ + stringField: 'hello', + }); + + expect( + databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName), + ).toHaveLength(1); + }); + + it('inserts a record with valid v7 id', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + const result = await databaseAdapter.insertAsync(queryContext, { + stringField: 'hello', + }); + + const ts = getTimeFromUUIDv7(result.customIdField); + expect(ts).toEqual(expectedTime); + }); + }); + + describe('updateAsync', () => { + it('updates a record', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + const result = await databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', { + stringField: 'b', + }); + expect(result).toMatchObject({ + stringField: 'b', + testIndexedField: 'h1', + }); + }); + + it('throws error when empty update to match common DBMS behavior', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + await expect( + databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {}), + ).rejects.toThrow(`Empty update (custom_id = hello)`); + }); + + it('throws error when updating nonexistent record', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'existing-id', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + // Try to update a record that doesn't exist + await expect( + databaseAdapter.updateAsync( + queryContext, + 'customIdField', + 'nonexistent-id', // This ID doesn't exist in the data store + { stringField: 'updated' }, + ), + ).rejects.toThrow('Empty results from database adapter update'); + }); + }); + + describe('deleteAsync', () => { + it('deletes an object', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'hello', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + await databaseAdapter.deleteAsync(queryContext, 'customIdField', 'hello'); + + expect( + databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName), + ).toHaveLength(0); + }); + + it('handles deletion of nonexistent record gracefully', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: 'existing-id', + testIndexedField: 'h1', + intField: 3, + stringField: 'a', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + + // Delete a record that doesn't exist + // Unlike update, delete doesn't throw an error when the record doesn't exist + await databaseAdapter.deleteAsync( + queryContext, + 'customIdField', + 'nonexistent-id', // This ID doesn't exist in the data store + ); + }); + }); + + it('supports string and number IDs', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter1 = new StubPostgresDatabaseAdapter( + simpleTestEntityConfiguration, + new Map(), + ); + const insertedObject1 = await databaseAdapter1.insertAsync(queryContext, {}); + expect(typeof insertedObject1.id).toBe('string'); + + const databaseAdapter2 = new StubPostgresDatabaseAdapter( + numberKeyEntityConfiguration, + new Map(), + ); + const insertedObject2 = await databaseAdapter2.insertAsync(queryContext, {}); + expect(typeof insertedObject2.id).toBe('number'); + + const databaseAdapter3 = new StubPostgresDatabaseAdapter( + dateIDTestEntityConfiguration, + new Map(), + ); + await expect(databaseAdapter3.insertAsync(queryContext, {})).rejects.toThrow( + 'Unsupported ID type for StubPostgresDatabaseAdapter: DateField', + ); + }); +}); + +describe('compareByOrderBys', () => { + describe('comparison', () => { + it.each([ + // nulls compare with 0 + [OrderByOrdering.DESCENDING, null, 0, -1], + [OrderByOrdering.ASCENDING, null, 0, 1], + [OrderByOrdering.DESCENDING, 0, null, 1], + [OrderByOrdering.ASCENDING, 0, null, -1], + // nulls compare with nulls + [OrderByOrdering.DESCENDING, null, null, 0], + [OrderByOrdering.ASCENDING, null, null, 0], + // nulls compare with -1 + [OrderByOrdering.DESCENDING, null, -1, -1], + [OrderByOrdering.ASCENDING, null, -1, 1], + [OrderByOrdering.DESCENDING, -1, null, 1], + [OrderByOrdering.ASCENDING, -1, null, -1], + // basic compares + [OrderByOrdering.ASCENDING, 'a', 'b', -1], + [OrderByOrdering.ASCENDING, 'b', 'a', 1], + [OrderByOrdering.DESCENDING, 'a', 'b', 1], + [OrderByOrdering.DESCENDING, 'b', 'a', -1], + ])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => { + expect( + StubPostgresDatabaseAdapter['compareByOrderBys']( + [ + { + columnName: 'hello', + order, + }, + ], + { + hello: v1, + }, + { + hello: v2, + }, + ), + ).toEqual(expectedResult); + }); + it('works for empty', () => { + expect( + StubPostgresDatabaseAdapter['compareByOrderBys']( + [], + { + hello: 'test', + }, + { + hello: 'blah', + }, + ), + ).toEqual(0); + }); + }); + describe('recursing', () => { + expect( + StubPostgresDatabaseAdapter['compareByOrderBys']( + [ + { + columnName: 'hello', + order: OrderByOrdering.ASCENDING, + }, + { + columnName: 'world', + order: OrderByOrdering.ASCENDING, + }, + ], + { + hello: 'a', + world: 1, + }, + { + hello: 'a', + world: 2, + }, + ), + ).toEqual(-1); + }); +}); + +/** + * Returns the Date object encoded in the first 48 bits of the given UUIDv7. + * @throws TypeError if the UUID is not version 7 + */ +function getTimeFromUUIDv7(uuid: string): Date { + if (!(validate(uuid) && version(uuid) === 7)) { + throw new TypeError(`Invalid UUID: ${uuid}`); + } + + // The first 48 bits = 12 hex characters of the UUID encode the timestamp in big endian + const hexCharacters = uuid.replaceAll('-', '').split('', 12); + const milliseconds = hexCharacters.reduce( + (milliseconds, character) => milliseconds * 16 + parseInt(character, 16), + 0, + ); + return new Date(milliseconds); +} diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/createUnitTestPostgresEntityCompanionProvider-test.ts b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/createUnitTestPostgresEntityCompanionProvider-test.ts new file mode 100644 index 000000000..2e78173a8 --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/createUnitTestPostgresEntityCompanionProvider-test.ts @@ -0,0 +1,16 @@ +import { EntityCompanionProvider, ViewerContext } from '@expo/entity'; +import { describe, expect, it } from '@jest/globals'; + +import { TestEntity } from '../__testfixtures__/TestEntity'; +import { createUnitTestPostgresEntityCompanionProvider } from '../createUnitTestPostgresEntityCompanionProvider'; + +describe(createUnitTestPostgresEntityCompanionProvider, () => { + it('creates a new EntityCompanionProvider', async () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + expect(companionProvider).toBeInstanceOf(EntityCompanionProvider); + const viewerContext = new ViewerContext(companionProvider); + await expect(TestEntity.creator(viewerContext).createAsync()).resolves.toBeInstanceOf( + TestEntity, + ); + }); +}); diff --git a/packages/entity-database-adapter-knex-testing-utils/src/createUnitTestPostgresEntityCompanionProvider.ts b/packages/entity-database-adapter-knex-testing-utils/src/createUnitTestPostgresEntityCompanionProvider.ts new file mode 100644 index 000000000..98a672cad --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/createUnitTestPostgresEntityCompanionProvider.ts @@ -0,0 +1,42 @@ +import { + EntityCompanionProvider, + IEntityMetricsAdapter, + NoOpEntityMetricsAdapter, +} from '@expo/entity'; +import { + InMemoryFullCacheStubCacheAdapterProvider, + StubQueryContextProvider, +} from '@expo/entity-testing-utils'; + +import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider'; + +const queryContextProvider = new StubQueryContextProvider(); + +/** + * Entity companion provider for use in knex unit tests. All database and cache implementations + * are replaced with in-memory simulations. + */ +export const createUnitTestPostgresEntityCompanionProvider = ( + metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(), +): EntityCompanionProvider => { + return new EntityCompanionProvider( + metricsAdapter, + new Map([ + [ + 'postgres', + { + adapterProvider: new StubPostgresDatabaseAdapterProvider(), + queryContextProvider, + }, + ], + ]), + new Map([ + [ + 'redis', + { + cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(), + }, + ], + ]), + ); +}; diff --git a/packages/entity-database-adapter-knex-testing-utils/src/index.ts b/packages/entity-database-adapter-knex-testing-utils/src/index.ts new file mode 100644 index 000000000..942b69d2e --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/src/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @packageDocumentation + * @module @expo/entity-database-adapter-knex-testing-utils + */ + +export * from './createUnitTestPostgresEntityCompanionProvider'; +export * from './StubPostgresDatabaseAdapter'; +export * from './StubPostgresDatabaseAdapterProvider'; diff --git a/packages/entity-database-adapter-knex-testing-utils/tsconfig.json b/packages/entity-database-adapter-knex-testing-utils/tsconfig.json new file mode 100644 index 000000000..a3aa2f96c --- /dev/null +++ b/packages/entity-database-adapter-knex-testing-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "references": [ + { "path": "../entity" }, + { "path": "../entity-database-adapter-knex" } + ] +} \ No newline at end of file diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts index 3ad566fee..566125b51 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -197,7 +197,7 @@ export class StubPostgresDatabaseAdapter< return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); } else { throw new Error( - `Unsupported ID type for StubDatabaseAdapter: ${idSchemaField.constructor.name}`, + `Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`, ); } } diff --git a/tsconfig.json b/tsconfig.json index 0aa807023..31e44fb40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ { "path": "./packages/entity-ip-address-field" }, { "path": "./packages/entity-secondary-cache-local-memory" }, { "path": "./packages/entity-secondary-cache-redis" }, - { "path": "./packages/entity-testing-utils" } + { "path": "./packages/entity-testing-utils" }, + { "path": "./packages/entity-database-adapter-knex-testing-utils" } ] } diff --git a/yarn.lock b/yarn.lock index 975c23157..dc61d0ba6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2832,6 +2832,24 @@ __metadata: languageName: unknown linkType: soft +"@expo/entity-database-adapter-knex-testing-utils@workspace:packages/entity-database-adapter-knex-testing-utils": + version: 0.0.0-use.local + resolution: "@expo/entity-database-adapter-knex-testing-utils@workspace:packages/entity-database-adapter-knex-testing-utils" + dependencies: + "@expo/entity": "workspace:^" + "@expo/entity-database-adapter-knex": "workspace:^" + "@jest/globals": "npm:30.2.0" + "@types/invariant": "npm:2.2.37" + "@types/node": "npm:24.10.9" + "@types/uuid": "npm:10.0.0" + invariant: "npm:^2.2.4" + typescript: "npm:5.9.3" + uuid: "npm:^13.0.0" + peerDependencies: + "@jest/globals": "*" + languageName: unknown + linkType: soft + "@expo/entity-database-adapter-knex@workspace:^, @expo/entity-database-adapter-knex@workspace:packages/entity-database-adapter-knex": version: 0.0.0-use.local resolution: "@expo/entity-database-adapter-knex@workspace:packages/entity-database-adapter-knex" @@ -5210,6 +5228,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: 10c0/9a1404bf287164481cb9b97f6bb638f78f955be57c40c6513b7655160beb29df6f84c915aaf4089a1559c216557dc4d2f79b48d978742d3ae10b937420ddac60 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -15594,7 +15619,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:13.0.0": +"uuid@npm:13.0.0, uuid@npm:^13.0.0": version: 13.0.0 resolution: "uuid@npm:13.0.0" bin: