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

Stricter TypeScript #48

Open
mindplay-dk opened this issue Dec 26, 2024 · 1 comment
Open

Stricter TypeScript #48

mindplay-dk opened this issue Dec 26, 2024 · 1 comment

Comments

@mindplay-dk
Copy link

The 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:

export function setup<Value>() {
  /**
   * 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<number>(
        (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<ReadonlyArray<RawValue>>,
    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<unknown>();

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? ☺️

@mindplay-dk
Copy link
Author

This does somewhat simplify integration for modules with proper typing - for example, here's Deno's SQLite client integrated in a local db.ts module for type-safety:

import { Database, BindValue } from "jsr:@db/[email protected]";
import { setup } from "./sql-template-tag.ts"; // (my local copy, modified as shown above)

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

On the other hand, this factory function doesn't actually do anything, other than provide types - I mean, it doesn't do anything at run-time, and so the extra function call seems kind of silly.

But I don't know of any other practical way to add generic type-safety to a whole API like this.

My first thought was to just have a generic Sql<TValue> class, but again, this fails to connect with the types in the rest of the API... so I don't know. 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant