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
@@ -1,12 +1,17 @@
import type {
EntityConfiguration,
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
IEntityMetricsAdapter,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { EntityNotFoundError, mapMap } from '@expo/entity';
import type { Result } from '@expo/results';
import { result } from '@expo/results';
import assert from 'assert';

import type {
FieldEqualityCondition,
Expand Down Expand Up @@ -64,6 +69,48 @@ export type EntityLoaderOrderByClause<
| EntityLoaderFieldNameOrderByClause<TFields, TSelectedFields>
| EntityLoaderFieldFragmentOrderByClause<TFields, TSelectedFields>;

/**
* Row locking modifier that locks the selected rows for update using `SELECT ... FOR UPDATE`.
* The lock is held until the end of the transaction, so the query context must be transactional.
*/
export interface EntityLoaderForUpdateRowLockingModifier {
forUpdate: true;
forShare?: never;
}

/**
* Row locking modifier that locks the selected rows in share mode using `SELECT ... FOR SHARE`.
* The lock is held until the end of the transaction, so the query context must be transactional.
*/
export interface EntityLoaderForShareRowLockingModifier {
forShare: true;
forUpdate?: never;
}

/**
* Row locking modifiers for load methods that never report a missing row as an error. Exactly one of
* `forUpdate` or `forShare` is required. `skipLocked` may be set to skip rows already locked by another
* transaction (using `SKIP LOCKED`) instead of waiting for them; skipped rows are reported as missing.
*/
export type EntityLoaderRowLockingModifiers = (
| EntityLoaderForUpdateRowLockingModifier
| EntityLoaderForShareRowLockingModifier
) & {
skipLocked?: boolean;
};

/**
* Row locking modifiers for load methods that throw when a row is missing. Exactly one of `forUpdate`
* or `forShare` is required. `skipLocked` is not permitted since a row skipped because another transaction
* holds a lock on it would be indistinguishable from a row that does not exist.
*/
export type EntityLoaderRowLockingModifiersWithoutSkipLocked = (
| EntityLoaderForUpdateRowLockingModifier
| EntityLoaderForShareRowLockingModifier
) & {
skipLocked?: never;
};

/**
* SQL modifiers that only affect the selection but not the projection.
*/
Expand Down Expand Up @@ -375,6 +422,15 @@ export class AuthorizationResultBasedKnexEntityLoader<
> {
constructor(
private readonly queryContext: EntityQueryContext,
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly entityClass: IEntityClass<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly constructionUtils: EntityConstructionUtils<
Expand All @@ -387,6 +443,127 @@ export class AuthorizationResultBasedKnexEntityLoader<
>,
) {}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadByIDFromDatabaseAsync(
id: TFields[TIDField],
modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked,
): Promise<Result<TEntity>> {
const entityResults = await this.loadManyByIDsFromDatabaseAsync([id], modifiers);
const entityResult = entityResults.get(id);
assert(entityResult !== undefined, `${id} should be guaranteed to be present in returned map`);
return entityResult;
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns entity result for matching ID, or null if no entity exists for ID, where result error can be UnauthorizedError.
*/
async loadByIDNullableFromDatabaseAsync(
id: TFields[TIDField],
modifiers: EntityLoaderRowLockingModifiers,
): Promise<Result<TEntity> | null> {
const entityResults = await this.loadManyByIDsNullableFromDatabaseAsync([id], modifiers);
return entityResults.get(id) ?? null;
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError.
*/
async loadManyByIDsFromDatabaseAsync(
ids: readonly TFields[TIDField][],
modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked,
): Promise<ReadonlyMap<TFields[TIDField], Result<TEntity>>> {
const entityResults = await this.loadManyByIDsNullableFromDatabaseAsync(ids, modifiers);
return mapMap(
entityResults,
(entityResult, id) =>
entityResult ??
result(
new EntityNotFoundError({
entityClass: this.entityClass,
fieldName: this.entityConfiguration.idField,
fieldValue: id,
}),
),
);
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError.
*/
async loadManyByIDsNullableFromDatabaseAsync(
ids: readonly TFields[TIDField][],
modifiers: EntityLoaderRowLockingModifiers,
): Promise<ReadonlyMap<TFields[TIDField], Result<TEntity> | null>> {
const idField = this.entityConfiguration.idField;
this.constructionUtils.validateFieldAndValues(idField, ids);

const fieldObjects =
ids.length > 0
? await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
[{ fieldName: idField, fieldValues: ids }],
modifiers,
)
: [];

const idsToFieldObjects = new Map<TFields[TIDField], readonly Readonly<TFields>[]>(
ids.map((id) => [id, []]),
);
for (const fieldObject of fieldObjects) {
const id = fieldObject[idField];
idsToFieldObjects.set(id, [...(idsToFieldObjects.get(id) ?? []), fieldObject]);
}

const idsToEntityResults =
await this.constructionUtils.constructAndAuthorizeEntitiesAsync(idsToFieldObjects);
return mapMap(idsToEntityResults, (entityResults) => entityResults[0] ?? null);
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition, where result error can be UnauthorizedError.
* @throws when multiple entities match the condition
*/
async loadByFieldEqualingFromDatabaseAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>,
modifiers: EntityLoaderRowLockingModifiers,
): Promise<Result<TEntity> | null> {
const entityResults = await this.loadManyByFieldEqualingFromDatabaseAsync(
uniqueFieldName,
fieldValue,
modifiers,
);
assert(
entityResults.length <= 1,
`loadByFieldEqualingFromDatabase: Multiple entities of type ${this.entityClass.name} found for ${String(
uniqueFieldName,
)}=${fieldValue}`,
);
return entityResults[0] ?? null;
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns array of entity results where fieldName equals fieldValue, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualingFromDatabaseAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>,
modifiers: EntityLoaderRowLockingModifiers,
): Promise<readonly Result<TEntity>[]> {
return await this.loadManyByFieldEqualityConjunctionAsync(
[{ fieldName, fieldValue }],
modifiers,
);
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns the first entity results that matches the query, where result error can be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import type {
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { mapMap } from '@expo/entity';

import type {
AuthorizationResultBasedKnexEntityLoader,
EntityLoaderLoadPageArgs,
EntityLoaderQuerySelectionModifiers,
EntityLoaderRowLockingModifiers,
EntityLoaderRowLockingModifiersWithoutSkipLocked,
} from './AuthorizationResultBasedKnexEntityLoader.ts';
import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
Expand Down Expand Up @@ -57,6 +60,140 @@ export class EnforcingKnexEntityLoader<
>,
) {}

/**
* Load an entity by ID directly from the database, bypassing the dataloader and cache.
*
* Unlike {@link "@expo/entity"!EnforcingEntityLoader.loadByIDAsync | EnforcingEntityLoader.loadByIDAsync}, this
* issues one database query per call and does not read from or write to the entity cache. It exists
* for cases that need row locking modifiers, which are required to make this intent explicit.
* `skipLocked` is not permitted here since a skipped row would be reported as not found.
* Use {@link loadByIDNullableFromDatabaseAsync} with `skipLocked`.
*
* @param id - ID of the entity
* @param modifiers - row locking modifiers for the query
* @returns entity matching ID
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
* @throws EntityNotFoundError when no entity exists for ID
*/
async loadByIDFromDatabaseAsync(
id: TFields[TIDField],
modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked,
): Promise<TEntity> {
const entityResult = await this.knexEntityLoader.loadByIDFromDatabaseAsync(id, modifiers);
return entityResult.enforceValue();
}

/**
* Load an entity by ID directly from the database, or return null if non-existent.
* See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader.
*
* @param id - ID of the entity
* @param modifiers - row locking modifiers for the query
* @returns entity for matching ID, or null if no entity exists for ID
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/
async loadByIDNullableFromDatabaseAsync(
id: TFields[TIDField],
modifiers: EntityLoaderRowLockingModifiers,
): Promise<TEntity | null> {
const entityResult = await this.knexEntityLoader.loadByIDNullableFromDatabaseAsync(
id,
modifiers,
);
return entityResult ? entityResult.enforceValue() : null;
}

/**
* Load many entities for a list of IDs directly from the database.
* See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader.
*
* `skipLocked` is not permitted here since a skipped row would be reported as not found.
* Use {@link loadManyByIDsNullableFromDatabaseAsync} with `skipLocked`.
*
* @param ids - IDs of the entities to load
* @param modifiers - row locking modifiers for the query
* @returns map from ID to corresponding entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
* @throws EntityNotFoundError when no entity exists for one or more of the IDs
*/
async loadManyByIDsFromDatabaseAsync(
ids: readonly TFields[TIDField][],
modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked,
): Promise<ReadonlyMap<TFields[TIDField], TEntity>> {
const entityResults = await this.knexEntityLoader.loadManyByIDsFromDatabaseAsync(
ids,
modifiers,
);
return mapMap(entityResults, (entityResult) => entityResult.enforceValue());
}

/**
* Load many entities for a list of IDs directly from the database, returning null for any IDs that are non-existent.
* See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader.
*
* @param ids - IDs of the entities to load
* @param modifiers - row locking modifiers for the query
* @returns map from ID to nullable corresponding entity
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByIDsNullableFromDatabaseAsync(
ids: readonly TFields[TIDField][],
modifiers: EntityLoaderRowLockingModifiers,
): Promise<ReadonlyMap<TFields[TIDField], TEntity | null>> {
const entityResults = await this.knexEntityLoader.loadManyByIDsNullableFromDatabaseAsync(
ids,
modifiers,
);
return mapMap(entityResults, (entityResult) => entityResult?.enforceValue() ?? null);
}

/**
* Load an entity where uniqueFieldName equals fieldValue directly from the database, or null if no entity matches.
* See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader.
*
* @param uniqueFieldName - entity field being queried
* @param fieldValue - uniqueFieldName field value being queried
* @param modifiers - row locking modifiers for the query
* @returns entity where uniqueFieldName equals fieldValue, or null if no entity matches the condition
* @throws when multiple entities match the condition
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/
async loadByFieldEqualingFromDatabaseAsync<N extends keyof Pick<TFields, TSelectedFields>>(
uniqueFieldName: N,
fieldValue: NonNullable<TFields[N]>,
modifiers: EntityLoaderRowLockingModifiers,
): Promise<TEntity | null> {
const entityResult = await this.knexEntityLoader.loadByFieldEqualingFromDatabaseAsync(
uniqueFieldName,
fieldValue,
modifiers,
);
return entityResult ? entityResult.enforceValue() : null;
}

/**
* Load many entities where fieldName equals fieldValue directly from the database.
* See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader.
*
* @param fieldName - entity field being queried
* @param fieldValue - fieldName field value being queried
* @param modifiers - row locking modifiers for the query
* @returns array of entities where fieldName equals fieldValue
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualingFromDatabaseAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>,
modifiers: EntityLoaderRowLockingModifiers,
): Promise<readonly TEntity[]> {
const entityResults = await this.knexEntityLoader.loadManyByFieldEqualingFromDatabaseAsync(
fieldName,
fieldValue,
modifiers,
);
return entityResults.map((entityResult) => entityResult.enforceValue());
}

/**
* Load the first entity matching the conjunction of field equality operands and
* query modifiers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export class KnexEntityLoaderFactory<

return new AuthorizationResultBasedKnexEntityLoader(
queryContext,
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
this.entityCompanion.entityCompanionDefinition.entityClass,
this.knexDataManager,
this.metricsAdapter,
constructionUtils,
Expand Down
Loading
Loading