blakeembrey / blakeembrey/sql-template-tag

Stricter TypeScript

Open
#48 7 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
393
Forks
20
PR merge metrics
No merged PRs in 30d

Description

The [Stricter TypeScript](https://github.com/blakeembrey/sql-template-tag?tab=readme-ov-file#stricter-typescript) section of the README is a bit unhelpful:

Simply creating a new `sql` function, as prescribed, results in `Sql` instances where the `values` property is still the built-in `Value` type, which is just an alias for `unknown` - which doesn't actually work, e.g. when you try to pass this to a (properly typed) SQL client library.

Similarly, this doesn't give you `join`, `bulk`, `raw` (etc.) functions with the correct types.

To address this, you'd need some sort of factory function for the whole API, I think?

I tried this:

```ts
export function setup() {
/**
* Supported value or SQL instance.
*/
type RawValue = Value | Sql;

/**
* A SQL instance can be nested within each other to build SQL strings.
*/
class Sql {
readonly values: Value[];
readonly strings: string[];

constructor(rawStrings: readonly string[], rawValues: readonly RawValue[]) {
if (rawStrings.length - 1 !== rawValues.length) {
if (rawStrings.length === 0) {
throw new TypeError("Expected at least 1 string");
}

throw new TypeError(
`Expected ${rawStrings.length} strings to have ${
rawStrings.length - 1
} values`,
);
}

const valuesLength = rawValues.reduce(
(len, value) => len + (value instanceof Sql ? value.values.length : 1),
0,
);

this.values = new Array(valuesLength);
this.strings = new Array(valuesLength + 1);

this.strings[0] = rawStrings[0];

// Iterate over raw values, strings, and children. The value is always
// positioned between two strings, e.g. `index + 1`.
let i = 0,
pos = 0;
while (i < rawValues.length) {
const child = rawValues[i++];
const rawString = rawStrings[i];

// Check for nested `sql` queries.
if (child instanceof Sql) {
// Append child prefix text to current string.
this.strings[pos] += child.strings[0];

let childIndex = 0;
while (childIndex < child.values.length) {
this.values[pos++] = child.values[childIndex++];
this.strings[pos] = child.strings[childIndex];
}

// Append raw string to current string.
this.strings[pos] += rawString;
} else {
this.values[pos++] = child;
this.strings[pos] = rawString;
}
}
}

get sql() {
const len = this.strings.length;
let i = 1;
let value = this.strings[0];
while (i < len) value += `?${this.strings[i++]}`;
return value;
}

get statement() {
const len = this.strings.length;
let i = 1;
let value = this.strings[0];
while (i < len) value += `:${i}${this.strings[i++]}`;
return value;
}

get text() {
const len = this.strings.length;
let i = 1;
let value = this.strings[0];
while (i < len) value += `$${i}${this.strings[i++]}`;
return value;
}

inspect() {
return {
sql: this.sql,
statement: this.statement,
text: this.text,
values: this.values,
};
}
}

/**
* Create a SQL query for a list of values.
*/
function join(
values: readonly RawValue[],
separator = ",",
prefix = "",
suffix = "",
) {
if (values.length === 0) {
throw new TypeError(
"Expected `join([])` to be called with an array of multiple elements, but got an empty array",
);
}

return new Sql(
[prefix, ...Array(values.length - 1).fill(separator), suffix],
values,
);
}

/**
* Create a SQL query for a list of structured values.
*/
function bulk(
data: ReadonlyArray>,
separator = ",",
prefix = "",
suffix = "",
) {
const length = data.length && data[0].length;

if (length === 0) {
throw new TypeError(
"Expected `bulk([][])` to be called with a nested array of multiple elements, but got an empty array",
);
}

const values = data.map((item, index) => {
if (item.length !== length) {
throw new TypeError(
`Expected \`bulk([${index}][])\` to have a length of ${length}, but got ${item.length}`,
);
}

return new Sql(["(", ...Array(item.length - 1).fill(separator), ")"], item);
});

return new Sql(
[prefix, ...Array(values.length - 1).fill(separator), suffix],
values,
);
}

/**
* Create raw SQL statement.
*/
function raw(value: string) {
return new Sql([value], []);
}

/**
* Placeholder value for "no text".
*/
const empty = raw("");

/**
* Create a SQL object from a template string.
*/
function sql(
strings: readonly string[],
...values: readonly RawValue[]
) {
return new Sql(strings, values);
}

return {
sql, join, bulk, raw, empty
}
}

export const { sql, join, bulk, raw, empty } = setup();

export default sql;
```

It's a simple change, but it's a breaking change, in that the exported types `Value` and `RawValue` are lost - not that these were useful (probably) since, again, they don't represent a strict value type, and they don't work with a custom `sql` wrapper function.

I could of course write my own wrapper module using *unsafe* typecasts, correcting all the types by force - but then I'm not really using the types provided by the package, and instead just throwing them all away and replacing them, which definitely feels wrong.

I don't know, what do you think?

As I recall, you don't use TS yourself, so maybe you don't care? ☺️

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the README's Stricter TypeScript section and review the exported Value and RawValue types. Trace the types of sql, join, bulk, raw, and empty, then determine whether a factory can preserve a custom value type across the whole API; done should provide correctly typed values and helpers without unsafe casts.

Written by the indexing model from the issue text.

Assessment

Tech stack
sql, typescript
Domain
api
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.