Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import type {
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
FieldEqualityCondition,
IEntityMetricsAdapter,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { isSingleValueFieldEqualityCondition } from '@expo/entity';
import type { Result } from '@expo/results';

import type {
FieldEqualityCondition,
NullsOrdering,
OrderByOrdering,
} from './BasePostgresEntityDatabaseAdapter.ts';
import { isSingleValueFieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
import type { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
import type { PaginationStrategy } from './PaginationStrategy.ts';
import type { SQLFragment } from './SQLOperator.ts';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,14 @@
import type { EntityQueryContext } from '@expo/entity';
import type { EntityQueryContext, FieldEqualityCondition } from '@expo/entity';
import {
EntityDatabaseAdapter,
getDatabaseFieldForEntityField,
isSingleValueFieldEqualityCondition,
transformDatabaseObjectToFields,
} from '@expo/entity';
import type { Knex } from 'knex';

import type { SQLFragment } from './SQLOperator.ts';

/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValue: TFields[N];
}

/**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}

/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
export type FieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;

export function isSingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
>(
condition: FieldEqualityCondition<TFields, N>,
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
}

export interface TableFieldSingleValueEqualityCondition {
tableField: string;
tableValue: any;
Expand Down Expand Up @@ -167,21 +128,31 @@ export abstract class BasePostgresEntityDatabaseAdapter<
}
/**
* Fetch many objects matching the conjunction of where clauses constructed from
* specified field equality operands.
* specified field equality operands. Overrides the base entity adapter implementation
* to push the conjunction filter all the way down to SQL.
*
* @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query.
* Optional; when omitted the entire conjunction is returned in arbitrary order.
* @returns array of objects matching the query
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
override async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields> = {},
): Promise<readonly Readonly<TFields>[]> {
const combinedOperands: readonly FieldEqualityCondition<TFields, N>[] = [
...fieldEqualityOperands,
...(this.entityConfiguration.inherentFilters as readonly FieldEqualityCondition<
TFields,
N
>[]),
];

const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
for (const operand of fieldEqualityOperands) {
for (const operand of combinedOperands) {
if (isSingleValueFieldEqualityCondition(operand)) {
tableFieldSingleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
FieldEqualityCondition,
IEntityMetricsAdapter,
ReadonlyEntity,
ViewerContext,
Expand All @@ -12,7 +13,6 @@ import type {
EntityLoaderLoadPageArgs,
EntityLoaderQuerySelectionModifiers,
} from './AuthorizationResultBasedKnexEntityLoader.ts';
import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
import type { SQLFragment } from './SQLOperator.ts';
import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager.ts';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { EntityConfiguration, EntityQueryContext, IEntityMetricsAdapter } from '@expo/entity';
import type {
EntityConfiguration,
EntityQueryContext,
FieldEqualityCondition,
IEntityMetricsAdapter,
} from '@expo/entity';
import {
EntityDatabaseAdapterPaginationCursorInvalidError,
EntityMetricsLoadType,
Expand All @@ -10,7 +15,6 @@ import assert from 'assert';

import type {
BasePostgresEntityDatabaseAdapter,
FieldEqualityCondition,
PostgresOrderByClause,
PostgresQuerySelectionModifiers,
} from '../BasePostgresEntityDatabaseAdapter.ts';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ViewerContext } from '@expo/entity';
import type { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import { RedisCacheInvalidationStrategy } from '@expo/entity-cache-adapter-redis';
import nullthrows from '@expo/nullthrows';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import type { Knex } from 'knex';
import knex from 'knex';
import { URL } from 'url';

import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider.ts';
import PolyParentEntity from './entities/PolyParentEntity.ts';
import ScopeAChildEntity from './entities/ScopeAChildEntity.ts';
import ScopeBChildEntity from './entities/ScopeBChildEntity.ts';

async function createPostgresTablesAsync(knex: Knex): Promise<void> {
await knex.schema.createTable('poly_parents', (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.schema.createTable('poly_children', (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.uuid('parent_id').references('id').inTable('poly_parents');
table.string('scope').notNullable();
});
}

async function truncatePostgresTablesAsync(knex: Knex): Promise<void> {
await knex.raw('TRUNCATE TABLE poly_children, poly_parents CASCADE');
}

async function dropPostgresTablesAsync(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('poly_children')) {
await knex.schema.dropTable('poly_children');
}
if (await knex.schema.hasTable('poly_parents')) {
await knex.schema.dropTable('poly_parents');
}
}

describe('EntityConfiguration.inherentFilters (Postgres + Redis)', () => {
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;

beforeAll(async () => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400,
ttlSecondsNegative: 600,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
await createPostgresTablesAsync(knexInstance);
});

beforeEach(async () => {
await truncatePostgresTablesAsync(knexInstance);
await redisClient.flushdb();
});

afterAll(async () => {
await dropPostgresTablesAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
});

it('loads only rows that match the inherent filter', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);

const parent = await PolyParentEntity.creator(viewerContext).createAsync();
const scopeARow = await ScopeAChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.setField('scope', 'A')
.createAsync();
const scopeBRow = await ScopeBChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.setField('scope', 'B')
.createAsync();

await expect(
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
).resolves.not.toBeNull();
await expect(
ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()),
).resolves.not.toBeNull();

// A wrong-scope id load returns null, not a constructor invariant throw — the SQL
// WHERE clause AND's the inherent filter, so the row is invisible to the wrong class.
await expect(
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()),
).resolves.toBeNull();
await expect(
ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
).resolves.toBeNull();
});

it("cascade-delete via inboundEdges only deletes rows matching each class's inherent filter", async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext),
);

const parent = await PolyParentEntity.creator(viewerContext).createAsync();
const scopeARow = await ScopeAChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.setField('scope', 'A')
.createAsync();
const scopeBRow = await ScopeBChildEntity.creator(viewerContext)
.setField('parent_id', parent.getID())
.setField('scope', 'B')
.createAsync();

await PolyParentEntity.deleter(parent).deleteAsync();

await expect(
ScopeAChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeARow.getID()),
).resolves.toBeNull();
await expect(
ScopeBChildEntity.loader(viewerContext).loadByIDNullableAsync(scopeBRow.getID()),
).resolves.toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
} from '@expo/entity';

import ScopeAChildEntity from './ScopeAChildEntity.ts';
import ScopeBChildEntity from './ScopeBChildEntity.ts';

interface PolyParentFields {
id: string;
}

class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<any, 'id', ViewerContext, any, any> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<any, 'id', ViewerContext, any, any>(),
];
}

export default class PolyParentEntity extends Entity<PolyParentFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
PolyParentFields,
'id',
ViewerContext,
PolyParentEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: PolyParentEntity,
entityConfiguration: new EntityConfiguration<PolyParentFields, 'id'>({
idField: 'id',
tableName: 'poly_parents',
inboundEdges: [ScopeAChildEntity, ScopeBChildEntity],
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
}
Loading