Skip to content
Open
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 @@ -11,9 +11,11 @@ import {
import {
BasePostgresEntityDatabaseAdapter,
OrderByOrdering,
SQLFragment,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
} from '@expo/entity-database-adapter-knex';
import invariant from 'invariant';
import { v7 as uuidv7 } from 'uuid';
Expand Down Expand Up @@ -184,6 +186,15 @@ export class StubPostgresDatabaseAdapter<
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
}

protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment,
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}

private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
SingleFieldHolder,
SingleFieldValueHolder,
} from '@expo/entity';
import { OrderByOrdering } from '@expo/entity-database-adapter-knex';
import { OrderByOrdering, sql } 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';
Expand Down Expand Up @@ -405,6 +405,19 @@ describe(StubPostgresDatabaseAdapter, () => {
});
});

describe('fetchManyBySQLFragmentAsync', () => {
it('throws because it is unsupported', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
).rejects.toThrow();
});
});

describe('insertAsync', () => {
it('inserts a record', async () => {
const queryContext = instance(mock(EntityQueryContext));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import {
FieldEqualityCondition,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByFragment,
QuerySelectionModifiersWithOrderByRaw,
} from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
import { SQLFragment } from './SQLOperator';
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';

/**
Expand All @@ -40,7 +43,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
private readonly queryContext: EntityQueryContext,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
public readonly constructionUtils: EntityConstructionUtils<
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
Expand Down Expand Up @@ -108,4 +111,75 @@ export class AuthorizationResultBasedKnexEntityLoader<
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}

/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
*/
loadManyBySQL(
fragment: SQLFragment,
modifiers: QuerySelectionModifiersWithOrderByFragment<TFields> = {},
): AuthorizationResultBasedSQLQueryBuilder<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new AuthorizationResultBasedSQLQueryBuilder(
this.knexDataManager,
this.constructionUtils,
this.queryContext,
fragment,
modifiers,
);
}
}

/**
* SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader.
*/
export class AuthorizationResultBasedSQLQueryBuilder<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> extends BaseSQLQueryBuilder<TFields, Result<TEntity>> {
constructor(
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
modifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
) {
super(sqlFragment, modifiers);
}

/**
* Execute the query and return results.
*/
async executeInternalAsync(): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync(
this.queryContext,
this.getSQLFragment(),
this.getModifiers(),
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from '@expo/entity';
import { Knex } from 'knex';

import { SQLFragment } from './SQLOperator';

/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
Expand Down Expand Up @@ -112,6 +114,15 @@ export interface QuerySelectionModifiersWithOrderByRaw<
orderByRaw?: string;
}

export interface QuerySelectionModifiersWithOrderByFragment<
TFields extends Record<string, any>,
> extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a SQL fragment `ORDER BY` clause.
*/
orderByFragment?: SQLFragment;
}

export interface TableQuerySelectionModifiers {
orderBy:
| {
Expand All @@ -125,6 +136,11 @@ export interface TableQuerySelectionModifiers {

export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
orderByRawBindings?: readonly any[];
}

export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQuerySelectionModifiers {
orderByFragment: SQLFragment | undefined;
}

export abstract class BasePostgresEntityDatabaseAdapter<
Expand Down Expand Up @@ -218,6 +234,38 @@ export abstract class BasePostgresEntityDatabaseAdapter<
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Promise<object[]>;

/**
* Fetch many objects matching the SQL fragment.
*
* @param queryContext - query context with which to perform the fetch
* @param sqlFragment - SQLFragment for the WHERE clause of the query
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
* @returns array of objects matching the query
*/
async fetchManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyBySQLFragmentInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
sqlFragment,
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
);

return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}

protected abstract fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
): Promise<object[]>;

private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
): TableQuerySelectionModifiersWithOrderByRaw {
Expand All @@ -227,6 +275,15 @@ export abstract class BasePostgresEntityDatabaseAdapter<
};
}

private convertToTableQueryModifiersWithOrderByFragment(
querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
): TableQuerySelectionModifiersWithOrderByFragment {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByFragment: querySelectionModifiers.orderByFragment,
};
}

private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields>,
): TableQuerySelectionModifiers {
Expand Down
100 changes: 100 additions & 0 deletions packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
OrderByOrdering,
QuerySelectionModifiersWithOrderByFragment,
} from './BasePostgresEntityDatabaseAdapter';
import { SQLFragment } from './SQLOperator';

/**
* Base SQL query builder that provides common functionality for building SQL queries.
*/
export abstract class BaseSQLQueryBuilder<TFields extends Record<string, any>, TResultType> {
private executed = false;

constructor(
private readonly sqlFragment: SQLFragment,
private readonly modifiers: {
limit?: number;
offset?: number;
orderBy?: { fieldName: keyof TFields; order: OrderByOrdering }[];
orderByFragment?: SQLFragment;
},
) {}

/**
* Limit the number of results
*/
limit(n: number): this {
this.modifiers.limit = n;
return this;
}

/**
* Skip a number of results
*/
offset(n: number): this {
this.modifiers.offset = n;
return this;
}

/**
* Order by a field. Can be called multiple times to add multiple order bys.
*/
orderBy(fieldName: keyof TFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
return this;
}

/**
* Order by a SQL fragment expression.
* Provides type-safe, parameterized ORDER BY clauses
*
* @example
* ```ts
* import { sql, raw } from '@expo/entity-database-adapter-knex';
*
* // Safe parameterized ordering
* .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`)
*
* // Dynamic column ordering
* const sortColumn = 'name';
* .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`)
*
* // Complex expressions
* .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`)
* ```
*/
orderBySQL(fragment: SQLFragment): this {
this.modifiers.orderByFragment = fragment;
return this;
}

/**
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
*/
protected getModifiers(): QuerySelectionModifiersWithOrderByFragment<TFields> {
return this.modifiers;
}

/**
* Get the SQL fragment
*/
protected getSQLFragment(): SQLFragment {
return this.sqlFragment;
}

/**
* Execute the query and return results.
* Implementation depends on the specific loader type.
*/
public async executeAsync(): Promise<readonly TResultType[]> {
if (this.executed) {
throw new Error(
'Query has already been executed. Create a new query builder to execute again.',
);
}
this.executed = true;
return await this.executeInternalAsync();
}

protected abstract executeInternalAsync(): Promise<readonly TResultType[]>;
}
Loading