Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add postgres fragment tagged literal #395

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions packages/postgres/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createClient,
createPool,
fragment,
postgresConnectionString,
sql,
db,
Expand All @@ -16,6 +17,9 @@ describe('@vercel/postgres', () => {
it('exports sql', () => {
expect(typeof sql).toEqual('function');
});
it('exports fragment', () => {
expect(typeof fragment).toEqual('function');
});
it('exports postgresConnectionString', () => {
expect(typeof postgresConnectionString).toEqual('function');
});
Expand Down
1 change: 1 addition & 0 deletions packages/postgres/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Primitive } from './sql-template';

export * from './create-client';
export * from './create-pool';
export { type QueryFragment, fragment } from './sql-template';
export * from './types';
export { postgresConnectionString } from './postgres-connection-string';

Expand Down
48 changes: 47 additions & 1 deletion packages/postgres/src/sql-template.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { VercelPostgresError } from './error';
import { sqlTemplate } from './sql-template';
import { sqlTemplate, fragment } from './sql-template';

const validCases = [
{
Expand All @@ -21,6 +21,38 @@ const validCases = [
input: sqlTemplate`SELECT * FROM users WHERE name = ${'John AND 1=1'}`,
output: ['SELECT * FROM users WHERE name = $1', ['John AND 1=1']],
},
{
input: sqlTemplate`SELECT * FROM users WHERE ${fragment`id = ${123}`}`,
output: ['SELECT * FROM users WHERE id = $1', [123]],
},
{
input: (() => {
const filterOnFoo = Boolean('true');
const filter = filterOnFoo
? fragment`foo = ${123}`
: fragment`bar = ${234}`;
return sqlTemplate`SELECT * FROM users WHERE ${filter}`;
})(),
output: ['SELECT * FROM users WHERE foo = $1', [123]],
},
{
input: (() => {
const sharedValues = fragment`${123}, ${'admin'}`;
return sqlTemplate`INSERT INTO users (id, credits, role) VALUES (1, ${sharedValues}), (2, ${sharedValues})`;
})(),
output: [
'INSERT INTO users (id, credits, role) VALUES (1, $1, $2), (2, $1, $2)',
[123, 'admin'],
],
},
{
input: (() => {
const column = fragment`foo`;
const filter = fragment`${column} = ${123}`;
return sqlTemplate`SELECT ${column} FROM table WHERE ${filter}`;
})(),
output: ['SELECT foo FROM table WHERE foo = $1', [123]],
},
];

describe('sql', () => {
Expand Down Expand Up @@ -49,3 +81,17 @@ describe('sql', () => {
}).toThrow(VercelPostgresError);
});
});

describe('fragment', () => {
it('throws when deliberately not used as a tagged literal to try to make us look dumb', () => {
const likes = 100;
expect(() => {
// @ts-expect-error - intentionally incorrect usage
fragment([`likes > ${likes}`]);
}).toThrow(VercelPostgresError);
expect(() => {
// @ts-expect-error - intentionally incorrect usage
fragment(`likes > ${likes}`, 123);
}).toThrow(VercelPostgresError);
});
});
69 changes: 64 additions & 5 deletions packages/postgres/src/sql-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import { VercelPostgresError } from './error';

export type Primitive = string | number | boolean | undefined | null;

/** An SQL query fragment created by `fragment` tagged literal. */
export interface QueryFragment {
[fragmentSymbol]: true;
strings: TemplateStringsArray;
values: (Primitive | QueryFragment)[];
}

const fragmentSymbol = Symbol('fragment');

export function sqlTemplate(
strings: TemplateStringsArray,
...values: Primitive[]
...values: (Primitive | QueryFragment)[]
): [string, Primitive[]] {
if (!isTemplateStringsArray(strings) || !Array.isArray(values)) {
throw new VercelPostgresError(
Expand All @@ -13,13 +22,63 @@ export function sqlTemplate(
);
}

let result = strings[0] ?? '';
const result: [string, Primitive[]] = ['', []];

processTemplate(result, strings, values);

return result;
}

function processTemplate(
result: [string, Primitive[]],
strings: TemplateStringsArray,
values: (Primitive | QueryFragment)[],
): void {
for (let i = 0; i < strings.length; i++) {
if (i > 0) {
const value = values[i - 1];
const valueIsFragment =
value && typeof value === 'object' && fragmentSymbol in value;

if (valueIsFragment) {
processTemplate(result, value.strings, value.values);
} else {
let valueIndex = result[1].indexOf(value);
if (valueIndex < 0) {
valueIndex = result[1].push(value) - 1;
}
result[0] += `$${valueIndex + 1}`;
}
}

for (let i = 1; i < strings.length; i++) {
result += `$${i}${strings[i] ?? ''}`;
result[0] += strings[i];
}
}

/**
* A template literal tag providing a fragment of an SQL query.
* @example
* ```ts
* const userId = 123;
* const filter = fragment`id = ${userId}`;
* const result = await sql`SELECT * FROM users WHERE ${filter}`;
* // Equivalent to: await `SELECT * FROM users WHERE id = ${userId}`;
* ```
* @returns An SQL query fragment to be used by `sql`
*/
export function fragment(
strings: TemplateStringsArray,
...values: (Primitive | QueryFragment)[]
): QueryFragment {
if (!isTemplateStringsArray(strings) || !Array.isArray(values)) {
throw new VercelPostgresError(
'incorrect_tagged_template_call',
// eslint-disable-next-line no-template-curly-in-string -- showing usage of a template string
"It looks like you tried to call `fragment` as a function. Make sure to use it as a tagged template.\n\tExample: fragment`id = ${id}`, not fragment('id = 1')",
);
}

return [result, values];
return { [fragmentSymbol]: true, strings, values };
}

function isTemplateStringsArray(
Expand Down