Skip to content

Commit 4d4d971

Browse files
committed
feat!: Add sql template and loader method
1 parent ceba08f commit 4d4d971

15 files changed

Lines changed: 1905 additions & 2 deletions

packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
import {
1212
BasePostgresEntityDatabaseAdapter,
1313
OrderByOrdering,
14+
SQLFragment,
1415
TableFieldMultiValueEqualityCondition,
1516
TableFieldSingleValueEqualityCondition,
1617
TableQuerySelectionModifiers,
18+
TableQuerySelectionModifiersWithOrderByFragment,
1719
} from '@expo/entity-database-adapter-knex';
1820
import invariant from 'invariant';
1921
import { v7 as uuidv7 } from 'uuid';
@@ -184,6 +186,15 @@ export class StubPostgresDatabaseAdapter<
184186
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
185187
}
186188

189+
protected fetchManyBySQLFragmentInternalAsync(
190+
_queryInterface: any,
191+
_tableName: string,
192+
_sqlFragment: SQLFragment,
193+
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
194+
): Promise<object[]> {
195+
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
196+
}
197+
187198
private generateRandomID(): any {
188199
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
189200
invariant(

packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
SingleFieldHolder,
66
SingleFieldValueHolder,
77
} from '@expo/entity';
8-
import { OrderByOrdering } from '@expo/entity-database-adapter-knex';
8+
import { OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
99
import { describe, expect, it, jest } from '@jest/globals';
1010
import { instance, mock } from 'ts-mockito';
1111
import { validate, version } from 'uuid';
@@ -405,6 +405,19 @@ describe(StubPostgresDatabaseAdapter, () => {
405405
});
406406
});
407407

408+
describe('fetchManyBySQLFragmentAsync', () => {
409+
it('throws because it is unsupported', async () => {
410+
const queryContext = instance(mock(EntityQueryContext));
411+
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
412+
testEntityConfiguration,
413+
new Map(),
414+
);
415+
await expect(
416+
databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
417+
).rejects.toThrow();
418+
});
419+
});
420+
408421
describe('insertAsync', () => {
409422
it('inserts a record', async () => {
410423
const queryContext = instance(mock(EntityQueryContext));

packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
FieldEqualityCondition,
1313
isSingleValueFieldEqualityCondition,
1414
QuerySelectionModifiers,
15+
QuerySelectionModifiersWithOrderByFragment,
1516
QuerySelectionModifiersWithOrderByRaw,
1617
} from './BasePostgresEntityDatabaseAdapter';
18+
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
19+
import { SQLFragment } from './SQLOperator';
1720
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
1821

1922
/**
@@ -40,7 +43,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
4043
private readonly queryContext: EntityQueryContext,
4144
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
4245
protected readonly metricsAdapter: IEntityMetricsAdapter,
43-
public readonly constructionUtils: EntityConstructionUtils<
46+
private readonly constructionUtils: EntityConstructionUtils<
4447
TFields,
4548
TIDField,
4649
TViewerContext,
@@ -108,4 +111,75 @@ export class AuthorizationResultBasedKnexEntityLoader<
108111
);
109112
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
110113
}
114+
115+
/**
116+
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
117+
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
118+
*/
119+
loadManyBySQL(
120+
fragment: SQLFragment,
121+
modifiers: QuerySelectionModifiersWithOrderByFragment<TFields> = {},
122+
): AuthorizationResultBasedSQLQueryBuilder<
123+
TFields,
124+
TIDField,
125+
TViewerContext,
126+
TEntity,
127+
TPrivacyPolicy,
128+
TSelectedFields
129+
> {
130+
return new AuthorizationResultBasedSQLQueryBuilder(
131+
this.knexDataManager,
132+
this.constructionUtils,
133+
this.queryContext,
134+
fragment,
135+
modifiers,
136+
);
137+
}
138+
}
139+
140+
/**
141+
* SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader.
142+
*/
143+
export class AuthorizationResultBasedSQLQueryBuilder<
144+
TFields extends Record<string, any>,
145+
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
146+
TViewerContext extends ViewerContext,
147+
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
148+
TPrivacyPolicy extends EntityPrivacyPolicy<
149+
TFields,
150+
TIDField,
151+
TViewerContext,
152+
TEntity,
153+
TSelectedFields
154+
>,
155+
TSelectedFields extends keyof TFields,
156+
> extends BaseSQLQueryBuilder<TFields, Result<TEntity>> {
157+
constructor(
158+
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
159+
private readonly constructionUtils: EntityConstructionUtils<
160+
TFields,
161+
TIDField,
162+
TViewerContext,
163+
TEntity,
164+
TPrivacyPolicy,
165+
TSelectedFields
166+
>,
167+
private readonly queryContext: EntityQueryContext,
168+
sqlFragment: SQLFragment,
169+
modifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
170+
) {
171+
super(sqlFragment, modifiers);
172+
}
173+
174+
/**
175+
* Execute the query and return results.
176+
*/
177+
async executeInternalAsync(): Promise<readonly Result<TEntity>[]> {
178+
const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync(
179+
this.queryContext,
180+
this.getSQLFragment(),
181+
this.getModifiers(),
182+
);
183+
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
184+
}
111185
}

packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
} from '@expo/entity';
77
import { Knex } from 'knex';
88

9+
import { SQLFragment } from './SQLOperator';
10+
911
/**
1012
* Equality operand that is used for selecting entities with a field with a single value.
1113
*/
@@ -112,6 +114,15 @@ export interface QuerySelectionModifiersWithOrderByRaw<
112114
orderByRaw?: string;
113115
}
114116

117+
export interface QuerySelectionModifiersWithOrderByFragment<
118+
TFields extends Record<string, any>,
119+
> extends QuerySelectionModifiers<TFields> {
120+
/**
121+
* Order the entities by a SQL fragment `ORDER BY` clause.
122+
*/
123+
orderByFragment?: SQLFragment;
124+
}
125+
115126
export interface TableQuerySelectionModifiers {
116127
orderBy:
117128
| {
@@ -125,6 +136,11 @@ export interface TableQuerySelectionModifiers {
125136

126137
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
127138
orderByRaw: string | undefined;
139+
orderByRawBindings?: readonly any[];
140+
}
141+
142+
export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQuerySelectionModifiers {
143+
orderByFragment: SQLFragment | undefined;
128144
}
129145

130146
export abstract class BasePostgresEntityDatabaseAdapter<
@@ -218,6 +234,38 @@ export abstract class BasePostgresEntityDatabaseAdapter<
218234
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
219235
): Promise<object[]>;
220236

237+
/**
238+
* Fetch many objects matching the SQL fragment.
239+
*
240+
* @param queryContext - query context with which to perform the fetch
241+
* @param sqlFragment - SQL fragment representing the query
242+
* @param querySelectionModifiers - limit, offset, and orderBy for the query
243+
* @returns array of objects matching the query
244+
*/
245+
async fetchManyBySQLFragmentAsync(
246+
queryContext: EntityQueryContext,
247+
sqlFragment: SQLFragment,
248+
querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
249+
): Promise<readonly Readonly<TFields>[]> {
250+
const results = await this.fetchManyBySQLFragmentInternalAsync(
251+
queryContext.getQueryInterface(),
252+
this.entityConfiguration.tableName,
253+
sqlFragment,
254+
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
255+
);
256+
257+
return results.map((result) =>
258+
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
259+
);
260+
}
261+
262+
protected abstract fetchManyBySQLFragmentInternalAsync(
263+
queryInterface: Knex,
264+
tableName: string,
265+
sqlFragment: SQLFragment,
266+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
267+
): Promise<object[]>;
268+
221269
private convertToTableQueryModifiersWithOrderByRaw(
222270
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
223271
): TableQuerySelectionModifiersWithOrderByRaw {
@@ -227,6 +275,15 @@ export abstract class BasePostgresEntityDatabaseAdapter<
227275
};
228276
}
229277

278+
private convertToTableQueryModifiersWithOrderByFragment(
279+
querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment<TFields>,
280+
): TableQuerySelectionModifiersWithOrderByFragment {
281+
return {
282+
...this.convertToTableQueryModifiers(querySelectionModifiers),
283+
orderByFragment: querySelectionModifiers.orderByFragment,
284+
};
285+
}
286+
230287
private convertToTableQueryModifiers(
231288
querySelectionModifiers: QuerySelectionModifiers<TFields>,
232289
): TableQuerySelectionModifiers {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
OrderByOrdering,
3+
QuerySelectionModifiersWithOrderByFragment,
4+
} from './BasePostgresEntityDatabaseAdapter';
5+
import { SQLFragment } from './SQLOperator';
6+
7+
/**
8+
* Base SQL query builder that provides common functionality for building SQL queries.
9+
*/
10+
export abstract class BaseSQLQueryBuilder<TFields extends Record<string, any>, TResultType> {
11+
private executed = false;
12+
13+
constructor(
14+
private readonly sqlFragment: SQLFragment,
15+
private readonly modifiers: {
16+
limit?: number;
17+
offset?: number;
18+
orderBy?: { fieldName: keyof TFields; order: OrderByOrdering }[];
19+
orderByFragment?: SQLFragment;
20+
},
21+
) {}
22+
23+
/**
24+
* Limit the number of results
25+
*/
26+
limit(n: number): this {
27+
this.modifiers.limit = n;
28+
return this;
29+
}
30+
31+
/**
32+
* Skip a number of results
33+
*/
34+
offset(n: number): this {
35+
this.modifiers.offset = n;
36+
return this;
37+
}
38+
39+
/**
40+
* Order by a field. Can be called multiple times to add multiple order bys.
41+
*/
42+
orderBy(fieldName: keyof TFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
43+
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
44+
return this;
45+
}
46+
47+
/**
48+
* Order by a SQL fragment expression.
49+
* Provides type-safe, parameterized ORDER BY clauses
50+
*
51+
* @example
52+
* ```ts
53+
* import { sql, raw } from '@expo/entity-database-adapter-knex';
54+
*
55+
* // Safe parameterized ordering
56+
* .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`)
57+
*
58+
* // Dynamic column ordering
59+
* const sortColumn = 'name';
60+
* .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`)
61+
*
62+
* // Complex expressions
63+
* .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`)
64+
* ```
65+
*/
66+
orderBySQL(fragment: SQLFragment): this {
67+
this.modifiers.orderByFragment = fragment;
68+
return this;
69+
}
70+
71+
/**
72+
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
73+
*/
74+
protected getModifiers(): QuerySelectionModifiersWithOrderByFragment<TFields> {
75+
return this.modifiers;
76+
}
77+
78+
/**
79+
* Get the SQL fragment
80+
*/
81+
protected getSQLFragment(): SQLFragment {
82+
return this.sqlFragment;
83+
}
84+
85+
/**
86+
* Execute the query and return results.
87+
* Implementation depends on the specific loader type.
88+
*/
89+
public async executeAsync(): Promise<readonly TResultType[]> {
90+
if (this.executed) {
91+
throw new Error(
92+
'Query has already been executed. Create a new query builder to execute again.',
93+
);
94+
}
95+
this.executed = true;
96+
return await this.executeInternalAsync();
97+
}
98+
99+
protected abstract executeInternalAsync(): Promise<readonly TResultType[]>;
100+
}

0 commit comments

Comments
 (0)