diff --git a/packages/entity-database-adapter-knex/src/PostgresEntity.ts b/packages/entity-database-adapter-knex/src/PostgresEntity.ts new file mode 100644 index 000000000..37a1f66ef --- /dev/null +++ b/packages/entity-database-adapter-knex/src/PostgresEntity.ts @@ -0,0 +1,116 @@ +import { + Entity, + EntityPrivacyPolicy, + EntityQueryContext, + IEntityClass, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; + +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; +import { + knexLoader as knexLoaderFn, + knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn, +} from './knexLoader'; + +/** + * Abstract base class for mutable entities backed by Postgres. + * Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods, + * in addition to the mutation methods inherited from `Entity`. + */ +export abstract class PostgresEntity< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TSelectedFields extends keyof TFields = keyof TFields, +> extends Entity { + /** + * Vend knex loader for loading entities via knex-specific methods in a given query context. + * + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoader< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return knexLoaderFn(this, viewerContext, queryContext); + } + + /** + * Vend knex loader for loading entities via knex-specific methods in a given query context. + * Returns authorization results instead of throwing on authorization errors. + * + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoaderWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext); + } +} diff --git a/packages/entity-database-adapter-knex/src/ReadonlyPostgresEntity.ts b/packages/entity-database-adapter-knex/src/ReadonlyPostgresEntity.ts new file mode 100644 index 000000000..07d86beb8 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/ReadonlyPostgresEntity.ts @@ -0,0 +1,115 @@ +import { + EntityPrivacyPolicy, + EntityQueryContext, + IEntityClass, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; + +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; +import { + knexLoader as knexLoaderFn, + knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn, +} from './knexLoader'; + +/** + * Abstract base class for readonly entities backed by Postgres. + * Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods. + * + * Entities that should not be mutated (e.g., representing SQL views or immutable tables) + * can extend this class instead of `ReadonlyEntity` to get knex loader ergonomics. + */ +export abstract class ReadonlyPostgresEntity< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TSelectedFields extends keyof TFields = keyof TFields, +> extends ReadonlyEntity { + /** + * Vend knex loader for loading entities via non-data-loader methods in a given query context. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoader< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): EnforcingKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return knexLoaderFn(this, viewerContext, queryContext); + } + + /** + * Vend knex loader for loading entities via non-data-loader methods in a given query context. + * Returns authorization results instead of throwing on authorization errors. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + static knexLoaderWithAuthorizationResults< + TMFields extends object, + TMIDField extends keyof NonNullable>, + TMViewerContext extends ViewerContext, + TMEntity extends ReadonlyEntity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields, + >( + this: IEntityClass< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + viewerContext: TMViewerContext, + queryContext: EntityQueryContext = viewerContext + .getViewerScopedEntityCompanionForClass(this) + .getQueryContextProvider() + .getQueryContext(), + ): AuthorizationResultBasedKnexEntityLoader< + TMFields, + TMIDField, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + > { + return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext); + } +} diff --git a/packages/entity-database-adapter-knex/src/__tests__/PostgresEntity-test.ts b/packages/entity-database-adapter-knex/src/__tests__/PostgresEntity-test.ts new file mode 100644 index 000000000..984ed2422 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/PostgresEntity-test.ts @@ -0,0 +1,172 @@ +import { + AlwaysAllowPrivacyPolicyRule, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + StringField, + UUIDField, + ViewerContext, +} from '@expo/entity'; +import { describe, expect, it } from '@jest/globals'; + +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; +import { PostgresEntity } from '../PostgresEntity'; +import { ReadonlyPostgresEntity } from '../ReadonlyPostgresEntity'; +import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider'; + +type TestPostgresFields = { + id: string; + name: string; +}; + +const testPostgresEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'postgres_entity_test', + schema: { + id: new UUIDField({ columnName: 'id', cache: true }), + name: new StringField({ columnName: 'name' }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +class TestPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy< + TestPostgresFields, + 'id', + ViewerContext, + TestPostgresEntity +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +class TestPostgresEntity extends PostgresEntity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestPostgresFields, + 'id', + ViewerContext, + TestPostgresEntity, + TestPostgresEntityPrivacyPolicy + > { + return { + entityClass: TestPostgresEntity, + entityConfiguration: testPostgresEntityConfiguration, + privacyPolicyClass: TestPostgresEntityPrivacyPolicy, + }; + } +} + +class TestReadonlyPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity + >(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity + >(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity + >(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity + >(), + ]; +} + +class TestReadonlyPostgresEntity extends ReadonlyPostgresEntity< + TestPostgresFields, + 'id', + ViewerContext +> { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestPostgresFields, + 'id', + ViewerContext, + TestReadonlyPostgresEntity, + TestReadonlyPostgresEntityPrivacyPolicy + > { + return { + entityClass: TestReadonlyPostgresEntity, + entityConfiguration: testPostgresEntityConfiguration, + privacyPolicyClass: TestReadonlyPostgresEntityPrivacyPolicy, + }; + } +} + +describe(PostgresEntity, () => { + describe('knexLoader', () => { + it('creates a new EnforcingKnexEntityLoader', () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(TestPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf( + EnforcingKnexEntityLoader, + ); + }); + }); + + describe('knexLoaderWithAuthorizationResults', () => { + it('creates a new AuthorizationResultBasedKnexEntityLoader', () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(TestPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf( + AuthorizationResultBasedKnexEntityLoader, + ); + }); + }); +}); + +describe(ReadonlyPostgresEntity, () => { + describe('knexLoader', () => { + it('creates a new EnforcingKnexEntityLoader', () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(TestReadonlyPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf( + EnforcingKnexEntityLoader, + ); + }); + }); + + describe('knexLoaderWithAuthorizationResults', () => { + it('creates a new AuthorizationResultBasedKnexEntityLoader', () => { + const companionProvider = createUnitTestPostgresEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect( + TestReadonlyPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext), + ).toBeInstanceOf(AuthorizationResultBasedKnexEntityLoader); + }); + }); +}); diff --git a/packages/entity-database-adapter-knex/src/index.ts b/packages/entity-database-adapter-knex/src/index.ts index 4693e1101..aa99bd9cd 100644 --- a/packages/entity-database-adapter-knex/src/index.ts +++ b/packages/entity-database-adapter-knex/src/index.ts @@ -12,9 +12,11 @@ export * from './EntityFields'; export * from './KnexEntityLoaderFactory'; export * from './knexLoader'; export * from './PaginationStrategy'; +export * from './PostgresEntity'; export * from './PostgresEntityDatabaseAdapter'; export * from './PostgresEntityDatabaseAdapterProvider'; export * from './PostgresEntityQueryContextProvider'; +export * from './ReadonlyPostgresEntity'; export * from './SQLOperator'; export * from './errors/wrapNativePostgresCallAsync'; export * from './internal/EntityKnexDataManager';