Skip to content

Stricter TypeScript #48

@mindplay-dk

Description

@mindplay-dk

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions