diff --git a/packages/backend/src/QueryEngine.test.ts b/packages/backend/src/QueryEngine.test.ts new file mode 100644 index 00000000..e06dbe54 --- /dev/null +++ b/packages/backend/src/QueryEngine.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { col, param } from '@synthql/queries'; +import { queryEngine } from './tests/queryEngine'; +import { from } from './tests/generated'; + +const params = { + 'where.actor_id': 2, + 'where.film_id': 47, + 'include.film.where.language_id': 3, + 'include.film.include.language.where.last_update': '2022-02-15 10:02:19+00', +}; + +describe('QueryEngine', () => { + it('registerQueries + executeRegisteredQuery', async () => { + queryEngine.registerQueries([findFilmActor(false).maybe()]); + + const parameterizedQueryResult = + await queryEngine.executeRegisteredQueryAndWait({ + queryId: findFilmActor(false).maybe().hash, + params, + }); + + const regularQueryResult = await queryEngine.executeAndWait( + findFilmActor(true).maybe(), + ); + + expect(parameterizedQueryResult).toEqual(regularQueryResult); + }); +}); + +function findFilmActor(regular: boolean) { + return from('film_actor') + .where({ + actor_id: regular ? params['where.actor_id'] : param(), + film_id: regular ? params['where.film_id'] : param(), + }) + .include({ + film: from('film') + .where({ + film_id: col('film_actor.film_id'), + language_id: regular + ? params['include.film.where.language_id'] + : param(), + }) + .include({ + language: from('language') + .where({ + language_id: col('film.language_id'), + last_update: regular + ? params[ + 'include.film.include.language.where.last_update' + ] + : param(), + }) + .maybe(), + }) + .maybe(), + }); +} diff --git a/packages/backend/src/QueryEngine.ts b/packages/backend/src/QueryEngine.ts index a09c496c..04619a95 100644 --- a/packages/backend/src/QueryEngine.ts +++ b/packages/backend/src/QueryEngine.ts @@ -1,5 +1,11 @@ import { Pool } from 'pg'; -import { Query, QueryResult, Table } from '@synthql/queries'; +import { + AnyQuery, + Query, + QueryResult, + RegisteredQueryRequestBody, + Table, +} from '@synthql/queries'; import { composeQuery } from './execution/executors/PgExecutor/composeQuery'; import { QueryPlan, collectLast } from '.'; import { QueryProvider } from './QueryProvider'; @@ -9,12 +15,15 @@ import { QueryProviderExecutor } from './execution/executors/QueryProviderExecut import { PgExecutor } from './execution/executors/PgExecutor'; import { generateLast } from './util/generators/generateLast'; import { SynthqlError } from './SynthqlError'; +import { QueryStore } from './QueryStore'; export interface QueryEngineProps { /** - * The database connection string e.g. `postgresql://user:password@localhost:5432/db`. + * The database connection string. + * e.g. `postgresql://user:password@localhost:5432/db`. * - * If you use this option, SynthQL will create a conection pool for you internally. + * If you use this option, SynthQL will create + * a conection pool for you internally. */ url?: string; /** @@ -25,14 +34,20 @@ export interface QueryEngineProps { */ schema?: string; /** - * An optional SQL statement that will be sent before every SynthQL query. + * If true, the executor will execute queries that have not + * been registered via `QueryEngine.registerQueries()`. + */ + dangerouslyAllowUnregisteredQueries?: boolean; + /** + * An optional SQL statement that will + * be sent before every SynthQL query. * * e.g `SELECT version();` */ prependSql?: string; /** - * A list of providers that you want to be used - * to execute your SynthQL queries against. + * A list of providers that you want to + * execute your SynthQL queries against. * * e.g: * @@ -55,9 +70,11 @@ export interface QueryEngineProps { */ providers?: Array>>; /** - * The connection pool to which the executor will send SQL queries to. + * The connection pool to which the + * executor will send SQL queries to. * - * You can use this instead of passing a connection string. + * You can use this instead of + * passing a connection string. */ pool?: Pool; @@ -70,18 +87,24 @@ export interface QueryEngineProps { export class QueryEngine { private pool: Pool; private schema: string; + private dangerouslyAllowUnregisteredQueries: boolean; private prependSql?: string; + private queryStore: QueryStore; private executors: Array = []; constructor(config: QueryEngineProps) { - this.schema = config.schema ?? 'public'; - this.prependSql = config.prependSql; this.pool = config.pool ?? new Pool({ connectionString: config.url, max: 10, }); + this.schema = config.schema ?? 'public'; + this.dangerouslyAllowUnregisteredQueries = + config.dangerouslyAllowUnregisteredQueries ?? false; + this.prependSql = config.prependSql; + + this.queryStore = new QueryStore(); const qpe = new QueryProviderExecutor(config.providers ?? []); this.executors = [ @@ -96,12 +119,24 @@ export class QueryEngine { ]; } + compile(query: T extends Query ? T : never): { + sql: string; + params: any[]; + } { + const { sqlBuilder } = composeQuery({ + defaultSchema: this.schema, + query, + }); + + return sqlBuilder.build(); + } + execute, TQuery extends Query>( query: TQuery, opts?: { /** - * The name of the database schema to execute - * your SynthQL query against + * The name of the database schema to + * execute your SynthQL query against * * e.g `public` */ @@ -113,6 +148,22 @@ export class QueryEngine { returnLastOnly?: boolean; }, ): AsyncGenerator> { + if (!this.dangerouslyAllowUnregisteredQueries) { + if (!query.hash) { + throw SynthqlError.createQueryMissingHashError({ + query, + }); + } + + const hasQueryFn = this.queryStore.has(query.hash); + + if (!hasQueryFn) { + throw SynthqlError.createQueryNotRegisteredError({ + queryId: query.hash, + }); + } + } + const gen = execute(query, { executors: this.executors, defaultSchema: opts?.schema ?? this.schema, @@ -133,35 +184,77 @@ export class QueryEngine { query: TQuery, opts?: { /** - * The name of the database schema to execute - * your SynthQL query against + * The name of the database schema to + * execute your SynthQL query against * * e.g `public` */ schema?: string; }, ): Promise> { - return await collectLast( - generateLast( - execute(query, { - executors: this.executors, - defaultSchema: opts?.schema ?? this.schema, - prependSql: this.prependSql, - }), - ), + return collectLast( + this.execute(query, { + schema: opts?.schema ?? this.schema, + returnLastOnly: true, + }), ); } - compile(query: T extends Query ? T : never): { - sql: string; - params: any[]; - } { - const { sqlBuilder } = composeQuery({ - defaultSchema: this.schema, - query, + executeRegisteredQuery< + TTable extends Table, + TQuery extends Query, + >( + { queryId, params }: RegisteredQueryRequestBody, + opts?: { + /** + * The name of the database schema to + * execute your SynthQL query against + * + * e.g `public` + */ + schema?: string; + /** + * If true, the query result generator will wait for query + * execution completion, and then return only the last result + */ + returnLastOnly?: boolean; + }, + ): AsyncGenerator> { + const query = this.queryStore.get({ + queryId, + params, }); - return sqlBuilder.build(); + return this.execute(query as TQuery, { + schema: opts?.schema ?? this.schema, + returnLastOnly: true, + }); + } + + executeRegisteredQueryAndWait< + TTable extends Table, + TQuery extends Query, + >( + { queryId, params }: RegisteredQueryRequestBody, + opts?: { + /** + * The name of the database schema to + * execute your SynthQL query against + * + * e.g `public` + */ + schema?: string; + }, + ): Promise> { + return collectLast( + this.executeRegisteredQuery( + { queryId, params }, + { + schema: opts?.schema ?? this.schema, + returnLastOnly: true, + }, + ), + ); } async explain>( @@ -173,7 +266,6 @@ export class QueryEngine { }); const { params, sql } = sqlBuilder.build(); - const explainQuery: string = `explain (analyze, buffers, verbose, settings, format json) ${sql}`; try { @@ -186,4 +278,10 @@ export class QueryEngine { }); } } + + registerQueries(queries: Array) { + for (const query of queries) { + this.queryStore.set(query); + } + } } diff --git a/packages/backend/src/QueryStore.ts b/packages/backend/src/QueryStore.ts new file mode 100644 index 00000000..f49e611f --- /dev/null +++ b/packages/backend/src/QueryStore.ts @@ -0,0 +1,89 @@ +import { + AnyQuery, + isQueryParameter, + iterateRecursively, +} from '@synthql/queries'; +import { SynthqlError } from './SynthqlError'; + +export class QueryStore { + private queries: Map; + + constructor() { + this.queries = new Map(); + } + + /** + * Finds a query from the store and + * applies the parameters to the query. + * + * Throws an error if the query cannot be found, + * or if any of the parameters cannot be applied. + */ + get({ + queryId, + params, + }: { + queryId: string; + params: Record; + }): AnyQuery { + const query = this.queries.get(queryId); + + if (!query) { + throw SynthqlError.createQueryNotRegisteredError({ queryId }); + } + + // Check if all required parameters are provided + const missingParams: string[] = []; + + iterateRecursively(query, (x, _) => { + if (isQueryParameter(x) && params[x.id] === undefined) { + missingParams.push(x.id); + } + }); + + if (missingParams.length > 0) { + throw SynthqlError.createQueryParameterMissingValueError({ + params, + paramIds: missingParams, + }); + } + + // Apply parameters + iterateRecursively(query, (x, _) => { + if (isQueryParameter(x)) { + x.value = params[x.id]; + } + }); + + return query; + } + + /** + * Checks if a query is in the the store + * and returns a corresponding boolean. + */ + has(queryId: string) { + return this.queries.has(queryId); + } + + /** + * Adds a parameterized query to the store. + * Throws an error if a query with the + * same identifier already exists. + */ + set(query: AnyQuery): void { + if (!query.hash) { + throw SynthqlError.createQueryMissingHashError({ + query, + }); + } + + if (this.queries.has(query.hash)) { + throw SynthqlError.createQueryAlreadyRegisteredError({ + queryId: query.hash, + }); + } + + this.queries.set(query.hash, query); + } +} diff --git a/packages/backend/src/SynthqlError.ts b/packages/backend/src/SynthqlError.ts index a91b6741..eb0dded8 100644 --- a/packages/backend/src/SynthqlError.ts +++ b/packages/backend/src/SynthqlError.ts @@ -19,6 +19,21 @@ export class SynthqlError extends Error { Error.captureStackTrace(this, SynthqlError); } + static createCardinalityError() { + const type = 'CardinalityError'; + + // Two ways this error can happen: + // 1. The top level query returned no results. + // 2. A subquery returned no results. + + const lines = [ + 'A query with a cardinality of `one` returned no results!', + 'Hint: are you using .one() when you should be using .maybe()?', + ]; + + return new SynthqlError(new Error(), type, lines.join('\n'), 404); + } + static createDatabaseConnectionError({ error, }: { @@ -29,10 +44,10 @@ export class SynthqlError extends Error { const lines = [ 'Database connection error!', '', - 'Failure to establish a connection to your database.', + 'Failure to establish a connection to your database', '', - 'Check your connection string, and make sure your database', - 'is up and can accept new connections.', + 'Check your connection string, and make sure your', + 'database is up and can accept new connections', '', 'Here is the underlying error message:', '', @@ -41,18 +56,28 @@ export class SynthqlError extends Error { return new SynthqlError(error, type, lines.join('\n')); } - static createSqlExecutionError({ + + static createJsonParsingError({ error, - props, + json, }: { error: any; - props: SqlExecutionErrorProps; + json: string; }): SynthqlError { - const type = 'SqlExecutionError'; + const type = 'JsonParsingError'; - const message = composeMessage(error, props); + const lines = [ + 'JSON parsing error!', + '', + 'Expected a JSON string but got this instead:', + '', + json, + '', + 'Check your query and make sure your stringifier', + 'function/method is behaving as expected', + ]; - return new SynthqlError(error, type, message); + return new SynthqlError(error, type, lines.join('\n')); } static createPrependSqlExecutionError({ @@ -76,6 +101,86 @@ export class SynthqlError extends Error { return new SynthqlError(error, type, lines.join('\n')); } + static createQueryAlreadyRegisteredError({ queryId }: { queryId: string }) { + const type = 'QueryAlreadyRegisteredError'; + + const lines = [ + 'Query already registered!', + '', + 'A query already exists in the query store for the queryId:', + '', + JSON.stringify(queryId, null, 2), + '', + ]; + + return new SynthqlError(new Error(), type, lines.join('\n')); + } + + static createQueryMissingHashError({ query }: { query: AnyQuery }) { + const type = 'QueryMissingHashError'; + + const lines = [ + 'Query missing hash!', + '', + 'The query:', + '', + JSON.stringify(query, null, 2), + '', + 'is missing its `hash` (i.e `query.hash`) property,', + ' which is used as the key when registering it', + 'via QueryEngine.registerQueries()', + '', + ]; + + return new SynthqlError(new Error(), type, lines.join('\n')); + } + + static createQueryNotRegisteredError({ queryId }: { queryId: string }) { + const type = 'QueryNotRegisteredError'; + + const lines = [ + 'Query not registered!', + '', + 'No query found in the query store for the queryId:', + '', + JSON.stringify(queryId, null, 2), + '', + 'Check and make sure the correct queryId', + '(i.e `query.hash`) is being passed', + '', + ]; + + return new SynthqlError(new Error(), type, lines.join('\n')); + } + + static createQueryParameterMissingValueError({ + params, + paramIds, + }: { + params: Record; + paramIds: string[]; + }) { + const type = 'QueryParameterMissingValueError'; + + const lines = [ + 'Query parameter missing value!', + '', + 'No value found for the parameter(s):', + '', + JSON.stringify(paramIds, null, 2), + '', + 'in the `params` object:', + '', + JSON.stringify(params, null, 2), + '', + 'Check and make sure the correct values for each', + 'parameter is included in the `params` object', + '', + ]; + + return new SynthqlError(new Error(), type, lines.join('\n')); + } + static createResponseStreamingError({ error, query, @@ -92,48 +197,27 @@ export class SynthqlError extends Error { '', JSON.stringify(query, null, 2), '', - 'Check your query and make sure you have `read` access to all included', - 'tables and columns, and have registered all queries via the QueryEngine', + 'Check your query and make sure you', + 'have `read` access to all included', + 'tables and columns, and registered', + 'all queries via the QueryEngine', ]; return new SynthqlError(error, type, lines.join('\n')); } - static createJsonParsingError({ + static createSqlExecutionError({ error, - json, + props, }: { error: any; - json: string; + props: SqlExecutionErrorProps; }): SynthqlError { - const type = 'JsonParsingError'; - - const lines = [ - 'JSON parsing error!', - '', - 'Expected a JSON string but got this instead:', - '', - json, - '', - 'Check your query and make sure your stringifying function is behaving as expected', - ]; - - return new SynthqlError(error, type, lines.join('\n')); - } - - static createCardinalityError() { - const type = 'CardinalityError'; - - // Two ways this error can happen: - // 1. The top level query returned no results. - // 2. A subquery returned no results. + const type = 'SqlExecutionError'; - const lines = [ - 'A query with a cardinality of `one` returned no results!', - 'Hint: are you using .one() when you should be using .maybe()?', - ]; + const message = composeMessage(error, props); - return new SynthqlError(new Error(), type, lines.join('\n'), 404); + return new SynthqlError(error, type, message); } } @@ -149,7 +233,7 @@ function printError(err: any): string { function composeMessage(err: any, props: SqlExecutionErrorProps): string { const lines: string[] = [ - '# Error executing query', + '# Error executing query:', '', printError(err), '', diff --git a/packages/backend/src/execution/executors/PgExecutor/PgExecutor.test.ts b/packages/backend/src/execution/executors/PgExecutor/PgExecutor.test.ts index 51af47ee..fbe812fb 100644 --- a/packages/backend/src/execution/executors/PgExecutor/PgExecutor.test.ts +++ b/packages/backend/src/execution/executors/PgExecutor/PgExecutor.test.ts @@ -1,4 +1,4 @@ -import { col } from '@synthql/queries'; +import { col, param } from '@synthql/queries'; import { describe, expect, it } from 'vitest'; import { PgExecutor } from '.'; import { from } from '../../../tests/generated'; @@ -14,7 +14,7 @@ describe('PgExecutor', () => { prependSql: `SET search_path TO "public";`, }); - const q1 = from('film') + const q = from('film') .columns('film_id', 'title') .include({ lang: from('language') @@ -30,7 +30,7 @@ describe('PgExecutor', () => { .one(); it('Film table SynthQL query compiles to expected SQL query', () => { - const { sql } = executor.compile(q1); + const { sql } = executor.compile(q); expect(sql).toMatchInlineSnapshot(` "select @@ -50,7 +50,7 @@ describe('PgExecutor', () => { }); it('Film table SynthQL query executes to expected result', async () => { - const result = await executor.execute(q1, executeProps); + const result = await executor.execute(q, executeProps); expect(result).toEqual([ { @@ -63,6 +63,42 @@ describe('PgExecutor', () => { ]); }); + const q0 = from('actor') + .columns('actor_id', 'first_name') + .where({ + actor_id: param(2), + }) + .one(); + + it('Actor table SynthQL query with `({ column: param(value) })` executes to expected result', async () => { + const result = await executor.execute(q0, executeProps); + + expect(result).toEqual([ + { + actor_id: 2, + first_name: 'NICK', + }, + ]); + }); + + const q1 = from('actor') + .columns('actor_id', 'first_name') + .where({ + actor_id: { '>': param(3) }, + }) + .one(); + + it('Actor table SynthQL query with param({ column: { op: param(value) } }) executes to expected result', async () => { + const result = await executor.execute(q1, executeProps); + + expect(result).toEqual([ + { + actor_id: 4, + first_name: 'JENNIFER', + }, + ]); + }); + const q2 = from('actor') .columns('actor_id', 'first_name', 'last_name') .take(2); diff --git a/packages/backend/src/execution/executors/PgExecutor/composeQuery.ts b/packages/backend/src/execution/executors/PgExecutor/composeQuery.ts index aef301a8..70284eb7 100644 --- a/packages/backend/src/execution/executors/PgExecutor/composeQuery.ts +++ b/packages/backend/src/execution/executors/PgExecutor/composeQuery.ts @@ -14,7 +14,7 @@ export function composeQuery({ }): { sqlBuilder: SqlBuilder; augmentedQuery: AugmentedQuery } { const augQuery: AugmentedQuery = createAugmentedQuery(query, defaultSchema); - let sqlBuilder = new SqlBuilder() + const sqlBuilder = new SqlBuilder() .select(augQuery.selection.map((s) => s.toSql())) .from(augQuery.rootTable) .leftJoins(augQuery.joins) diff --git a/packages/backend/src/execution/executors/PgExecutor/queryBuilder/exp.ts b/packages/backend/src/execution/executors/PgExecutor/queryBuilder/exp.ts index 2400d71c..7b288d2b 100644 --- a/packages/backend/src/execution/executors/PgExecutor/queryBuilder/exp.ts +++ b/packages/backend/src/execution/executors/PgExecutor/queryBuilder/exp.ts @@ -1,4 +1,4 @@ -import { AnyDB } from '@synthql/queries'; +import { AnyDB, isQueryParameter } from '@synthql/queries'; import { OPERATORS, UnaryOperator } from 'kysely'; import { TableRef } from '../../../../refs/TableRef'; import { ColumnRef } from '../../../../refs/ColumnRef'; @@ -101,16 +101,20 @@ type Exp = export function compileExp(exp: Exp): SqlBuilder { const builder = new SqlBuilder(); + if (typeof exp === 'string') { return builder.addColumnReference(exp); } + switch (exp[0]) { case 'op': { const [_, op, ...exps] = exp; + if (exp[1] === '= any') { const [_, op, exp1, exp2] = exp; return compileExp(eqAny(exp1, exp2)); } + if (exp[1] === 'not = any') { const [_, op, exp1, exp2] = exp; return compileExp(not(eqAny(exp1, exp2))); @@ -176,6 +180,7 @@ export class SqlBuilder { // TODO: escape string return this.add(`'${value}'`).space(); } + if (Array.isArray(value)) { return this.openParen() .addInterleaved( @@ -184,6 +189,7 @@ export class SqlBuilder { ) .closeParen(); } + return this.add(String(value)).space(); } @@ -217,27 +223,33 @@ export class SqlBuilder { if (builders.length === 0) { return this; } + builders .flatMap((builder, i) => { if (i === 0) { return [builder]; } + return [separator.space(), builder]; }) .forEach((builder) => this.addBuilder(builder)); + return this; } addOperator(op: BinaryOperator) { const unknownOp = op as unknown; + if (!OPERATORS.includes(op as any)) { throw new Error(`Invalid operator: ${op}`); } + return this.add(op); } addFn(fn: Fn) { const [_, name, ...args] = fn; + return this.add(name) .openParen() .addInterleaved( @@ -249,12 +261,14 @@ export class SqlBuilder { addAs(as: As) { const [_, exp, alias] = as; - // TODO validate that alias is a valid alias + + // TODO: validate that alias is a valid alias return this.addBuilder(compileExp(exp)).space().add(`as "${alias}" `); } addOp(op: Op) { const [_, opName, ...exps] = op; + return this.openParen() .addInterleaved( exps.map((exp) => compileExp(exp)), @@ -294,15 +308,19 @@ export class SqlBuilder { */ space() { const lastPart = this.parts[this.parts.length - 1]; + if (!lastPart) { return this.add(' '); } + if (typeof lastPart !== 'string') { return this; } + if (lastPart.endsWith(' ')) { return this; } + return this.add(' '); } @@ -315,6 +333,7 @@ export class SqlBuilder { for (const builder of builders) { this.addBuilder(builder); } + return this; } @@ -348,6 +367,7 @@ export class SqlBuilder { if (limit === undefined) { return this; } + return this.add(`limit ${limit} `); } @@ -364,6 +384,7 @@ export class SqlBuilder { const own = cond.ownColumn.aliasQuoted(); const other = cond.otherColumn.aliasQuoted(); const op = cond.op; + return new SqlBuilder() .add(own) .space() @@ -382,6 +403,7 @@ export class SqlBuilder { for (const join of sorted) { this.leftJoin(join); } + return this; } @@ -389,11 +411,13 @@ export class SqlBuilder { if (offset === undefined) { return this; } + return this.add(`offset ${offset} `); } build() { const params: any[] = []; + return { sql: this.parts .map((part) => { @@ -419,19 +443,57 @@ export class SqlBuilder { const expressions = Object.entries(where) .map(([column, op]): Exp | undefined => { const quotedColumn = table.column(column).aliasQuoted(); + if (op === null) { return ['op', 'is', quotedColumn, ['const', null]]; } + if (isPrimitive(op)) { const exp: Exp = ['op', '=', quotedColumn, ['param', op]]; return exp; } + if (isRefOp(op)) { return undefined; } + + if (isQueryParameter(op)) { + assertPrimitive( + op.value, + `Expected value ${JSON.stringify(op.value)} to be a primitive in ${JSON.stringify(op)}`, + ); + + const exp: Exp = [ + 'op', + '=', + quotedColumn, + ['param', op.value], + ]; + + return exp; + } + if (typeof op === 'object' && Object.keys(op).length === 1) { const [opName, value] = Object.entries(op)[0]; + assertOp(opName); + + if (isQueryParameter(value)) { + assertPrimitive( + value.value, + `Expected value ${JSON.stringify(value.value)} to be a primitive in ${JSON.stringify(op)}`, + ); + + const exp: Exp = [ + 'op', + opName, + quotedColumn, + ['param', value.value], + ]; + + return exp; + } + assertPrimitive( value, `Expected value ${JSON.stringify(value)} to be a primitive in ${JSON.stringify(op)}`, @@ -449,6 +511,7 @@ export class SqlBuilder { if (expressions.length === 0) { return this; } + return this.addInterleaved(expressions, SqlBuilder.and()).space(); } @@ -458,9 +521,11 @@ export class SqlBuilder { const expressions = where .map((w) => new SqlBuilder().expressionFromWhere(w)) .filter((b) => !b.isEmpty()); + if (expressions.length === 0) { return this; } + return this.add('where ').addInterleaved(expressions, SqlBuilder.and()); } } diff --git a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryQuery.ts b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryQuery.ts index 31935e7e..05a3ecbb 100644 --- a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryQuery.ts +++ b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryQuery.ts @@ -13,6 +13,7 @@ interface ArbitraryQuery { allTablesRowsMap: AllTablesRowsMap; cardinality: Cardinality; validWhere: boolean; + parameterize: boolean; } export function arbitraryQuery({ @@ -20,6 +21,7 @@ export function arbitraryQuery({ allTablesRowsMap, cardinality, validWhere, + parameterize, }: ArbitraryQuery): fc.Arbitrary { return fc .constantFrom( @@ -36,6 +38,7 @@ export function arbitraryQuery({ allTablesRowsMap, tableName, validWhere, + parameterize, }), limit: arbitraryLimit(), cardinality: arbitraryCardinality(cardinality), diff --git a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhere.ts b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhere.ts index fc663cad..ae3efdc0 100644 --- a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhere.ts +++ b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhere.ts @@ -2,7 +2,6 @@ import { fc } from '@fast-check/vitest'; import { AnyDB, Schema, Where } from '@synthql/queries'; import { arbitraryWhereValue } from './arbitraryWhereValue'; import { AllTablesRowsMap } from '../getTableRowsByTableName'; -import { checkIfDateTimeColumn } from '../checkIfDateTimeColumn'; import { getTableWhereableColumns } from '../getTableWhereableColumns'; export function arbitraryWhere({ @@ -10,31 +9,16 @@ export function arbitraryWhere({ allTablesRowsMap, tableName, validWhere, + parameterize, }: { schema: Schema; allTablesRowsMap: AllTablesRowsMap; tableName: string; validWhere: boolean; + parameterize: boolean; }): fc.Arbitrary> { return fc .constantFrom(...getTableWhereableColumns(schema, tableName)) - .filter( - (value) => - // TODO: We should remove this check once we resolve the `date-time` issue - // When there's a mismatch between the timezone of the data stored in the - // database and the timezone of the machine that is running the database, - // the value returned from the database is adjusted to match the timezone - // of the server. But this means when we try to find rows matching the data - // received, we don't get the matching rows returned. So for now, we're using - // the logic below to exempt columns that of the timestampz type from being - // used in this property test - - !checkIfDateTimeColumn({ - schema, - table: tableName, - column: value, - }), - ) .chain((columnName) => { return fc.dictionary( fc.constant(columnName), @@ -44,6 +28,7 @@ export function arbitraryWhere({ tableName, columnName, validWhere, + parameterize, }), { depthIdentifier: '0', diff --git a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhereValue.ts b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhereValue.ts index 3836ec5c..7eda3704 100644 --- a/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhereValue.ts +++ b/packages/backend/src/tests/propertyBased/arbitraries/arbitraryWhereValue.ts @@ -1,6 +1,6 @@ import { fc } from '@fast-check/vitest'; import { AllTablesRowsMap } from '../getTableRowsByTableName'; -import { Schema } from '@synthql/queries'; +import { param, Schema } from '@synthql/queries'; import { getTableDef } from '../getTableDef'; import { getColumnDef } from '../getColumnDef'; import { getColumnPgType } from '../getColumnPgType'; @@ -11,12 +11,14 @@ export function arbitraryWhereValue({ tableName, columnName, validWhere, + parameterize, }: { schema: Schema; allTablesRowsMap: AllTablesRowsMap; tableName: string; columnName: string; validWhere: boolean; + parameterize: boolean; }): fc.Arbitrary { const tableRows = allTablesRowsMap.get(tableName); @@ -30,7 +32,9 @@ export function arbitraryWhereValue({ const columnValuesFromSet = Array.from(new Set(columnValues)); if (validWhere) { - return fc.constantFrom(...columnValuesFromSet); + return fc + .constantFrom(...columnValuesFromSet) + .map((value) => parameterizeValue(parameterize, value)); } else { const tableDef = getTableDef(schema, tableName); @@ -52,56 +56,65 @@ export function arbitraryWhereValue({ min: 1, max: 32767, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.int4') { return fc .integer({ min: 1, max: 2147483647, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.int8') { return fc .bigInt({ min: 2n, max: 52n, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.numeric') { return fc .stringMatching(/^[0-9]{0,131072}\.[0-9]{1,16383}$/, { size: 'xsmall', }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.bool') { return fc .boolean() - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.text') { return fc .string({ minLength: 1, maxLength: 10, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.tsvector') { return fc .string({ minLength: 1, maxLength: 10, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.bpchar') { return fc .string({ minLength: 1, maxLength: 19, }) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else if (columnPgType === 'pg_catalog.bytea') { return fc .constant(Buffer.from('pg_catalog.bytea', 'hex')) - .filter((value) => !columnValuesFromSet.includes(value)); + .filter((value) => !columnValuesFromSet.includes(value)) + .map((value) => parameterizeValue(parameterize, value)); } else { return fc.constant(undefined); } @@ -110,3 +123,10 @@ export function arbitraryWhereValue({ return fc.constant(undefined); } } + +function parameterizeValue( + parameterize: boolean, + value: T, +): T | ReturnType { + return parameterize ? param(value) : value; +} diff --git a/packages/backend/src/tests/propertyBased/properties/cardinalityMany.test.ts b/packages/backend/src/tests/propertyBased/properties/cardinalityMany.test.ts index ecdd0166..dcf05e2c 100644 --- a/packages/backend/src/tests/propertyBased/properties/cardinalityMany.test.ts +++ b/packages/backend/src/tests/propertyBased/properties/cardinalityMany.test.ts @@ -12,6 +12,15 @@ describe('cardinalityMany', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'many', validWhere: true, + parameterize: false, + }); + + const validAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'many', + validWhere: true, + parameterize: true, }); const invalidWhereArbitraryQuery = arbitraryQuery({ @@ -19,47 +28,59 @@ describe('cardinalityMany', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'many', validWhere: false, + parameterize: false, + }); + + const invalidAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'many', + validWhere: false, + parameterize: true, }); - it.prop([validWhereArbitraryQuery], { verbose: 2 })( - 'Valid where query should return possibly empty array', - async (query) => { - const typedQuery = query as Query; + it.prop( + [validWhereArbitraryQuery, validAndParameterizedWhereArbitraryQuery], + { verbose: 2 }, + )('Valid where query should return possibly empty array', async (query) => { + const typedQuery = query as Query; - const queryResult = await queryEngine.executeAndWait(typedQuery); + const queryResult = await queryEngine.executeAndWait(typedQuery); - const result = queryResult as any; + const result = queryResult as any; - expect(Array.isArray(result)).toEqual(true); + expect(Array.isArray(result)).toEqual(true); - expect(result.length).toBeLessThanOrEqual(Number(query.limit)); + expect(result.length).toBeLessThanOrEqual(Number(query.limit)); - const expectedKeys = Object.entries(query.select).flatMap( - ([key, selected]) => { - return selected ? [key] : []; - }, - ); + const expectedKeys = Object.entries(query.select).flatMap( + ([key, selected]) => { + return selected ? [key] : []; + }, + ); - for (const item of result) { - const actualKeys = Object.keys(item); + for (const item of result) { + const actualKeys = Object.keys(item); - expect(actualKeys).to.containSubset(expectedKeys); - } - }, - ); + expect(actualKeys).to.containSubset(expectedKeys); + } + }); - it.skip.prop([invalidWhereArbitraryQuery], { verbose: 2 })( - 'Invalid where query should return empty array', - async (query) => { - const typedQuery = query as Query; + it.skip.prop( + [ + invalidWhereArbitraryQuery, + invalidAndParameterizedWhereArbitraryQuery, + ], + { verbose: 2 }, + )('Invalid where query should return empty array', async (query) => { + const typedQuery = query as Query; - const queryResult = await queryEngine.executeAndWait(typedQuery); + const queryResult = await queryEngine.executeAndWait(typedQuery); - const result = queryResult as any; + const result = queryResult as any; - expect(Array.isArray(result)).toEqual(true); + expect(Array.isArray(result)).toEqual(true); - expect(result).toEqual([]); - }, - ); + expect(result).toEqual([]); + }); }); diff --git a/packages/backend/src/tests/propertyBased/properties/cardinalityMaybe.test.ts b/packages/backend/src/tests/propertyBased/properties/cardinalityMaybe.test.ts index ac59bcc2..44bfd014 100644 --- a/packages/backend/src/tests/propertyBased/properties/cardinalityMaybe.test.ts +++ b/packages/backend/src/tests/propertyBased/properties/cardinalityMaybe.test.ts @@ -12,6 +12,15 @@ describe('cardinalityMaybe', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'maybe', validWhere: true, + parameterize: false, + }); + + const validAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'maybe', + validWhere: true, + parameterize: true, }); const invalidWhereArbitraryQuery = arbitraryQuery({ @@ -19,9 +28,21 @@ describe('cardinalityMaybe', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'maybe', validWhere: false, + parameterize: false, }); - it.prop([validWhereArbitraryQuery], { verbose: 2 })( + const invalidAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'maybe', + validWhere: false, + parameterize: true, + }); + + it.prop( + [validWhereArbitraryQuery, validAndParameterizedWhereArbitraryQuery], + { verbose: 2 }, + )( 'Valid where query should return a possibly null, non-array, TS object result', async (query) => { const typedQuery = query as Query; @@ -46,16 +67,19 @@ describe('cardinalityMaybe', async () => { }, ); - it.skip.prop([invalidWhereArbitraryQuery], { verbose: 2 })( - 'Invalid where query should return null', - async (query) => { - const typedQuery = query as Query; + it.skip.prop( + [ + invalidWhereArbitraryQuery, + invalidAndParameterizedWhereArbitraryQuery, + ], + { verbose: 2 }, + )('Invalid where query should return null', async (query) => { + const typedQuery = query as Query; - const queryResult = await queryEngine.executeAndWait(typedQuery); + const queryResult = await queryEngine.executeAndWait(typedQuery); - const result = queryResult as any; + const result = queryResult as any; - expect(result).toEqual(null); - }, - ); + expect(result).toEqual(null); + }); }); diff --git a/packages/backend/src/tests/propertyBased/properties/cardinalityOne.test.ts b/packages/backend/src/tests/propertyBased/properties/cardinalityOne.test.ts index 867a8160..15fb7b0f 100644 --- a/packages/backend/src/tests/propertyBased/properties/cardinalityOne.test.ts +++ b/packages/backend/src/tests/propertyBased/properties/cardinalityOne.test.ts @@ -13,6 +13,15 @@ describe('cardinalityOne', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'one', validWhere: true, + parameterize: false, + }); + + const validAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'one', + validWhere: true, + parameterize: true, }); const invalidWhereArbitraryQuery = arbitraryQuery({ @@ -20,9 +29,21 @@ describe('cardinalityOne', async () => { allTablesRowsMap: await getTableRowsByTableName(pool, schema), cardinality: 'one', validWhere: false, + parameterize: false, + }); + + const invalidAndParameterizedWhereArbitraryQuery = arbitraryQuery({ + schema, + allTablesRowsMap: await getTableRowsByTableName(pool, schema), + cardinality: 'one', + validWhere: false, + parameterize: true, }); - it.prop([validWhereArbitraryQuery], { verbose: 2 })( + it.prop( + [validWhereArbitraryQuery, validAndParameterizedWhereArbitraryQuery], + { verbose: 2 }, + )( 'Valid where query should return a non-null, non-array, TS object result', async (query) => { const typedQuery = query as Query; @@ -49,7 +70,13 @@ describe('cardinalityOne', async () => { }, ); - it.skip.prop([invalidWhereArbitraryQuery], { verbose: 2 })( + it.skip.prop( + [ + invalidWhereArbitraryQuery, + invalidAndParameterizedWhereArbitraryQuery, + ], + { verbose: 2 }, + )( 'Invalid where query should throw expected cardinality error', async (query) => { try { diff --git a/packages/backend/src/tests/queryEngine.ts b/packages/backend/src/tests/queryEngine.ts index 81019625..887dc88d 100644 --- a/packages/backend/src/tests/queryEngine.ts +++ b/packages/backend/src/tests/queryEngine.ts @@ -13,4 +13,5 @@ export const pool = new Pool({ export const queryEngine = new QueryEngine({ pool, schema: 'public', + dangerouslyAllowUnregisteredQueries: true, }); diff --git a/packages/docs/docs/200-security.md b/packages/docs/docs/200-security.md index 08d96067..2477902e 100644 --- a/packages/docs/docs/200-security.md +++ b/packages/docs/docs/200-security.md @@ -18,15 +18,15 @@ const users = from('users').columns('id', 'name', 'email'); const queryEngine = new QueryEngine(opts); -queryEngine.registerQueries(users); +queryEngine.registerQueries([users]); ``` What this means is that the `QueryEngine` will only allow queries on the `users` table and will allow any subset of the `id`, `name` and `email` columns to be selected. -This behaviour can be disabled with the `allowUnknownQueries` option. +This behaviour can be disabled with the `dangerouslyAllowUnregisteredQueries` option. ```ts -const queryEngine = new QueryEngine({..., allowUnknownQueries:true}); +const queryEngine = new QueryEngine({ ..., dangerouslyAllowUnregisteredQueries: true }); ``` ## Restricting access to tables and columns @@ -119,5 +119,5 @@ const queryEngine = new QueryEngine({ middlewares: [restrictOrdersByUser], }); -queryEngine.registerQueries(orders); +queryEngine.registerQueries([orders]); ``` diff --git a/packages/docs/static/reference/assets/navigation.js b/packages/docs/static/reference/assets/navigation.js index abb0d21d..6707037e 100644 --- a/packages/docs/static/reference/assets/navigation.js +++ b/packages/docs/static/reference/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA5WXbW/bIBCA/4s/p+vard2Wb2kaad2mNkuiTVNVVRRfGlQCDuAq1rT/Phk78Qv4oF/DwwMcx+V8/zcxsDfJOHki9AVEeqoVTUZJRswmGSdbmeYc9Gk9+KgVfbcxW56Mkhcm0mR8PkrohvFUgUjG90fZzxxUMRPPTEAjo5xo3ZO1wK747Pzzv9HRtyyE2ez4TCmpcGGbxIythedKZrqxMmFArQkd3qmd0IvDxWVfPudExFlLEtNRyTlQ84No0wjXuaCGSdH1tdCu8fJjR7jNpAa7eNjYsI7yoS3lzJ88lDNP4rRmbohIOagT2GcKtPZbauixhqJTkSogBmbVrDo7vlYu39F9yyAKNCKHcwnYG/xQJRF9olvYm6GDmCLzad0pvZXef/l0dtG+E3fGAnY5tDMweq16ZmjJKszY6dxrOq46NBm9ICaMkjoDOnA9zXj05TyDAEUM+Dbd0x1QdIu7HBSDgTdRD0Zvbip5vhXXsPbWpbbtSGJ16QiVBRGUYeAvo15xMydqiTeYUeGSbmBLgrIKw0Qr8sQhJpQHEJNNRHF91X9ZbYkFQg9oIopeSfeKPKXc67IbD7gsE+vqRGtY58bKMV4xQVRxl2G6AxPrKt+idEqpz1iRIe+UqJQJwplBL6SFBY02w1GZJeI8qyJDb7eh4ny/CM8jhBYLGW8E5XmK2mokZPommcDzpCJCnuDDinpVVbtHFNmCcf+2Hd2RjPIuQOfc+X92pBUWMi5gjYfNAiHLEspuFNNURMgTLEVRdej3BhRqsUCUZcpJrsOuCgtWoJvbyeLP4918tpis7hbLRvtKFCtP1qtDPb7r/9DpqST3dSJtG5Uc+VRgeihn/bY+j6p7STZk9KRaR5SVi4U0FkIku6GPobbEQidnaMemgAz1k3YoulurG9mpFPYHX5/RCLsw2gFVpL2m7+DUNEd5AENZrAe22+Rwo+6y/YB2MrjexVzJV5b688/Z8gFGk28wDj6xgyNq/RZxD/bkViPONdS7wJ0NFyVzbgx3elPMvoGH/wmqzOxOEgAA" \ No newline at end of file +window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE52X204bMRBA/2WfaSlpoW3eIESCtgKaoFYVQsh4J6yL493YXpSo6r9Xe8lefBmbvmaPz9ie8cS++5No2OpkmjwS+gwiPVSSJgdJQXSWTJN1npYc1GH78UFJ+jbTa54cJM9MpMl0cpDQjPFUgkimd53sewlyNxdPTEAvo5woZcgG4Fh8NPn096DzLXdCZxs+lzKXuHBIYsZB4BuZF6q3MqFBrgj1z7QeYOzD8Ykpv+FExFkrEtPRnHOg+htRuheuSkE1y8XYN0DHxpMPI+G6yBXUwcPGnrWU90MpZ+7ioZw5CmcwMiMi5SDfwLaQoJTb0kIPLRRdilQC0TBvRrXVcdG4XEt3hUEU6I7s1yVgq/FFVUT0iq5gq30L0bvCpbWHGJHeff54dDzMiT1iAZsShhUYHasdGQrZbDO2OjtNXVTfYDRBTGiZqwKoJz399+jkPIEASTS4Jm3o9ig6xU0JkoHnTLQfoyc3y3m5Fuewcvaloa0jsb7UQVVDBKkZuNuoU9yPiQrxCjMqXMATUxokpHVHs4raI3cPe32gszzd/WewamggYMmJfO2yzDFYiCXNYE2C1gbDRLfkkUNMHe5BTHYqdk3uzdY0FHVQqAudit35WcB0fhZhMf5dnSLHv6rTVW9DwFUzsa7R3vt19s5bxjMmiNxdF5huz8S6qraYW/9qLmNDhrwzIlMmCGcaTcgACxqDBRdXbQ21gBVIEBRNsYHGmW93RYS0ouJ8PwgvI4Q1FjJeCsrLFLW1SMj0JWcCr8CGCHmCRzbqvDZ3eiLJGrR9N7N0HRnl9dzCLGvknatlVcljlBUWMi5ghaeiBkKWJVTPGEzTECFPsHFGdc2fGUjUUgNRlhknpQq7GizYLy+vThe/Hq5v5ovT2+vFste+EMmqlRld0+DH/vcT//UlZDZwXNxdOSKsHYso2xu/48i5tSaPqGnOXTf5oY3mHHlqp78fJxdEZSHLnkNUGVGZ98k+egrsQUTGlK9NuZ0mj6qNHuAzOjqBJcJv635z+Lpuh/Jfn5E4+P15HETX770F0FIq9gI8mEl7BKIvqtyEjDWESDYxBVZDb47Qh6sE4ntW15+iH63taZ3lov7B9WLohWMYfcs0ZJ26r2D961vKPRjqycoz3b4V9eoxa26os8fJ/IWl7uNqTXkPo3Xv3QeX2MIRtXqN2IAdtdWLSwXtLHBnz0XJrIzhTmeJ1Wfg/h9DXy+qVRcAAA==" \ No newline at end of file diff --git a/packages/docs/static/reference/assets/search.js b/packages/docs/static/reference/assets/search.js index 7687d576..7a9b6fe3 100644 --- a/packages/docs/static/reference/assets/search.js +++ b/packages/docs/static/reference/assets/search.js @@ -1 +1 @@ -window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file +window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file diff --git a/packages/handler-express/src/createExpressSynthqlHandler.ts b/packages/handler-express/src/createExpressSynthqlHandler.ts index 88fd7dab..78ec9731 100644 --- a/packages/handler-express/src/createExpressSynthqlHandler.ts +++ b/packages/handler-express/src/createExpressSynthqlHandler.ts @@ -1,5 +1,10 @@ -import { collectLast, QueryEngine, SynthqlError } from '@synthql/backend'; import type { Request, Response, RequestHandler } from 'express'; +import { collectLast, QueryEngine, SynthqlError } from '@synthql/backend'; +import { + isRegisteredQueryRequest, + isRegularQueryRequest, + Query, +} from '@synthql/queries'; /** * Create an Express request handler that can handle SynthQL requests. @@ -9,6 +14,7 @@ import type { Request, Response, RequestHandler } from 'express'; * ```typescript * import express from 'express'; * import { createExpressSynthqlHandler } from '@synthql/handler-express'; + * import { queryEngine } from './queryEngine'; * * const app = express(); * app.use(createExpressSynthqlHandler(queryEngine)); @@ -37,6 +43,7 @@ export function createExpressSynthqlHandler( error: e.message, }), ); + res.end(); } else { // Let another layer handle the error @@ -52,7 +59,7 @@ async function executeSynthqlRequest( res: Response, ) { // First try to parse the request body as JSON - const { query, returnLastOnly } = await tryParseRequest(req); + const { body, headers } = await tryParseRequest(req); // We don't do this yet, but eventually we'll want to validate the request // const validatedQuery = await tryValidateSynthqlQuery(query); @@ -60,43 +67,62 @@ async function executeSynthqlRequest( // Execute the query, but just to get the initial generator const resultGenerator = await tryExecuteQuery( queryEngine, - query, - returnLastOnly, + body, + headers.returnLastOnly, ); // Now that we have the generator, we want to iterate over the items // and depending on `returnLastOnly`, we will write the status code // either before, or after iteration - await writeBody(res, query, resultGenerator, returnLastOnly); + await writeResponseBody(res, body, resultGenerator, headers.returnLastOnly); + // End response stream res.end(); } async function tryParseRequest(req: Request) { - const body = req.body; - const returnLastOnly = req.headers['x-return-last-only'] === 'true'; - try { - const query = JSON.parse(body); - - return { query, returnLastOnly }; + return { + body: + typeof req.body === 'string' ? JSON.parse(req.body) : req.body, + headers: { + ...req.headers, + returnLastOnly: req.headers['x-return-last-only'] === 'true', + }, + }; } catch (e) { throw SynthqlError.createJsonParsingError({ error: e, - json: body, + json: req.body, }); } } async function tryExecuteQuery( queryEngine: QueryEngine, - query: any, + queryOrBody: any, returnLastOnly: boolean, ) { - return queryEngine.execute(query, { returnLastOnly }); + if (isRegisteredQueryRequest(queryOrBody)) { + return queryEngine.executeRegisteredQuery( + { + queryId: queryOrBody.queryId, + params: queryOrBody.params, + }, + { + returnLastOnly, + }, + ); + } else if (isRegularQueryRequest(queryOrBody)) { + return queryEngine.execute(queryOrBody.query as Query, { + returnLastOnly, + }); + } else { + return queryEngine.execute(queryOrBody, { returnLastOnly }); + } } -async function writeBody( +async function writeResponseBody( res: Response, query: any, generator: AsyncGenerator, @@ -126,7 +152,7 @@ async function writeBody( // First, wrap the error in a SynthqlError to capture // the fact that it happened during streaming - // The `e` can be of any type, but in case its an error, + // The `e` can be of any type, but in case its an error // we want to preserve the stack trace and any other // information that might be useful for debugging diff --git a/packages/handler-next/src/createNextSynthqlHandler.test.ts b/packages/handler-next/src/createNextSynthqlHandler.test.ts index eb7cb714..3a5da045 100644 --- a/packages/handler-next/src/createNextSynthqlHandler.test.ts +++ b/packages/handler-next/src/createNextSynthqlHandler.test.ts @@ -304,7 +304,7 @@ describe('createNextSynthqlHandler', () => { expect(async () => await handler(newReq)).not.toThrow(); }); - // TODO: FIX:This test does not yet accurately test for the response streaming error + // TODO: FIX: This test does not yet accurately test for the response streaming error test.skip(`Well-formed but invalid query object returns expected response streaming error`, async () => { const q = fromWithVirtualTables('film_rating') .columns('film_id', 'rating') diff --git a/packages/handler-next/src/createNextSynthqlHandler.ts b/packages/handler-next/src/createNextSynthqlHandler.ts index fbc165a0..d15411b5 100644 --- a/packages/handler-next/src/createNextSynthqlHandler.ts +++ b/packages/handler-next/src/createNextSynthqlHandler.ts @@ -1,6 +1,11 @@ -import { ReadableStream } from 'stream/web'; import { NextRequest, NextResponse } from 'next/server'; import { collectLast, QueryEngine, SynthqlError } from '@synthql/backend'; +import { + isRegisteredQueryRequest, + isRegularQueryRequest, + Query, +} from '@synthql/queries'; +import { ReadableStream } from 'stream/web'; export type NextSynthqlHandlerRequest = Pick< NextRequest, @@ -10,6 +15,23 @@ export type NextSynthqlHandler = ( req: NextSynthqlHandlerRequest, ) => Promise; +/** + * Create a Next request handler that can handle SynthQL requests. + * + * Usage: + * + * ```typescript + * import { createNextSynthqlHandler } from '@synthql/handler-next'; + * import { queryEngine } from './queryEngine'; + * + * const nextSynthqlRequestHandler = createNextSynthqlHandler(queryEngine); + * + * export async function POST(request: Request) { + * return await nextSynthqlRequestHandler(request); + * } + * ``` + * + */ export function createNextSynthqlHandler( queryEngine: QueryEngine, ): NextSynthqlHandler { @@ -46,7 +68,7 @@ async function executeSynthqlRequest( req: NextSynthqlHandlerRequest, ): Promise { // First try to parse the request body as JSON - const { query, returnLastOnly } = await tryParseRequest(req); + const { body, headers } = await tryParseRequest(req); // We don't do this yet, but eventually we'll want to validate the request // const validatedQuery = await tryValidateSynthqlQuery(query); @@ -54,25 +76,32 @@ async function executeSynthqlRequest( // Execute the query, but just to get the initial generator const resultGenerator = await tryExecuteQuery( queryEngine, - query, - returnLastOnly, + body, + headers.returnLastOnly, ); // Now that we have the generator, we want to iterate over // the items and depending on `returnLastOnly`, we will // write the status code either before, or after iteration - return await writeBody(query, resultGenerator, returnLastOnly); + return await writeResponseBody( + body, + resultGenerator, + headers.returnLastOnly, + ); } async function tryParseRequest(req: NextSynthqlHandlerRequest) { const body = await req.text(); const requestHeaders = Object.fromEntries(req.headers); - const returnLastOnly = requestHeaders['x-return-last-only'] === 'true'; try { - const query = JSON.parse(body); - - return { query, returnLastOnly }; + return { + body: JSON.parse(body), + headers: { + ...requestHeaders, + returnLastOnly: requestHeaders['x-return-last-only'] === 'true', + }, + }; } catch (e) { throw SynthqlError.createJsonParsingError({ error: e, @@ -83,13 +112,29 @@ async function tryParseRequest(req: NextSynthqlHandlerRequest) { async function tryExecuteQuery( queryEngine: QueryEngine, - query: any, + queryOrBody: any, returnLastOnly: boolean, ) { - return queryEngine.execute(query, { returnLastOnly }); + if (isRegisteredQueryRequest(queryOrBody)) { + return queryEngine.executeRegisteredQuery( + { + queryId: queryOrBody.queryId, + params: queryOrBody.params, + }, + { + returnLastOnly, + }, + ); + } else if (isRegularQueryRequest(queryOrBody)) { + return queryEngine.execute(queryOrBody.query as Query, { + returnLastOnly, + }); + } else { + return queryEngine.execute(queryOrBody, { returnLastOnly }); + } } -async function writeBody( +async function writeResponseBody( query: any, generator: AsyncGenerator, returnLastOnly: boolean, @@ -131,14 +176,14 @@ async function writeBody( // we want to preserve the stack trace and any other // information that might be useful for debugging + // We need to catch errors here and write them to the streaming response + // We can't throw them because that would break the stream + const error = SynthqlError.createResponseStreamingError({ error: e, query, }); - // We need to catch errors here and write them to the streaming response - // We can't throw them because that would break the stream - return new NextResponse( JSON.stringify({ type: error.type, diff --git a/packages/handler-next/src/tests/queryEngine.ts b/packages/handler-next/src/tests/queryEngine.ts index 71b38dc4..50dd17b3 100644 --- a/packages/handler-next/src/tests/queryEngine.ts +++ b/packages/handler-next/src/tests/queryEngine.ts @@ -9,4 +9,5 @@ export const pool = new Pool({ export const queryEngine = new QueryEngine({ pool, schema: 'public', + dangerouslyAllowUnregisteredQueries: true, }); diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 2581be8d..9d46409f 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -3,10 +3,12 @@ export * from './types/AnyQuery'; export * from './types/BinaryOp'; export * from './types/Cardinality'; export * from './types/Column'; +export * from './types/ColumnReference'; export * from './types/ColumnValue'; export * from './types/Include'; export * from './types/JoinOp'; export * from './types/QueryParameter'; +export * from './types/QueryRequest'; export * from './types/QueryResult'; export * from './types/RefOp'; export * from './types/Schema'; @@ -14,8 +16,12 @@ export * from './types/Select'; export * from './types/Table'; export * from './types/Where'; export * from './types/WhereClause'; -export * from './validators/isQueryParam'; +export * from './validators/isQueryParameter'; export * from './validators/isRefOp'; +export * from './validators/isRegisteredQueryRequest'; +export * from './validators/isRegularQueryRequest'; +export * from './util/hashQuery'; +export * from './util/iterateRecursively'; export { col } from './col'; export { param } from './param'; export { query } from './query'; diff --git a/packages/queries/src/param.ts b/packages/queries/src/param.ts index 5af649e4..facba005 100644 --- a/packages/queries/src/param.ts +++ b/packages/queries/src/param.ts @@ -1,12 +1,11 @@ import { QueryParameter } from './types/QueryParameter'; export function param( - id: string, - value: TValue, + value?: TValue, ): QueryParameter { return { type: 'synthql::parameter', - id, value, + id: String(value), }; } diff --git a/packages/queries/src/query.test.ts b/packages/queries/src/query.test.ts index 882b9b98..89845def 100644 --- a/packages/queries/src/query.test.ts +++ b/packages/queries/src/query.test.ts @@ -1,5 +1,5 @@ import { describe, test } from 'vitest'; -import { Query, QueryResult, Table, col } from '.'; +import { Query, QueryResult, Table, col, param } from '.'; import { DB, from } from './generated'; describe('queries', () => { @@ -9,17 +9,6 @@ describe('queries', () => { return {} as any; } - test('Find one actor with `name()`', () => { - const q = from('actor') - .columns('actor_id', 'first_name') - .name('findActor') - .one(); - - const result = fakeQueryResult(q); - - result satisfies { actor_id: number; first_name: string }; - }); - test('Find one actor with `columns()`', () => { const q = from('actor').columns('actor_id', 'first_name').one(); @@ -54,6 +43,31 @@ describe('queries', () => { }; }); + test('Find one actor with `name()`', () => { + const q = from('actor') + .columns('actor_id', 'first_name') + .name('findActor') + .one(); + + const result = fakeQueryResult(q); + + result satisfies { actor_id: number; first_name: string }; + }); + + test('Find one actor with `param()`', () => { + const q = from('actor') + .columns('actor_id', 'first_name') + .where({ + actor_id: param(), + first_name: param(), + }) + .one(); + + const result = fakeQueryResult(q); + + result satisfies { actor_id: number; first_name: string }; + }); + test('Find many actors', () => { const q = from('actor').columns('actor_id', 'first_name').many(); diff --git a/packages/queries/src/query.ts b/packages/queries/src/query.ts index c561cf58..4e92b0b3 100644 --- a/packages/queries/src/query.ts +++ b/packages/queries/src/query.ts @@ -9,6 +9,8 @@ import { getTableSelectableColumns } from './schema/getTableSelectableColumns'; import { getTablePrimaryKeyColumns } from './schema/getTablePrimaryKeyColumns'; import { validateNestedQueriesHaveAValidRefOp } from './validators/validateNestedQueriesHaveAValidRefOp'; import { hashQuery } from './util/hashQuery'; +import { iterateRecursively } from './util/iterateRecursively'; +import { isQueryParameter } from './validators/isQueryParameter'; export class QueryBuilder< DB, @@ -61,7 +63,7 @@ export class QueryBuilder< hash: string; name?: string; } { - return { + const query = { from: this._from, where: this._where, select: this._select, @@ -85,6 +87,14 @@ export class QueryBuilder< }), name: this._name, }; + + iterateRecursively(query, (x, path) => { + if (isQueryParameter(x)) { + x.id = path.join('.'); + } + }); + + return query; } /** diff --git a/packages/queries/src/types/AnyQuery.ts b/packages/queries/src/types/AnyQuery.ts index ae4de94d..14c97fd4 100644 --- a/packages/queries/src/types/AnyQuery.ts +++ b/packages/queries/src/types/AnyQuery.ts @@ -1,3 +1,4 @@ +import { Column } from './Column'; import { Table } from './Table'; import { Query } from './types'; @@ -16,4 +17,5 @@ export type AnyTableDef = { }; export type AnyDB = Record; export type AnyTable = Table; +export type AnyColumn = Column; export type AnyQuery = Query; diff --git a/packages/queries/src/types/BinaryOp.ts b/packages/queries/src/types/BinaryOp.ts index 0f0918f8..3c8c0b7c 100644 --- a/packages/queries/src/types/BinaryOp.ts +++ b/packages/queries/src/types/BinaryOp.ts @@ -1,6 +1,7 @@ import { Table } from './Table'; import { Column } from './Column'; import { ColumnValue } from './ColumnValue'; +import { QueryParameter } from './QueryParameter'; import { RefOp } from './RefOp'; export const BINARY_OPERATORS = [ @@ -68,7 +69,6 @@ export type BinaryOperator = (typeof BINARY_OPERATORS)[number]; * [operator]: value * } * ``` - * */ export type BinaryOp< DB, @@ -78,5 +78,6 @@ export type BinaryOp< [op in BinaryOperator | '= any']?: | ColumnValue | Array> + | QueryParameter> | RefOp; }; diff --git a/packages/queries/src/types/QueryParameter.ts b/packages/queries/src/types/QueryParameter.ts index 9ff3a203..bac3387d 100644 --- a/packages/queries/src/types/QueryParameter.ts +++ b/packages/queries/src/types/QueryParameter.ts @@ -1,5 +1,7 @@ +export const SynthqlParameter = 'synthql::parameter'; + export type QueryParameter = { - type: 'synthql::parameter'; - id: string | number; - value: TValue; + type: typeof SynthqlParameter; + value: TValue | undefined; + id: string; }; diff --git a/packages/queries/src/types/QueryRequest.ts b/packages/queries/src/types/QueryRequest.ts new file mode 100644 index 00000000..4c6e1362 --- /dev/null +++ b/packages/queries/src/types/QueryRequest.ts @@ -0,0 +1,22 @@ +import { AnyQuery } from './AnyQuery'; + +export const RegularQuery = 'RegularQuery'; +export const RegisteredQuery = 'RegisteredQuery'; + +export interface RegularQueryRequest { + type: typeof RegularQuery; + query: AnyQuery; +} + +export interface RegisteredQueryRequestBody { + queryId: string; + params: Record; +} + +export interface RegisteredQueryRequest { + type: typeof RegisteredQuery; + queryId: RegisteredQueryRequestBody['queryId']; + params: RegisteredQueryRequestBody['params']; +} + +export type QueryRequest = RegularQueryRequest | RegisteredQueryRequest; diff --git a/packages/queries/src/types/Where.ts b/packages/queries/src/types/Where.ts index d181f1b4..a38b9028 100644 --- a/packages/queries/src/types/Where.ts +++ b/packages/queries/src/types/Where.ts @@ -9,10 +9,20 @@ import { WhereClause } from './WhereClause'; * * Currently, this can be either: * - * 1. `{column: {operator: value}}` example: `{age: {'>': 18}}`, which translates to `age > 18`. - * 1. `{column: value}` which is equivalent to `{column: {'=': value}}`. - * 1. `{column: col('table.column')}` which translates to `column = table.column`. + * 1. `{column: {operator: value}}`, example: `{age: {'>': 18}}`, + * which translates to ` WHERE age > 18`. * + * 1. `{column: value}`, example: `{age: 18}`, which is + * equivalent to `{age: {'=': 18}}` and translates to `WHERE age = 18`. + * + * 1. `{column: col('table.column')}`, example: `{age: col('users.bio')}`, + * which translates to `WHERE age = users.bio`. + * + * 1. `{column: param(value)}`, example: `{age: param(18)}`, + * which also translates to `WHERE age = ?`, and adds some metadata + * that 'marks' your query to be processed as a persisted query + * (after registering it via `QueryEngine.registerQueries()`), + * for even faster query execution. */ export type Where> = { [TColumn in Column]?: WhereClause; diff --git a/packages/queries/src/types/WhereClause.ts b/packages/queries/src/types/WhereClause.ts index e068f7b4..420686bf 100644 --- a/packages/queries/src/types/WhereClause.ts +++ b/packages/queries/src/types/WhereClause.ts @@ -1,14 +1,16 @@ import { Table } from './Table'; import { Column } from './Column'; +import { BinaryOp } from './BinaryOp'; import { ColumnValue } from './ColumnValue'; +import { QueryParameter } from './QueryParameter'; import { RefOp } from './RefOp'; -import { BinaryOp } from './BinaryOp'; export type WhereClause< DB, TTable extends Table, TColumn extends Column, > = - | ColumnValue | BinaryOp + | ColumnValue + | QueryParameter> | RefOp; diff --git a/packages/queries/src/util/hashQuery.ts b/packages/queries/src/util/hashQuery.ts index 44c1a1b2..de396e8c 100644 --- a/packages/queries/src/util/hashQuery.ts +++ b/packages/queries/src/util/hashQuery.ts @@ -1,5 +1,5 @@ import { AnyQuery } from '../types/AnyQuery'; -import { isQueryParameter } from '../validators/isQueryParam'; +import { isQueryParameter } from '../validators/isQueryParameter'; // Copied from: https://github.com/TanStack/query/blob/353e4ad7291645f27de6585e9897b45e46c666fb/packages/query-core/src/utils.ts#L205 /** diff --git a/packages/queries/src/util/iterateRecursively.ts b/packages/queries/src/util/iterateRecursively.ts new file mode 100644 index 00000000..791a00a3 --- /dev/null +++ b/packages/queries/src/util/iterateRecursively.ts @@ -0,0 +1,19 @@ +export function iterateRecursively( + traversable: T, + visitor: (traversable: unknown, path: string[]) => void, + path: string[] = [], +): void { + visitor(traversable, path); + + if ( + traversable === null || + typeof traversable !== 'object' || + traversable instanceof Date + ) { + return; + } + + for (const [key, value] of Object.entries(traversable)) { + iterateRecursively(value, visitor, [...path, key]); + } +} diff --git a/packages/queries/src/validators/isQueryParam.ts b/packages/queries/src/validators/isQueryParam.ts deleted file mode 100644 index 36f7b168..00000000 --- a/packages/queries/src/validators/isQueryParam.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { QueryParameter } from '../types/QueryParameter'; - -export function isQueryParameter(x: any): x is QueryParameter { - return x !== null && x !== undefined && x?.type === 'synthql::parameter'; -} diff --git a/packages/queries/src/validators/isQueryParameter.ts b/packages/queries/src/validators/isQueryParameter.ts new file mode 100644 index 00000000..95d5987e --- /dev/null +++ b/packages/queries/src/validators/isQueryParameter.ts @@ -0,0 +1,5 @@ +import { QueryParameter, SynthqlParameter } from '../types/QueryParameter'; + +export function isQueryParameter(x: any): x is QueryParameter { + return x !== null && x !== undefined && x?.type === SynthqlParameter; +} diff --git a/packages/queries/src/validators/isRegisteredQueryRequest.ts b/packages/queries/src/validators/isRegisteredQueryRequest.ts new file mode 100644 index 00000000..c69bd238 --- /dev/null +++ b/packages/queries/src/validators/isRegisteredQueryRequest.ts @@ -0,0 +1,5 @@ +import { RegisteredQuery, RegisteredQueryRequest } from '../types/QueryRequest'; + +export function isRegisteredQueryRequest(x: any): x is RegisteredQueryRequest { + return x !== null && x !== undefined && x?.type === RegisteredQuery; +} diff --git a/packages/queries/src/validators/isRegularQueryRequest.ts b/packages/queries/src/validators/isRegularQueryRequest.ts new file mode 100644 index 00000000..990ef0a5 --- /dev/null +++ b/packages/queries/src/validators/isRegularQueryRequest.ts @@ -0,0 +1,5 @@ +import { RegularQuery, RegularQueryRequest } from '../types/QueryRequest'; + +export function isRegularQueryRequest(x: any): x is RegularQueryRequest { + return x !== null && x !== undefined && x?.type === RegularQuery; +} diff --git a/packages/react/src/createBody.test.ts b/packages/react/src/createBody.test.ts new file mode 100644 index 00000000..58916339 --- /dev/null +++ b/packages/react/src/createBody.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { col, param, RegisteredQuery, RegularQuery } from '@synthql/queries'; +import { createBody } from './createBody'; +import { from } from './test/generated'; + +const params = { + 'where.actor_id': 2, + 'where.film_id': 47, + 'include.film.where.language_id': 3, + 'include.film.include.language.where.last_update': '2022-02-15 10:02:19+00', +}; + +describe('createBody', () => { + it('Parameterized query returns a RegisteredQueryRequest', async () => { + const request = createBody(findFilmActor(false).maybe()); + + expect(request.type).toEqual(RegisteredQuery); + }); + + it('Regular query without name() returns a RegularQueryRequest', async () => { + const request = createBody(findFilmActor(true).maybe()); + + expect(request.type).toEqual(RegularQuery); + }); + + it('Regular query with name() returns a RegisteredQueryRequest', async () => { + const request = createBody( + findFilmActor(true) + .name('findFilmActorWithRegularQueryWithName') + .maybe(), + ); + + expect(request.type).toEqual(RegisteredQuery); + }); + + it('Regular query with empty string name() returns a RegularQueryRequest', async () => { + const request = createBody(findFilmActor(true).name('').maybe()); + + expect(request.type).toEqual(RegularQuery); + }); +}); + +function findFilmActor(regular: boolean) { + return from('film_actor') + .where({ + actor_id: regular + ? params['where.actor_id'] + : param(params['where.actor_id']), + film_id: regular + ? params['where.film_id'] + : param(params['where.film_id']), + }) + .include({ + film: from('film') + .where({ + film_id: col('film_actor.film_id'), + language_id: regular + ? params['include.film.where.language_id'] + : param(params['include.film.where.language_id']), + }) + .include({ + language: from('language') + .where({ + language_id: col('film.language_id'), + last_update: regular + ? params[ + 'include.film.include.language.where.last_update' + ] + : param( + params[ + 'include.film.include.language.where.last_update' + ], + ), + }) + .maybe(), + }) + .maybe(), + }); +} diff --git a/packages/react/src/createBody.ts b/packages/react/src/createBody.ts new file mode 100644 index 00000000..309ffcda --- /dev/null +++ b/packages/react/src/createBody.ts @@ -0,0 +1,57 @@ +import { + AnyQuery, + isQueryParameter, + iterateRecursively, + QueryRequest, +} from '@synthql/queries'; + +export function createBody(query: AnyQuery): QueryRequest { + const params: Record = {}; + + iterateRecursively(query, (x, _) => { + if (isQueryParameter(x)) { + if (x.value === undefined) { + throw new Error( + [ + 'Missing value error!', + '', + 'No value passed for the parameter:', + '', + JSON.stringify(x.id, null, 2), + '', + ].join('\n'), + ); + } + + params[x.id] = x.value; + } + }); + + if (query.name || Object.keys(params).length > 0) { + if (!query.hash) { + throw new Error( + [ + 'Missing hash error!', + '', + 'The query:', + '', + JSON.stringify(query, null, 2), + '', + 'is missing its `hash` property', + '', + ].join('\n'), + ); + } + + return { + type: 'RegisteredQuery', + queryId: query.hash, + params, + }; + } else { + return { + type: 'RegularQuery', + query, + }; + } +} diff --git a/packages/react/src/useSynthql.test.tsx b/packages/react/src/useSynthql.test.tsx index b60b7c61..9b83de39 100644 --- a/packages/react/src/useSynthql.test.tsx +++ b/packages/react/src/useSynthql.test.tsx @@ -45,6 +45,7 @@ describe('useSynthql', () => { beforeAll(async () => { const queryEngine = new QueryEngine({ url: 'postgres://postgres:postgres@localhost:5432/postgres', + dangerouslyAllowUnregisteredQueries: true, }); pagilaServer = await createPagilaServer({ queryEngine }); diff --git a/packages/react/src/useSynthql.ts b/packages/react/src/useSynthql.ts index c99c8a7e..084ad43b 100644 --- a/packages/react/src/useSynthql.ts +++ b/packages/react/src/useSynthql.ts @@ -1,8 +1,9 @@ +import { QueryOptions, UseQueryResult } from '@tanstack/react-query'; import { Query, QueryResult, Table } from '@synthql/queries'; import { useSynthqlContext } from './SynthqlProvider'; import { useAyncGeneratorQuery } from './useAsyncGeneratorQuery'; import { synthqlQueryKey } from './synthqlQueryKey'; -import { QueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { createBody } from './createBody'; import { fetchJsonLines } from './fetchJsonLines'; type SynthqlQueryOptions< @@ -25,8 +26,6 @@ export function useSynthql< ): UseQueryResult> { const { endpoint, requestInit } = useSynthqlContext(); - const enrichedEndpoint = `${endpoint}/${query.name ?? query.from}-${query.hash}`; - const mergedRequestInit: RequestInit = { ...requestInit, ...opts.requestInit, @@ -34,9 +33,10 @@ export function useSynthql< ...requestInit?.headers, 'X-Return-Last-Only': opts.returnLastOnly ? 'true' : 'false', }, - body: JSON.stringify(query), + body: JSON.stringify(createBody(query)), }; + const enrichedEndpoint = `${endpoint}/${query.name ?? query.from}${query.hash ? '-' + query.hash : ''}`; const queryKey = synthqlQueryKey(query, { endpoint: enrichedEndpoint, requestInit: mergedRequestInit, diff --git a/packages/react/src/useSynthqlExamples.test.tsx b/packages/react/src/useSynthqlExamples.test.tsx index 7fc768a4..2956ef33 100644 --- a/packages/react/src/useSynthqlExamples.test.tsx +++ b/packages/react/src/useSynthqlExamples.test.tsx @@ -11,7 +11,7 @@ describe('useSynthql test examples', () => { beforeAll(async () => { echoServer = await createEchoServer((req) => { - return Object.values(req.where?.id.in).map((id) => { + return Object.values(req.query.where?.id.in).map((id) => { return { id, name: 'Bob', age: 1, active: true }; }); });