Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: gajus/slonik
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v30.1.2
Choose a base ref
...
head repository: gajus/slonik
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v30.3.0
Choose a head ref
  • 5 commits
  • 18 files changed
  • 1 contributor

Commits on Aug 6, 2022

  1. Copy the full SHA
    71c457d View commit details
  2. Copy the full SHA
    70d26d7 View commit details

Commits on Aug 19, 2022

  1. Copy the full SHA
    dde2fa1 View commit details
  2. feat: adds sql.date and sql.timestamp (fixes #113) (#383)

    * feat: adds sql.date and sql.timestamp (fixes #113)
    
    * test: correct test assertion
    gajus authored Aug 19, 2022
    Copy the full SHA
    fece53a View commit details
  3. feat: add sql.interval (#385)

    gajus authored Aug 19, 2022
    Copy the full SHA
    7f578ff View commit details
173 changes: 147 additions & 26 deletions .README/QUERY_BUILDING.md
Original file line number Diff line number Diff line change
@@ -111,6 +111,33 @@ Produces:
}
```

### `sql.date`

```ts
(
date: Date
) => DateSqlToken;
```

Inserts a date, e.g.

```ts
await connection.query(sql`
SELECT ${sql.date(new Date('2022-08-19T03:27:24.951Z'))}
`);
```

Produces:

```ts
{
sql: 'SELECT $1::date',
values: [
'2022-08-19'
]
}
```

### `sql.identifier`

```ts
@@ -137,60 +164,73 @@ Produces:
}
```

### `sql.json`
### `sql.interval`

```ts
(
value: SerializableValue
) => JsonSqlToken;
interval: {
years?: number,
months?: number,
weeks?: number,
days?: number,
hours?: number,
minutes?: number,
seconds?: number,
}
) => IntervalSqlToken;
```

Serializes value and binds it as a JSON string literal, e.g.
Inserts an [interval](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT), e.g.

```ts
await connection.query(sql`
SELECT (${sql.json([1, 2, 3])})
`);
sql`
SELECT 1
FROM ${sql.interval({days: 3})}
`;
```

Produces:

```ts
{
sql: 'SELECT $1::json',
sql: 'SELECT make_interval("days" => $1)',
values: [
'[1,2,3]'
3
]
}
```

### `sql.jsonb`
You can use `sql.interval` exactly how you would use PostgreSQL [`make_interval` function](https://www.postgresql.org/docs/current/functions-datetime.html). However, notice that Slonik does not use abbreviations, i.e. "secs" is seconds and "mins" is minutes.

```ts
(
value: SerializableValue
) => JsonBinarySqlToken;
```
|`make_interval`|`sql.interval`|Interval output|
|---|---|---|
|`make_interval("days" => 1, "hours" => 2)`|`sql.interval({days: 1, hours: 2})`|`1 day 02:00:00`|
|`make_interval("mins" => 1)`|`sql.interval({minutes: 1})`|`00:01:00`|
|`make_interval("secs" => 120)`|`sql.interval({seconds: 120})`|`00:02:00`|
|`make_interval("secs" => 0.001)`|`sql.interval({seconds: 0.001})`|`00:00:00.001`|

Serializes value and binds it as a JSON binary, e.g.
#### Dynamic intervals without `sql.interval`

If you need a dynamic interval (e.g. X days), you can achieve this using multiplication, e.g.

```ts
await connection.query(sql`
SELECT (${sql.jsonb([1, 2, 3])})
`);
sql`
SELECT ${2} * interval '1 day'
`
```

Produces:
The above is equivalent to `interval '2 days'`.

You could also use `make_interval()` directly, e.g.

```ts
{
sql: 'SELECT $1::jsonb',
values: [
'[1,2,3]'
]
}
sql`
SELECT make_interval("days" => ${2})
`
```

`sql.interval` was added mostly as a type-safe alternative.

### `sql.join`

```ts
@@ -259,6 +299,60 @@ sql`
// SELECT ($1, $2), ($3, $4)
```

### `sql.json`

```ts
(
value: SerializableValue
) => JsonSqlToken;
```

Serializes value and binds it as a JSON string literal, e.g.

```ts
await connection.query(sql`
SELECT (${sql.json([1, 2, 3])})
`);
```

Produces:

```ts
{
sql: 'SELECT $1::json',
values: [
'[1,2,3]'
]
}
```

### `sql.jsonb`

```ts
(
value: SerializableValue
) => JsonBinarySqlToken;
```

Serializes value and binds it as a JSON binary, e.g.

```ts
await connection.query(sql`
SELECT (${sql.jsonb([1, 2, 3])})
`);
```

Produces:

```ts
{
sql: 'SELECT $1::jsonb',
values: [
'[1,2,3]'
]
}
```

### `sql.literalValue`

> ⚠️ Do not use. This method interpolates values as literals and it must be used only for [building utility statements](#slonik-recipes-building-utility-statements). You are most likely looking for [value placeholders](#slonik-value-placeholders).
@@ -285,6 +379,33 @@ Produces:
}
```

### `sql.timestamp`

```ts
(
date: Date
) => TimestampSqlToken;
```

Inserts a timestamp, e.g.

```ts
await connection.query(sql`
SELECT ${sql.timestamp(new Date('2022-08-19T03:27:24.951Z'))}
`);
```

Produces:

```ts
{
sql: 'SELECT to_timestamp($1)',
values: [
'1660879644.951'
]
}
```

### `sql.unnest`

```ts
26 changes: 26 additions & 0 deletions .README/SQL_TAG.md
Original file line number Diff line number Diff line change
@@ -22,3 +22,29 @@ import {

const sql = createSqlTag();
```

### Typing `sql` tag

`sql` has a generic interface, meaning that you can supply it with the type that represents the expected result of the query, e.g.

```ts
type Person = {
id: number,
name: string,
};

const query = sql<Person>`
SELECT id, name
FROM person
`;

// onePerson has a type of Person
const onePerson = await connection.one(query);

// persons has a type of Person[]
const persons = await connection.many(query);
```

As you see, query helper methods (`one`, `many`, etc.) infer the result type based on the type associated with the `sql` tag instance.

However, you should avoid passing types directly to `sql` and instead use [runtime validation](#runtime-validation). Runtime validation produces typed `sql` tags, but also validates the results of the query.
222 changes: 191 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -101,18 +101,22 @@ Note: Using this project does not require TypeScript. It is a regular ES6 module
* [Inferring types](#user-content-slonik-runtime-validation-inferring-types)
* [Transforming results](#user-content-slonik-runtime-validation-transforming-results)
* [`sql` tag](#user-content-slonik-sql-tag)
* [Typing `sql` tag](#user-content-slonik-sql-tag-typing-sql-tag)
* [Value placeholders](#user-content-slonik-value-placeholders)
* [Tagged template literals](#user-content-slonik-value-placeholders-tagged-template-literals)
* [Manually constructing the query](#user-content-slonik-value-placeholders-manually-constructing-the-query)
* [Nesting `sql`](#user-content-slonik-value-placeholders-nesting-sql)
* [Query building](#user-content-slonik-query-building)
* [`sql.array`](#user-content-slonik-query-building-sql-array)
* [`sql.binary`](#user-content-slonik-query-building-sql-binary)
* [`sql.date`](#user-content-slonik-query-building-sql-date)
* [`sql.identifier`](#user-content-slonik-query-building-sql-identifier)
* [`sql.interval`](#user-content-slonik-query-building-sql-interval)
* [`sql.join`](#user-content-slonik-query-building-sql-join)
* [`sql.json`](#user-content-slonik-query-building-sql-json)
* [`sql.jsonb`](#user-content-slonik-query-building-sql-jsonb)
* [`sql.join`](#user-content-slonik-query-building-sql-join)
* [`sql.literalValue`](#user-content-slonik-query-building-sql-literalvalue)
* [`sql.timestamp`](#user-content-slonik-query-building-sql-timestamp)
* [`sql.unnest`](#user-content-slonik-query-building-sql-unnest)
* [Query methods](#user-content-slonik-query-methods)
* [`any`](#user-content-slonik-query-methods-any)
@@ -1388,6 +1392,33 @@ import {
const sql = createSqlTag();
```

<a name="user-content-slonik-sql-tag-typing-sql-tag"></a>
<a name="slonik-sql-tag-typing-sql-tag"></a>
### Typing <code>sql</code> tag

`sql` has a generic interface, meaning that you can supply it with the type that represents the expected result of the query, e.g.

```ts
type Person = {
id: number,
name: string,
};

const query = sql<Person>`
SELECT id, name
FROM person
`;

// onePerson has a type of Person
const onePerson = await connection.one(query);

// persons has a type of Person[]
const persons = await connection.many(query);
```

As you see, query helper methods (`one`, `many`, etc.) infer the result type based on the type associated with the `sql` tag instance.

However, you should avoid passing types directly to `sql` and instead use [runtime validation](#user-content-runtime-validation). Runtime validation produces typed `sql` tags, but also validates the results of the query.

<a name="user-content-slonik-value-placeholders"></a>
<a name="slonik-value-placeholders"></a>
@@ -1597,6 +1628,35 @@ Produces:
}
```

<a name="user-content-slonik-query-building-sql-date"></a>
<a name="slonik-query-building-sql-date"></a>
### <code>sql.date</code>

```ts
(
date: Date
) => DateSqlToken;
```

Inserts a date, e.g.

```ts
await connection.query(sql`
SELECT ${sql.date(new Date('2022-08-19T03:27:24.951Z'))}
`);
```

Produces:

```ts
{
sql: 'SELECT $1::date',
values: [
'2022-08-19'
]
}
```

<a name="user-content-slonik-query-building-sql-identifier"></a>
<a name="slonik-query-building-sql-identifier"></a>
### <code>sql.identifier</code>
@@ -1625,64 +1685,77 @@ Produces:
}
```

<a name="user-content-slonik-query-building-sql-json"></a>
<a name="slonik-query-building-sql-json"></a>
### <code>sql.json</code>
<a name="user-content-slonik-query-building-sql-interval"></a>
<a name="slonik-query-building-sql-interval"></a>
### <code>sql.interval</code>

```ts
(
value: SerializableValue
) => JsonSqlToken;
interval: {
years?: number,
months?: number,
weeks?: number,
days?: number,
hours?: number,
minutes?: number,
seconds?: number,
}
) => IntervalSqlToken;
```

Serializes value and binds it as a JSON string literal, e.g.
Inserts an [interval](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT), e.g.

```ts
await connection.query(sql`
SELECT (${sql.json([1, 2, 3])})
`);
sql`
SELECT 1
FROM ${sql.interval({days: 3})}
`;
```

Produces:

```ts
{
sql: 'SELECT $1::json',
sql: 'SELECT make_interval("days" => $1)',
values: [
'[1,2,3]'
3
]
}
```

<a name="user-content-slonik-query-building-sql-jsonb"></a>
<a name="slonik-query-building-sql-jsonb"></a>
### <code>sql.jsonb</code>
You can use `sql.interval` exactly how you would use PostgreSQL [`make_interval` function](https://www.postgresql.org/docs/current/functions-datetime.html). However, notice that Slonik does not use abbreviations, i.e. "secs" is seconds and "mins" is minutes.

```ts
(
value: SerializableValue
) => JsonBinarySqlToken;
```
|`make_interval`|`sql.interval`|Interval output|
|---|---|---|
|`make_interval("days" => 1, "hours" => 2)`|`sql.interval({days: 1, hours: 2})`|`1 day 02:00:00`|
|`make_interval("mins" => 1)`|`sql.interval({minutes: 1})`|`00:01:00`|
|`make_interval("secs" => 120)`|`sql.interval({seconds: 120})`|`00:02:00`|
|`make_interval("secs" => 0.001)`|`sql.interval({seconds: 0.001})`|`00:00:00.001`|

Serializes value and binds it as a JSON binary, e.g.
<a name="user-content-slonik-query-building-sql-interval-dynamic-intervals-without-sql-interval"></a>
<a name="slonik-query-building-sql-interval-dynamic-intervals-without-sql-interval"></a>
#### Dynamic intervals without <code>sql.interval</code>

If you need a dynamic interval (e.g. X days), you can achieve this using multiplication, e.g.

```ts
await connection.query(sql`
SELECT (${sql.jsonb([1, 2, 3])})
`);
sql`
SELECT ${2} * interval '1 day'
`
```

Produces:
The above is equivalent to `interval '2 days'`.

You could also use `make_interval()` directly, e.g.

```ts
{
sql: 'SELECT $1::jsonb',
values: [
'[1,2,3]'
]
}
sql`
SELECT make_interval("days" => ${2})
`
```

`sql.interval` was added mostly as a type-safe alternative.

<a name="user-content-slonik-query-building-sql-join"></a>
<a name="slonik-query-building-sql-join"></a>
### <code>sql.join</code>
@@ -1753,6 +1826,64 @@ sql`
// SELECT ($1, $2), ($3, $4)
```

<a name="user-content-slonik-query-building-sql-json"></a>
<a name="slonik-query-building-sql-json"></a>
### <code>sql.json</code>

```ts
(
value: SerializableValue
) => JsonSqlToken;
```

Serializes value and binds it as a JSON string literal, e.g.

```ts
await connection.query(sql`
SELECT (${sql.json([1, 2, 3])})
`);
```

Produces:

```ts
{
sql: 'SELECT $1::json',
values: [
'[1,2,3]'
]
}
```

<a name="user-content-slonik-query-building-sql-jsonb"></a>
<a name="slonik-query-building-sql-jsonb"></a>
### <code>sql.jsonb</code>

```ts
(
value: SerializableValue
) => JsonBinarySqlToken;
```

Serializes value and binds it as a JSON binary, e.g.

```ts
await connection.query(sql`
SELECT (${sql.jsonb([1, 2, 3])})
`);
```

Produces:

```ts
{
sql: 'SELECT $1::jsonb',
values: [
'[1,2,3]'
]
}
```

<a name="user-content-slonik-query-building-sql-literalvalue"></a>
<a name="slonik-query-building-sql-literalvalue"></a>
### <code>sql.literalValue</code>
@@ -1781,6 +1912,35 @@ Produces:
}
```

<a name="user-content-slonik-query-building-sql-timestamp"></a>
<a name="slonik-query-building-sql-timestamp"></a>
### <code>sql.timestamp</code>

```ts
(
date: Date
) => TimestampSqlToken;
```

Inserts a timestamp, e.g.

```ts
await connection.query(sql`
SELECT ${sql.timestamp(new Date('2022-08-19T03:27:24.951Z'))}
`);
```

Produces:

```ts
{
sql: 'SELECT to_timestamp($1)',
values: [
'1660879644.951'
]
}
```

<a name="user-content-slonik-query-building-sql-unnest"></a>
<a name="slonik-query-building-sql-unnest"></a>
### <code>sql.unnest</code>
14 changes: 7 additions & 7 deletions package-lock.json
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -50,7 +50,7 @@
"sinon": "^12.0.1",
"ts-node": "^10.7.0",
"typescript": "^4.7.4",
"zod": "^3.17.10"
"zod": "^3.18.0"
},
"engines": {
"node": ">=10.0"
92 changes: 63 additions & 29 deletions src/factories/createSqlTag.ts
Original file line number Diff line number Diff line change
@@ -8,26 +8,33 @@ import {
import {
ArrayToken,
BinaryToken,
DateToken,
IdentifierToken,
IntervalToken,
JsonBinaryToken,
JsonToken,
ListToken,
SqlToken,
TimestampToken,
UnnestToken,
} from '../tokens';
import {
type ArraySqlToken,
type BinarySqlToken,
type DateSqlToken,
type IdentifierSqlToken,
type IntervalInput,
type IntervalSqlToken,
type JsonBinarySqlToken,
type JsonSqlToken,
type SqlToken as SqlTokenType,
type ListSqlToken,
type PrimitiveValueExpression,
type QueryResultRow,
type SerializableValue,
type SqlSqlToken,
type SqlTaggedTemplate,
type SqlToken as SqlTokenType,
type TimestampSqlToken,
type TypeNameIdentifier,
type UnnestSqlToken,
type ValueExpression,
@@ -107,23 +114,6 @@ const sql: SqlTaggedTemplate = (
return query;
};

sql.type = (
parser,
) => {
let strictParser = parser;

if (parser._def.unknownKeys === 'strip') {
strictParser = parser.strict();
}

return (...args) => {
return {
...sql(...args),
parser: strictParser,
};
};
};

sql.array = (
values: readonly PrimitiveValueExpression[],
memberType: SqlTokenType | TypeNameIdentifier,
@@ -144,6 +134,15 @@ sql.binary = (
};
};

sql.date = (
date: Date,
): DateSqlToken => {
return {
date,
type: DateToken,
};
};

sql.identifier = (
names: readonly string[],
): IdentifierSqlToken => {
@@ -153,6 +152,26 @@ sql.identifier = (
};
};

sql.interval = (
interval: IntervalInput,
): IntervalSqlToken => {
return {
interval,
type: IntervalToken,
};
};

sql.join = (
members: readonly ValueExpression[],
glue: SqlSqlToken,
): ListSqlToken => {
return {
glue,
members,
type: ListToken,
};
};

sql.json = (
value: SerializableValue,
): JsonSqlToken => {
@@ -171,17 +190,6 @@ sql.jsonb = (
};
};

sql.join = (
members: readonly ValueExpression[],
glue: SqlSqlToken,
): ListSqlToken => {
return {
glue,
members,
type: ListToken,
};
};

sql.literalValue = (
value: string,
): SqlSqlToken => {
@@ -192,6 +200,32 @@ sql.literalValue = (
};
};

sql.timestamp = (
date: Date,
): TimestampSqlToken => {
return {
date,
type: TimestampToken,
};
};

sql.type = (
parser,
) => {
let strictParser = parser;

if (parser._def.unknownKeys === 'strip') {
strictParser = parser.strict();
}

return (...args) => {
return {
...sql(...args),
parser: strictParser,
};
};
};

sql.unnest = (
tuples: ReadonlyArray<readonly PrimitiveValueExpression[]>,
columnTypes: Array<[...string[], TypeNameIdentifier]> | Array<SqlSqlToken | TypeNameIdentifier>,
12 changes: 12 additions & 0 deletions src/factories/createSqlTokenSqlFragment.ts
Original file line number Diff line number Diff line change
@@ -4,20 +4,26 @@ import {
import {
createArraySqlFragment,
createBinarySqlFragment,
createDateSqlFragment,
createIdentifierSqlFragment,
createIntervalSqlFragment,
createJsonSqlFragment,
createListSqlFragment,
createSqlSqlFragment,
createTimestampSqlFragment,
createUnnestSqlFragment,
} from '../sqlFragmentFactories';
import {
ArrayToken,
BinaryToken,
DateToken,
IdentifierToken,
IntervalToken,
JsonBinaryToken,
JsonToken,
ListToken,
SqlToken,
TimestampToken,
UnnestToken,
} from '../tokens';
import {
@@ -30,8 +36,12 @@ export const createSqlTokenSqlFragment = (token: SqlTokenType, greatestParameter
return createArraySqlFragment(token, greatestParameterPosition);
} else if (token.type === BinaryToken) {
return createBinarySqlFragment(token, greatestParameterPosition);
} else if (token.type === DateToken) {
return createDateSqlFragment(token, greatestParameterPosition);
} else if (token.type === IdentifierToken) {
return createIdentifierSqlFragment(token);
} else if (token.type === IntervalToken) {
return createIntervalSqlFragment(token, greatestParameterPosition);
} else if (token.type === JsonBinaryToken) {
return createJsonSqlFragment(token, greatestParameterPosition, true);
} else if (token.type === JsonToken) {
@@ -40,6 +50,8 @@ export const createSqlTokenSqlFragment = (token: SqlTokenType, greatestParameter
return createListSqlFragment(token, greatestParameterPosition);
} else if (token.type === SqlToken) {
return createSqlSqlFragment(token, greatestParameterPosition);
} else if (token.type === TimestampToken) {
return createTimestampSqlFragment(token, greatestParameterPosition);
} else if (token.type === UnnestToken) {
return createUnnestSqlFragment(token, greatestParameterPosition);
}
20 changes: 20 additions & 0 deletions src/sqlFragmentFactories/createDateSqlFragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
InvalidInputError,
} from '../errors';
import {
type DateSqlToken,
type SqlFragment,
} from '../types';

export const createDateSqlFragment = (token: DateSqlToken, greatestParameterPosition: number): SqlFragment => {
if (!(token.date instanceof Date)) {
throw new InvalidInputError('Date parameter value must be an instance of Date.');
}

return {
sql: '$' + String(greatestParameterPosition + 1) + '::date',
values: [
token.date.toISOString().slice(0, 10),
],
};
};
58 changes: 58 additions & 0 deletions src/sqlFragmentFactories/createIntervalSqlFragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
z,
} from 'zod';
import {
InvalidInputError,
} from '../errors';
import {
type IntervalSqlToken,
type SqlFragment,
} from '../types';

const IntervalInput = z.object({
days: z.number().optional(),
hours: z.number().optional(),
minutes: z.number().optional(),
months: z.number().optional(),
seconds: z.number().optional(),
weeks: z.number().optional(),
years: z.number().optional(),
}).strict();

const intervalFragments = [
'years',
'months',
'days',
'hours',
'minutes',
'seconds',
];

export const createIntervalSqlFragment = (token: IntervalSqlToken, greatestParameterPosition: number): SqlFragment => {
let intervalInput;

try {
intervalInput = IntervalInput.parse(token.interval);
} catch {
throw new InvalidInputError('Interval input must not contain unknown properties.');
}

const values: number[] = [];

const intervalTokens: string[] = [];

for (const intervalFragment of intervalFragments) {
const value = intervalInput[intervalFragment];

if (value !== undefined) {
values.push(value);

intervalTokens.push(intervalFragment + ' => $' + String(greatestParameterPosition + values.length));
}
}

return {
sql: 'make_interval(' + intervalTokens.join(', ') + ')',
values,
};
};
20 changes: 20 additions & 0 deletions src/sqlFragmentFactories/createTimestampSqlFragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
InvalidInputError,
} from '../errors';
import {
type TimestampSqlToken,
type SqlFragment,
} from '../types';

export const createTimestampSqlFragment = (token: TimestampSqlToken, greatestParameterPosition: number): SqlFragment => {
if (!(token.date instanceof Date)) {
throw new InvalidInputError('Timestamp parameter value must be an instance of Date.');
}

return {
sql: 'to_timestamp($' + String(greatestParameterPosition + 1) + ')',
values: [
String(token.date.getTime() / 1_000),
],
};
};
9 changes: 9 additions & 0 deletions src/sqlFragmentFactories/index.ts
Original file line number Diff line number Diff line change
@@ -4,9 +4,15 @@ export {
export {
createBinarySqlFragment,
} from './createBinarySqlFragment';
export {
createDateSqlFragment,
} from './createDateSqlFragment';
export {
createIdentifierSqlFragment,
} from './createIdentifierSqlFragment';
export {
createIntervalSqlFragment,
} from './createIntervalSqlFragment';
export {
createJsonSqlFragment,
} from './createJsonSqlFragment';
@@ -16,6 +22,9 @@ export {
export {
createSqlSqlFragment,
} from './createSqlSqlFragment';
export {
createTimestampSqlFragment,
} from './createTimestampSqlFragment';
export {
createUnnestSqlFragment,
} from './createUnnestSqlFragment';
3 changes: 3 additions & 0 deletions src/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export const ArrayToken = 'SLONIK_TOKEN_ARRAY';
export const BinaryToken = 'SLONIK_TOKEN_BINARY';
export const ComparisonPredicateToken = 'SLONIK_TOKEN_COMPARISON_PREDICATE';
export const DateToken = 'SLONIK_TOKEN_DATE';
export const IdentifierToken = 'SLONIK_TOKEN_IDENTIFIER';
export const IntervalToken = 'SLONIK_TOKEN_INTERVAL';
export const JsonBinaryToken = 'SLONIK_TOKEN_JSON_BINARY';
export const JsonToken = 'SLONIK_TOKEN_JSON';
export const ListToken = 'SLONIK_TOKEN_LIST';
export const SqlToken = 'SLONIK_TOKEN_SQL';
export const TimestampToken = 'SLONIK_TOKEN_TIMESTAMP';
export const UnnestToken = 'SLONIK_TOKEN_UNNEST';
31 changes: 31 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -247,6 +247,16 @@ type CallSite = {
readonly lineNumber: number,
};

export type IntervalInput = {
days?: number,
hours?: number,
minutes?: number,
months?: number,
seconds?: number,
weeks?: number,
years?: number,
};

/**
* @property connectionId Unique connection ID.
* @property log Instance of Roarr logger with bound query context parameters.
@@ -280,11 +290,21 @@ export type BinarySqlToken = {
readonly type: typeof tokens.BinaryToken,
};

export type DateSqlToken = {
readonly date: Date,
readonly type: typeof tokens.DateToken,
};

export type IdentifierSqlToken = {
readonly names: readonly string[],
readonly type: typeof tokens.IdentifierToken,
};

export type IntervalSqlToken = {
readonly interval: IntervalInput,
readonly type: typeof tokens.IntervalToken,
};

export type ListSqlToken = {
readonly glue: SqlSqlToken,
readonly members: readonly ValueExpression[],
@@ -308,6 +328,11 @@ export type SqlSqlToken<T = UserQueryResultRow> = {
readonly values: readonly PrimitiveValueExpression[],
};

export type TimestampSqlToken = {
readonly date: Date,
readonly type: typeof tokens.TimestampToken,
};

export type UnnestSqlToken = {
readonly columnTypes: Array<[...string[], TypeNameIdentifier]> | Array<SqlSqlToken | TypeNameIdentifier>,
readonly tuples: ReadonlyArray<readonly ValueExpression[]>,
@@ -325,11 +350,14 @@ export type PrimitiveValueExpression =
export type SqlToken =
| ArraySqlToken
| BinarySqlToken
| DateSqlToken
| IdentifierSqlToken
| IntervalSqlToken
| JsonBinarySqlToken
| JsonSqlToken
| ListSqlToken
| SqlSqlToken
| TimestampSqlToken
| UnnestSqlToken;

export type ValueExpression = PrimitiveValueExpression | SqlToken;
@@ -372,11 +400,14 @@ export type SqlTaggedTemplate<T extends UserQueryResultRow = QueryResultRow> = {
memberType: SqlToken | TypeNameIdentifier,
) => ArraySqlToken,
binary: (data: Buffer) => BinarySqlToken,
date: (date: Date) => DateSqlToken,
identifier: (names: readonly string[]) => IdentifierSqlToken,
interval: (interval: IntervalInput) => IntervalSqlToken,
join: (members: readonly ValueExpression[], glue: SqlSqlToken) => ListSqlToken,
json: (value: SerializableValue) => JsonSqlToken,
jsonb: (value: SerializableValue) => JsonBinarySqlToken,
literalValue: (value: string) => SqlSqlToken,
timestamp: (date: Date) => TimestampSqlToken,
type: <U>(parser: Parser<U>) => (template: TemplateStringsArray, ...values: ValueExpression[]) => TaggedTemplateLiteralInvocation<U>,
unnest: (
// Value might be ReadonlyArray<ReadonlyArray<PrimitiveValueExpression>>,
6 changes: 6 additions & 0 deletions src/utilities/isSqlToken.ts
Original file line number Diff line number Diff line change
@@ -5,11 +5,14 @@ import {
ArrayToken,
BinaryToken,
ComparisonPredicateToken,
DateToken,
IdentifierToken,
IntervalToken,
JsonBinaryToken,
JsonToken,
ListToken,
SqlToken,
TimestampToken,
UnnestToken,
} from '../tokens';
import {
@@ -23,11 +26,14 @@ const Tokens = [
ArrayToken,
BinaryToken,
ComparisonPredicateToken,
DateToken,
IdentifierToken,
IntervalToken,
JsonBinaryToken,
JsonToken,
ListToken,
SqlToken,
TimestampToken,
UnnestToken,
] as const;

46 changes: 46 additions & 0 deletions test/helpers/createIntegrationTests.ts
Original file line number Diff line number Diff line change
@@ -9,6 +9,9 @@ import {
import {
serializeError,
} from 'serialize-error';
import {
z,
} from 'zod';
import {
type DatabasePoolConnection,
BackendTerminatedError,
@@ -24,6 +27,9 @@ import {
import {
Logger,
} from '../../src/Logger';
import {
type SchemaValidationError,
} from '../../src/errors';

const POSTGRES_DSN = process.env.POSTGRES_DSN ?? 'postgres@localhost:5432';

@@ -158,6 +164,46 @@ export const createIntegrationTests = (
await t.throwsAsync(firstConnection.oneFirst(sql`SELECT 1`));
});

test('validates results using zod (passes)', async (t) => {
const pool = await createPool(t.context.dsn, {
PgPool,
});

const result = await pool.one(sql.type(z.object({
foo: z.string(),
}))`
SELECT 'bar' foo
`);

t.like(result, {
foo: 'bar',
});

await pool.end();
});

test('validates results using zod (fails)', async (t) => {
const pool = await createPool(t.context.dsn, {
PgPool,
});

const error = await t.throwsAsync<SchemaValidationError>(pool.one(sql.type(z.object({
foo: z.number(),
}))`
SELECT 'bar' foo
`));

if (!error) {
throw new Error('Expected SchemaValidationError');
}

t.like(error.issues[0], {
code: 'invalid_type',
});

await pool.end();
});

// We have to test serialization due to the use of different drivers (pg and postgres).
test('serializes json', async (t) => {
const pool = await createPool(t.context.dsn, {
30 changes: 30 additions & 0 deletions test/slonik/templateTags/sql/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'ava';
import {
createSqlTag,
} from '../../../../src/factories/createSqlTag';
import {
SqlToken,
} from '../../../../src/tokens';

const sql = createSqlTag();

test('binds a date', (t) => {
const query = sql`SELECT ${sql.date(new Date('2022-08-19T03:27:24.951Z'))}`;

t.deepEqual(query, {
sql: 'SELECT $1::date',
type: SqlToken,
values: [
'2022-08-19',
],
});
});

test('throws if not instance of Date', (t) => {
const error = t.throws(() => {
// @ts-expect-error
sql`SELECT ${sql.date(1)}`;
});

t.is(error?.message, 'Date parameter value must be an instance of Date.');
});
30 changes: 30 additions & 0 deletions test/slonik/templateTags/sql/interval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'ava';
import {
createSqlTag,
} from '../../../../src/factories/createSqlTag';
import {
SqlToken,
} from '../../../../src/tokens';

const sql = createSqlTag();

test('creates an empty make_interval invocation', (t) => {
const query = sql`SELECT ${sql.interval({})}`;

t.deepEqual(query, {
sql: 'SELECT make_interval()',
type: SqlToken,
values: [],
});
});

test('throws if contains unknown properties', (t) => {
const error = t.throws(() => {
sql`SELECT ${sql.interval({
// @ts-expect-error
foo: 'bar',
})}`;
});

t.is(error?.message, 'Interval input must not contain unknown properties.');
});
30 changes: 30 additions & 0 deletions test/slonik/templateTags/sql/timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'ava';
import {
createSqlTag,
} from '../../../../src/factories/createSqlTag';
import {
SqlToken,
} from '../../../../src/tokens';

const sql = createSqlTag();

test('binds a timestamp', (t) => {
const query = sql`SELECT ${sql.timestamp(new Date('2022-08-19T03:27:24.951Z'))}`;

t.deepEqual(query, {
sql: 'SELECT to_timestamp($1)',
type: SqlToken,
values: [
'1660879644.951',
],
});
});

test('throws if not instance of Date', (t) => {
const error = t.throws(() => {
// @ts-expect-error
sql`SELECT ${sql.timestamp(1)}`;
});

t.is(error?.message, 'Timestamp parameter value must be an instance of Date.');
});