Skip to content

Commit

Permalink
Add query events
Browse files Browse the repository at this point in the history
  • Loading branch information
brombal committed Jan 31, 2024
1 parent cfb7554 commit 8497c98
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 29 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ SELECT * FROM users WHERE id = ?
- [INSERT expressions](#INSERT-expressions)
- [IN expressions](#IN-expressions)
- [Joining/concatenating values](#Joiningconcatenating-values)
- [Query Events](#Query-Events)
- [Creating SqlTags for other databases](#Creating-SqlTags-for-other-databases)

---
Expand Down Expand Up @@ -436,6 +437,45 @@ const [rows] = await sql`
// with parameters: [123, 'active']
```

## Query Events

The SqlTag class extends EventEmitter and provides a strongly-typed interface for handling database
query events. This allows you to attach listeners to specific events emitted during the query
lifecycle. For example:

```ts
sql.on('afterQuery', (event) => {
// ...
});
```

Here are the events you can listen to:

### `'beforeQuery'`

Emitted immediately before a query is executed. Use this event to inspect or log the query before it
runs.

The event object has the following properties:

- `queryText` - The query string.
- `params` - The query parameters.

### `'afterQuery'`

Emitted after a query is executed and the result is received. Use this event to inspect or log the
query result and performance.

The event object has the following properties:

- `queryText` - The query string.
- `params` - The query parameters.
- `result` - The query result.
- `info` - Additional information about the query (e.g. column definitions, rows affected, etc).
- `ms` - The query execution time in milliseconds.

> Note that this event is not emitted for cursors.
## Creating SqlTags for other database clients

A `SqlTag` instance is just a thin wrapper around a database client driver. Any database client
Expand Down
24 changes: 14 additions & 10 deletions core/SqlQuery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type SqlTagDriver } from './SqlTagDriver';
import { _driver, SqlTag } from './SqlTag';

export type SqlExpression = SqlQuery<never, never, never>;

Expand All @@ -8,7 +8,7 @@ export class SqlQuery<TResult, TQueryInfo, TCursorOptions> extends Promise<
SqlQueryResult<TResult, TQueryInfo>
> {
constructor(
private driver: SqlTagDriver<any, any>,
private sqlTag: SqlTag<any, any>,
private templateStrings: string[],
private values: any[],
) {
Expand All @@ -22,9 +22,12 @@ export class SqlQuery<TResult, TQueryInfo, TCursorOptions> extends Promise<
| null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
): Promise<TResult1 | TResult2> {
const [text, params] = this.compile();
return this.driver.query(text, params).then(([rows, queryInfo]) => {
return onfulfilled!([rows, queryInfo, text, params]);
const now = performance.now();
const [queryText, params] = this.compile();
this.sqlTag.emit('beforeQuery', { queryText, params });
return this.sqlTag[_driver].query(queryText, params).then(([rows, queryInfo]) => {
this.sqlTag.emit('afterQuery', { queryText, params, rows, queryInfo, ms: performance.now() - now });
return onfulfilled!([rows, queryInfo, queryText, params]);
}, onrejected);
}

Expand All @@ -42,10 +45,10 @@ export class SqlQuery<TResult, TQueryInfo, TCursorOptions> extends Promise<
const [subSql] = value.compile(params);
sql += subSql;
} else {
const serializedValue = this.driver.serializeValue
? this.driver.serializeValue(value)
const serializedValue = this.sqlTag[_driver].serializeValue
? this.sqlTag[_driver].serializeValue(value)
: value;
sql += this.driver.parameterizeValue(serializedValue, params.length);
sql += this.sqlTag[_driver].parameterizeValue(serializedValue, params.length);
params.push(serializedValue);
}
}
Expand All @@ -54,7 +57,8 @@ export class SqlQuery<TResult, TQueryInfo, TCursorOptions> extends Promise<
}

cursor(options?: TCursorOptions): AsyncIterable<TResult> {
const [sql, params] = this.compile();
return this.driver.cursor(sql, params, options);
const [queryText, params] = this.compile();
this.sqlTag.emit('beforeQuery', { queryText, params });
return this.sqlTag[_driver].cursor(queryText, params, options);
}
}
98 changes: 87 additions & 11 deletions core/SqlTag.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,90 @@
import { SqlQuery, type SqlExpression } from './SqlQuery';
import { SqlTagDriver } from './SqlTagDriver';
import { Callable } from './util';
import EventEmitter from 'node:events';

export class SqlTag<TQueryInfo, TCursorOptions> {
constructor(private readonly driver: SqlTagDriver<TQueryInfo, TCursorOptions>) {
/**
* The event types that SqlTag emits.
*/
interface SqlTagEvents<TQueryInfo> {
/**
* Emitted immediately before a query is executed.
*/
beforeQuery: (event: {
/**
* The compiled query text.
*/
queryText: string;

/**
* The query parameters.
*/
params: any[];
}) => void;

/**
* Emitted immediately after a query is executed.
*/
afterQuery: (event: {
/**
* The compiled query text.
*/
queryText: string;

/**
* The query parameters.
*/
params: any[];

/**
* The result rows.
*/
rows: any[];

/**
* The query info.
*/
queryInfo: TQueryInfo;

/**
* The time it took to execute the query in milliseconds.
*/
ms: number;
}) => void;
}

/**
* Extends the SqlTag class definition to define the events that are emitted.
*/
export declare interface SqlTag<TQueryInfo, TCursorOptions> {
on<U extends keyof SqlTagEvents<TQueryInfo>>(
event: U,
listener: SqlTagEvents<TQueryInfo>[U],
): this;

emit<U extends keyof SqlTagEvents<TQueryInfo>>(
event: U,
...args: Parameters<SqlTagEvents<TQueryInfo>[U]>
): boolean;
}

/**
* Symbol used to reference the driver property on the SqlTag class within the library (the
* property is not accessible publicly).
*/
export const _driver = Symbol('driver');

export class SqlTag<TQueryInfo, TCursorOptions> extends EventEmitter {
[_driver]: SqlTagDriver<TQueryInfo, TCursorOptions>;

constructor(driver: SqlTagDriver<TQueryInfo, TCursorOptions>) {
super();
this[_driver] = driver;
return Callable(this);
}

[Callable.call]<T>(strings: TemplateStringsArray, ...values: any[]) {
return new SqlQuery<T, TQueryInfo, TCursorOptions>(this.driver, [...strings], values);
return new SqlQuery<T, TQueryInfo, TCursorOptions>(this, [...strings], values);
}

/**
Expand All @@ -21,8 +97,8 @@ export class SqlTag<TQueryInfo, TCursorOptions> {
*/
join(values: any[], joinWith = ', '): SqlExpression {
const filteredValues = values.filter((value) => value !== undefined);
return new SqlQuery(
this.driver,
return new SqlQuery<never, never, never>(
this,
['', ...Array(filteredValues.length - 1).fill(joinWith), ''],
filteredValues,
);
Expand All @@ -36,7 +112,7 @@ export class SqlTag<TQueryInfo, TCursorOptions> {
* @example sql.id('name') // `name`
*/
id(identifier: string): SqlExpression {
return new SqlQuery(this.driver, [this.driver.escapeIdentifier(identifier)], []);
return new SqlQuery(this, [this[_driver].escapeIdentifier(identifier)], []);
}

/**
Expand All @@ -47,7 +123,7 @@ export class SqlTag<TQueryInfo, TCursorOptions> {
* @example sql.raw('NOW()') // NOW()
*/
raw(string: string): SqlExpression {
return new SqlQuery(this.driver, [string], []);
return new SqlQuery(this, [string], []);
}

/**
Expand Down Expand Up @@ -90,9 +166,9 @@ export class SqlTag<TQueryInfo, TCursorOptions> {
* @returns A SqlExpression representing the IN expression.
* @example sql.in('id', [1, 2, 3]) // id IN (1, 2, 3)
*/
in(column: string, values: any[], ifEmpty: any = 0): SqlExpression {
const filteredValues = values.filter((value) => value !== undefined);
if (!filteredValues.length) return this`${ifEmpty}` as SqlExpression;
in(column: string, values: any[] | undefined | null, ifEmpty: any = 0): SqlExpression {
const filteredValues = values?.filter((value) => value !== undefined);
if (!filteredValues?.length) return this`${ifEmpty}` as SqlExpression;
return this`${this.id(column)} IN (${this.join(filteredValues)})` as SqlExpression;
}

Expand Down Expand Up @@ -164,7 +240,7 @@ export class SqlTag<TQueryInfo, TCursorOptions> {
* @returns A 2-tuple containing the parameterized query string, and parameters as an array.
*/
compile(strings: TemplateStringsArray, ...values: any[]) {
return new SqlQuery(this.driver, [...strings], values).compile();
return new SqlQuery(this, [...strings], values).compile();
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sqltags/core",
"version": "0.0.24",
"version": "0.0.25",
"description": "Safely create & execute parameterized SQL queries using template strings 🔧✨ minimal API and works with any db driver (pg, mysql, sqlite, etc).",
"license": "MIT",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion drivers/mysql/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sqltags/mysql",
"version": "0.0.24",
"version": "0.0.25",
"description": "MySQL driver for sqltags (@sqltags/core) 🔧✨ Safely create & execute parameterized SQL queries using template strings",
"license": "MIT",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion drivers/postgres/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sqltags/pg",
"version": "0.0.24",
"version": "0.0.25",
"description": "PostgreSQL driver for SqlTags (@sqltags/core) 🔧✨ Safely create & execute parameterized SQL queries using template strings",
"license": "MIT",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion drivers/sqlite/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sqltags/sqlite",
"version": "0.0.24",
"version": "0.0.25",
"description": "SQLite driver for sqltags (@sqltags/core) 🔧✨ Safely create & execute parameterized SQL queries using template strings",
"license": "MIT",
"author": {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions test/sqltags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,20 @@ describe('sqltags', () => {
]);
});

test('value in undefined array', async () => {
const [sql] = createMockSqlTag();
const [, info] = await sql`
SELECT * FROM users
WHERE ${sql.in('id', undefined)}
`;

expect(info).toEqual([
`SELECT * FROM users
WHERE $1`,
0,
]);
});

test('value in array with custom ifEmpty', async () => {
const [sql] = createMockSqlTag();
const [, info] = await sql`
Expand Down Expand Up @@ -344,4 +358,49 @@ describe('sqltags', () => {
expect(query).toEqual('SELECT * FROM users WHERE id = $1');
expect(params).toEqual([1]);
});

test('beforeQuery event', async () => {
const [sql] = createMockSqlTag();

const beforeQuery = jest.fn();
sql.on('beforeQuery', beforeQuery);

await sql`SELECT * FROM users WHERE id = ${1}`;

expect(beforeQuery).toHaveBeenCalledTimes(1);
const callParams = beforeQuery.mock.calls[0][0];
expect(callParams.queryText).toEqual('SELECT * FROM users WHERE id = $1');
expect(callParams.params).toEqual([1]);
});

test('beforeQuery event (cursors)', async () => {
const [sql] = createMockSqlTag();

const beforeQuery = jest.fn();
sql.on('beforeQuery', beforeQuery);

sql`SELECT * FROM users WHERE id = ${1}`.cursor();

expect(beforeQuery).toHaveBeenCalledTimes(1);
const callParams = beforeQuery.mock.calls[0][0];
expect(callParams.queryText).toEqual('SELECT * FROM users WHERE id = $1');
expect(callParams.params).toEqual([1]);
});

test('afterQuery event', async () => {
const [sql] = createMockSqlTag();

const afterQuery = jest.fn();
sql.on('afterQuery', afterQuery);

await sql`SELECT * FROM users WHERE id = ${1}`;

expect(afterQuery).toHaveBeenCalledTimes(1);
const callParams = afterQuery.mock.calls[0][0];
expect(callParams.queryText).toEqual('SELECT * FROM users WHERE id = $1');
expect(callParams.params).toEqual([1]);
expect(callParams.rows).toEqual(testUsers);
expect(callParams.queryInfo).toEqual(['SELECT * FROM users WHERE id = $1', 1]);
expect(callParams.ms).toBeGreaterThan(0);
});
});

0 comments on commit 8497c98

Please sign in to comment.