From 5b4f0b578c40affe98984d69cd267b104c4ebd51 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 27 Nov 2023 11:35:26 -0700 Subject: [PATCH 1/9] feat: Add @vercel/tql --- packages/tql/.prettierignore | 3 + packages/tql/README.md | 166 +++++++++ packages/tql/license.md | 201 +++++++++++ packages/tql/package.json | 70 ++++ packages/tql/src/build.ts | 23 ++ packages/tql/src/dialect.test.ts | 22 ++ packages/tql/src/dialect.ts | 16 + packages/tql/src/dialects/postgres.test.ts | 321 ++++++++++++++++++ packages/tql/src/dialects/postgres.ts | 104 ++++++ packages/tql/src/dialects/test-dialect.ts | 44 +++ packages/tql/src/error.test.ts | 27 ++ packages/tql/src/error.ts | 42 +++ packages/tql/src/index.test.ts | 306 +++++++++++++++++ packages/tql/src/index.ts | 84 +++++ packages/tql/src/nodes.test.ts | 57 ++++ packages/tql/src/nodes.ts | 109 ++++++ packages/tql/src/types.ts | 186 ++++++++++ packages/tql/src/utils.test.ts | 25 ++ packages/tql/src/utils.ts | 43 +++ .../tql/src/values-object-validator.test.ts | 67 ++++ packages/tql/src/values-object-validator.ts | 56 +++ packages/tql/tsconfig.json | 4 + packages/tql/tsup.config.js | 14 + pnpm-lock.yaml | 147 +++++--- 24 files changed, 2081 insertions(+), 56 deletions(-) create mode 100644 packages/tql/.prettierignore create mode 100644 packages/tql/README.md create mode 100644 packages/tql/license.md create mode 100644 packages/tql/package.json create mode 100644 packages/tql/src/build.ts create mode 100644 packages/tql/src/dialect.test.ts create mode 100644 packages/tql/src/dialect.ts create mode 100644 packages/tql/src/dialects/postgres.test.ts create mode 100644 packages/tql/src/dialects/postgres.ts create mode 100644 packages/tql/src/dialects/test-dialect.ts create mode 100644 packages/tql/src/error.test.ts create mode 100644 packages/tql/src/error.ts create mode 100644 packages/tql/src/index.test.ts create mode 100644 packages/tql/src/index.ts create mode 100644 packages/tql/src/nodes.test.ts create mode 100644 packages/tql/src/nodes.ts create mode 100644 packages/tql/src/types.ts create mode 100644 packages/tql/src/utils.test.ts create mode 100644 packages/tql/src/utils.ts create mode 100644 packages/tql/src/values-object-validator.test.ts create mode 100644 packages/tql/src/values-object-validator.ts create mode 100644 packages/tql/tsconfig.json create mode 100644 packages/tql/tsup.config.js diff --git a/packages/tql/.prettierignore b/packages/tql/.prettierignore new file mode 100644 index 000000000..1d690c1d2 --- /dev/null +++ b/packages/tql/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +pnpm-lock.yaml \ No newline at end of file diff --git a/packages/tql/README.md b/packages/tql/README.md new file mode 100644 index 000000000..fb0a596e5 --- /dev/null +++ b/packages/tql/README.md @@ -0,0 +1,166 @@ +# What is it? + +```sh +pnpm i @vercel/tql +``` + +TQL (Template-SQL -- unfortunately, T-SQL is already taken!) is a lightweight library for writing SQL in TypeScript: + +```ts +import { init, PostgresDialect } from '@vercel/tql'; + +const { query, fragment, identifiers, list, values, set, unsafe } = init({ + dialect: PostgresDialect, +}); + +const [q, params] = query`SELECT * FROM users`; +// output: ['SELECT * FROM users', []] +``` + +Its API is simple -- everything starts and ends with `query`, which returns a tuple of the compiled query string and parameters to pass to your database. + +## A Primer on Tagged Templates + +What is a tagged template in JavaScript? Quite simply, it's a function with the following signature: + +```ts +function taggedTemplate( + strings: TemplateStringsArray, + ...values: unknown[] +): any { + // just like any regular function, you can return anything from here! +} + +const parameterName = 'strings'; +const result = taggedTemplate`The stringy parts of this are split on the "holes" and passed in as the "${variableName}" array!`; +// strings = ['The stringy parts of this are split on the "holes" and passed in as the ", " array!"] +// values: ['strings'] +``` + +This is good news for SQL! The values in `strings` can _only_ come directly from your code (as in, you as a developer definitely wrote them), which means they're always safe. We can then view the "holes" (anything between `${}`) as a doorway through which more-exotic, potentially-unsafe things can be safely passed into your query. + +But how do we know what to do with the special values passed through the "doorways"? Let's break it down. We need two pieces of semantic information: + +- The developer's intent (what should the query compiler do with this information?) +- The actual data being passed through + +This is trivially easy to do with classes -- if we only allow subclasses of a parent class (called `TqlNode`) to be passed in, then we can do something simple like this when building our query tree: + +```ts +if (!value instanceof TqlNode) { + return new TqlParameter(value); +} +return value; +``` + +Now we know all of the values we have are instances of our parent class, we can use which specific subclass they are to determine what to do with them. For example, if the node is a `TqlParameter` node, we would add a `$1` (or whichever number it should have) into the query string and pass the `node.data` value into the parameters array. + +## API + +To start, you'll need to initialize the query compiler: + +```ts +import { init, PostgresDialect } from '@vercel/tql'; + +const tql = init({ dialect: PostgresDialect }); +``` + +Missing your dialect? Feel free to open a PR -- they're pretty easy to write! + +Below, you can see the utilities returned from `init`, but here's a summary table: + +| Utility | Signature | Purpose | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `query` | `(strings: TemplateStringsArray, ...values: unknown[]) => [string, unknown[]]` | The top-level query object. Returns a tuple of the SQL query string and an array of parameters. Pass both of these to your database driver. | +| `fragment` | `(strings: TemplateStringsArray, ...values: unknown[]) => [string, unknown[]]` | Has the same API as `query`, but returns a `TqlFragment` node which can be recursively nested within itself and included in a top-level `query`. | +| `identifiers` | (ids: string | string[]) => TqlIdentifiers | Accepts a list of strings, escapes them, and inserts them into the query as identifiers (table or column names). Identifiers are safe and easy to escape, unlike query values! Will also accept a single identifier, for convenience. | +| `list` | `(parameters: unknown[]) => TqlList` | Accepts a list of anything and inserts it into the query as a parameterized list. For example, `[1, 2, 3]` would become `($1, $2, $3)` with the original values stored in the parameters array. | +| `values` | `(entries: ValuesObject) => TqlValues`, where `ValuesObject` is `{ [columnName: string]: unknown }` or an array of that object type. | Accepts an array of records (or, for convenience, a single record) and builds a VALUES clause out of it. See the example below for a full explanation. | +| `set` | `(entry: SetObject) => TqlSet`, where `SetObject` is `{ [columnName: string]: unknown }`. | Accepts a record representing the SET clause, and returns a parameterized SET clause. See example below for a full explanation. | +| `unsafe` | `(str: string) => TqlTemplateString` | Accepts a string and returns a representation of the string that will be inserted VERBATIM, UNESCAPED into the compiled query. Please, for all that is good, it's in the name -- this is unsafe. Do not use it unless you absolutely know your input is safe. | + +Important: Anywhere you pass a single value into `query` or `fragment`, you can also pass in an array of values. They'll be treated just as if you'd simply interpolated them right next to each other, with all the same protections. + +### Parameters + +Anything directly passed into a template becomes a parameter. Essentially, the "holes" in the template are filled in with the dialect's parameter placeholder, and the value itself is passed directly into the parameters array: + +```ts +const userId = 1234; +const [q, params] = query`SELECT * FROM users WHERE user_id = ${userId}`; +// output: ['SELECT * FROM users WHERE user_id = $1', [1234]] +``` + +### List parameters + +Need to use list syntax?: + +```ts +const userId = [1234, 5678]; +const [q, params] = query`SELECT * FROM users WHERE user_id IN ${list(userId)}`; +// output: ['SELECT * FROM users WHERE user_id IN ($1, $2)', [1234, 5678]] +``` + +### Composable queries + +Need to share clauses between queries, or do you just find it more convenient to build a specific query in multiple variable declarations? `fragment` is what you're looking for! + +```ts +const userId = 1234; +const whereClause = fragment`WHERE user_id = ${userId}`; +const [q, params] = query`SELECT * FROM users ${whereClause}`; +// output: ['SELECT * FROM users WHERE user_id = $1', [1234]] +``` + +Fragments can be nested recursively, so the possibilities are endless. + +### Identifiers + +Need to dynamically insert identifiers? + +```ts +const columns = ['name', 'dob']; +const [q, params] = query`SELECT ${identifiers(columns)} FROM users`; +// output: ['SELECT "name", "dob" FROM users', []] +``` + +Note: Dotted identifiers are escaped with all sides quoted, so `dbo.users` would become `"dbo"."users"`. + +### VALUES clauses + +Inserting records is a pain! + +```ts +const users = [ + { name: 'vercelliott', favorite_hobby: 'screaming into the void' }, + { name: 'reselliott', favorite_hobby: 'thrifting' }, +]; +const [q, params] = query`INSERT INTO users ${values(users)}`; +// output: [ +// 'INSERT INTO users ("name", "favorite_hobby") VALUES ($1, $2), ($3, $4)', +// ['vercelliott', 'screaming into the void', 'reselliott', 'thrifting'] +// ] +``` + +`values` also accepts just one record instead of an array. If an array is passed, it will validate that all records have the same columns. + +### SET clauses + +Updating records can also be a pain! + +```ts +const updatedUser = { name: 'vercelliott' }; +const userId = 1234; +const [q, params] = query`UPDATE users ${set( + updatedUser, +)} WHERE userId = ${userId}`; +// output: ['UPDATE users SET "name" = $1 WHERE userId = $2', ['vercelliott', 1234]] +``` + +### `unsafe` + +This is just a tagged template that will be verbatim-inserted into your query. It _is_ unsafe, do _not_ pass unsanitized user input into it! + +## I want to... + +As people ask questions about how to do various things, I'll fill out this section as a sort of FAQ. diff --git a/packages/tql/license.md b/packages/tql/license.md new file mode 100644 index 000000000..6c8c76876 --- /dev/null +++ b/packages/tql/license.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/tql/package.json b/packages/tql/package.json new file mode 100644 index 000000000..8b8db1273 --- /dev/null +++ b/packages/tql/package.json @@ -0,0 +1,70 @@ +{ + "name": "@vercel/tql", + "version": "1.0.0", + "description": "A flexible, multi-dialect tagged template SQL query builder.", + "keywords": [], + "repository": { + "type": "git", + "url": "https://github.com/vercel/storage", + "directory": "./packages/tql" + }, + "license": "Apache-2.0", + "author": { + "name": "S. Elliott Johnson", + "email": "elliott.johnson@vercel.com", + "url": "https://github.com/tcc-sejohnson" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + }, + "default": "./dist/index.js" + } + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch --clean=false", + "format": "prettier --write . --ignore-path .gitignore", + "lint": "eslint \"src/**/*.ts\"", + "lint-fix": "eslint \"src/**/*.ts\" --fix", + "prepublishOnly": "pnpm run build", + "prettier-check": "prettier --check . --ignore-path .gitignore", + "publint": "npx publint", + "test": "jest --env @edge-runtime/jest-environment .test.ts && jest --env node .test.ts", + "type-check": "tsc --noEmit" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node" + }, + "devDependencies": { + "@changesets/cli": "2.26.2", + "@edge-runtime/jest-environment": "2.3.6", + "@edge-runtime/types": "2.2.6", + "@types/jest": "29.5.7", + "@types/node": "20.8.10", + "eslint": "8.52.0", + "eslint-config-custom": "workspace:*", + "jest": "29.7.0", + "prettier": "3.0.3", + "ts-jest": "29.1.1", + "tsconfig": "workspace:*", + "tsup": "7.2.0", + "typescript": "5.2.2" + }, + "engines": { + "node": ">=14.6" + } +} diff --git a/packages/tql/src/build.ts b/packages/tql/src/build.ts new file mode 100644 index 000000000..0a2991cf9 --- /dev/null +++ b/packages/tql/src/build.ts @@ -0,0 +1,23 @@ +import { TqlError } from './error'; +import { type TqlQuery, type TqlFragment, type TqlNodeType } from './nodes'; +import type { DialectImpl } from './types'; + +// TODO: test +export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { + const actions = { + identifiers: dialect.identifiers.bind(dialect), + list: dialect.list.bind(dialect), + values: dialect.values.bind(dialect), + 'update-set': dialect.set.bind(dialect), + string: dialect.string.bind(dialect), + parameter: dialect.parameter.bind(dialect), + fragment: (node) => build(dialect, node), + query: (): void => { + throw new TqlError('illegal_query_recursion'); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } satisfies { [key in TqlNodeType]: (node: any) => void }; + for (const node of ast.nodes) { + actions[node.type](node); + } +} diff --git a/packages/tql/src/dialect.test.ts b/packages/tql/src/dialect.test.ts new file mode 100644 index 000000000..160ffe574 --- /dev/null +++ b/packages/tql/src/dialect.test.ts @@ -0,0 +1,22 @@ +import { BaseDialect } from './dialect'; +import type { TqlQuery } from './nodes'; + +describe('base dialect', () => { + it('should not preprocess the ast', () => { + const dialect = new BaseDialect( + () => 0, + () => 0, + ); + const ast = { nodes: [], type: 'query' } satisfies TqlQuery; + expect(dialect.preprocess(ast)).toEqual(ast); + }); + it('should not postprocess the query', () => { + const dialect = new BaseDialect( + () => 0, + () => 0, + ); + const query = 'SELECT * FROM foo'; + const params: unknown[] = []; + expect(dialect.postprocess(query, params)).toEqual([query, params]); + }); +}); diff --git a/packages/tql/src/dialect.ts b/packages/tql/src/dialect.ts new file mode 100644 index 000000000..ba8dad0d9 --- /dev/null +++ b/packages/tql/src/dialect.ts @@ -0,0 +1,16 @@ +import type { TqlQuery } from './nodes'; + +export class BaseDialect { + constructor( + protected readonly appendToQuery: (...values: string[]) => number, + protected readonly appendToParams: (...values: unknown[]) => number, + ) {} + + preprocess(ast: TqlQuery): TqlQuery { + return ast; + } + + postprocess(query: string, values: unknown[]): [string, unknown[]] { + return [query, values]; + } +} diff --git a/packages/tql/src/dialects/postgres.test.ts b/packages/tql/src/dialects/postgres.test.ts new file mode 100644 index 000000000..c605af6fc --- /dev/null +++ b/packages/tql/src/dialects/postgres.test.ts @@ -0,0 +1,321 @@ +import { + TqlTemplateString, + TqlParameter, + TqlIdentifiers, + TqlList, + TqlValues, + TqlSet, +} from '../nodes'; +import { createQueryBuilder } from '../utils'; +import { TqlError } from '../error'; +import { PostgresDialect } from './postgres'; + +describe('tql dialect: Postgres', () => { + let queryBuilder: ReturnType; + let d: () => PostgresDialect; + + beforeEach(() => { + const qb = createQueryBuilder(); + qb.appendToParams = jest.fn().mockImplementation(qb.appendToParams); + qb.appendToQuery = jest.fn().mockImplementation(qb.appendToQuery); + queryBuilder = qb; + d = (): PostgresDialect => + new PostgresDialect(qb.appendToQuery, qb.appendToParams); + }); + + describe('string', () => { + it('appends the string', () => { + const dialect = d(); + dialect.string(new TqlTemplateString('hi')); + expect(queryBuilder.params).toEqual([]); + expect(queryBuilder.query).toBe('hi'); + }); + }); + + describe('parameter', () => { + it('appends the parameter', () => { + const dialect = d(); + const parameterValue = 'vercelliott'; + dialect.parameter(new TqlParameter(parameterValue)); + expect(queryBuilder.params).toEqual([parameterValue]); + expect(queryBuilder.query).toBe('$1'); + }); + + it("does not change the type of the parameter value, even if it's exotic", () => { + const dialect = d(); + const parameterValue = { name: 'dispelliott' }; + dialect.parameter(new TqlParameter(parameterValue)); + expect(queryBuilder.params).toEqual([parameterValue]); + expect(queryBuilder.query).toBe('$1'); + }); + + it("increments the parameter value according to what's returned from appendToQuery", () => { + const dialect = d(); + const parameter1Value = 'retelliott'; + const parameter2Value = 'quelliott'; + dialect.parameter(new TqlParameter(parameter1Value)); + dialect.parameter(new TqlParameter(parameter2Value)); + expect(queryBuilder.params).toEqual([parameter1Value, parameter2Value]); + expect(queryBuilder.query).toBe('$1$2'); + }); + }); + + describe('identifiers', () => { + it('adds a single identifier to the query', () => { + const dialect = d(); + const identifier = 'name'; + dialect.identifiers(new TqlIdentifiers(identifier)); + expect(queryBuilder.params).toEqual([]); + expect(queryBuilder.query).toBe('"name"'); + }); + + it.each([ + { input: 'with"quotes', output: '"with""quotes"' }, + { + input: 'dotted.identifiers', + output: '"dotted"."identifiers"', + }, + { + input: + 'with.injection" FROM users; SELECT * FROM privileged_information;--', + output: + '"with"."injection"" FROM users; SELECT * FROM privileged_information;--"', + }, + ])('escapes identifiers', ({ input, output }) => { + const dialect = d(); + dialect.identifiers(new TqlIdentifiers(input)); + expect(queryBuilder.params).toEqual([]); + expect(queryBuilder.query).toBe(output); + }); + + it('adds multiple identifiers to the query', () => { + const dialect = d(); + const identifier = 'name'; + dialect.identifiers( + new TqlIdentifiers([identifier, identifier, identifier]), + ); + expect(queryBuilder.params).toEqual([]); + expect(queryBuilder.query).toBe('"name", "name", "name"'); + }); + + it.each([ + { + input: ['with"quotes', 'with"quotes'], + output: '"with""quotes", "with""quotes"', + }, + { + input: ['dotted.identifiers'], + output: '"dotted"."identifiers"', + }, + { + input: [ + 'with.injection" FROM users; SELECT * FROM privileged_information;--', + 'blah', + ], + output: + '"with"."injection"" FROM users; SELECT * FROM privileged_information;--", "blah"', + }, + ])('escapes identifiers', ({ input, output }) => { + const dialect = d(); + dialect.identifiers(new TqlIdentifiers(input)); + expect(queryBuilder.params).toEqual([]); + expect(queryBuilder.query).toBe(output); + }); + }); + + describe('list', () => { + it('adds items to a comma-separated list', () => { + const dialect = d(); + const items = [1, 'hi', { complex: 'type' }]; + dialect.list(new TqlList(items)); + expect(queryBuilder.params).toEqual(items); + expect(queryBuilder.query).toBe('($1, $2, $3)'); + }); + }); + + describe('values', () => { + describe('single object', () => { + it('correctly constructs the clause', () => { + const dialect = d(); + const item = { + name: 'vercelliott', + email: 'wouldnt.you.like.to.know@vercel.com', + }; + dialect.values(new TqlValues(item)); + expect(queryBuilder.params).toEqual([item.name, item.email]); + expect(queryBuilder.query).toBe('("name", "email") VALUES ($1, $2)'); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const item = { + 'name"; SELECT * FROM privileged_information; --': + 'vercelliott; SELECT * FROM privileged_information; --', + email: 'wouldnt.you.like.to.know@vercel.com', + }; + dialect.values(new TqlValues(item)); + expect(queryBuilder.params).toEqual([ + item['name"; SELECT * FROM privileged_information; --'], + item.email, + ]); + expect(queryBuilder.query).toBe( + '("name""; SELECT * FROM privileged_information; --", "email") VALUES ($1, $2)', + ); + }); + + it('retains complex types', () => { + const dialect = d(); + const item = { + name: 'vercelliott', + email: 'wouldnt.you.like.to.know@vercel.com', + address: { street: 'go away' }, + }; + dialect.values(new TqlValues(item)); + expect(queryBuilder.params).toEqual([ + item.name, + item.email, + item.address, + ]); + expect(queryBuilder.query).toBe( + '("name", "email", "address") VALUES ($1, $2, $3)', + ); + }); + }); + + describe('multiple objects', () => { + it('correctly constructs the clause', () => { + const dialect = d(); + const items = [ + { name: 'vercelliott', email: 'wouldnt.you.like.to.know@vercel.com' }, + { name: 'farewelliott', email: 'go-away@somewhere-else.com' }, + ]; + dialect.values(new TqlValues(items)); + expect(queryBuilder.params).toEqual([ + items[0]?.name, + items[0]?.email, + items[1]?.name, + items[1]?.email, + ]); + expect(queryBuilder.query).toBe( + '("name", "email") VALUES ($1, $2), ($3, $4)', + ); + }); + + it('correctly constructs the clause when objects have different key orders', () => { + const dialect = d(); + const items = [ + { name: 'vercelliott', email: 'wouldnt.you.like.to.know@vercel.com' }, + { email: 'go-away@somewhere-else.com', name: 'farewelliott' }, + ]; + dialect.values(new TqlValues(items)); + expect(queryBuilder.params).toEqual([ + items[0]?.name, + items[0]?.email, + items[1]?.name, + items[1]?.email, + ]); + expect(queryBuilder.query).toBe( + '("name", "email") VALUES ($1, $2), ($3, $4)', + ); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const items = [ + { + 'name"; SELECT * FROM privileged_information; --': + 'vercelliott; SELECT * FROM privileged_information; --', + email: 'wouldnt.you.like.to.know@vercel.com', + }, + { + email: 'go-away@somewhere-else.com', + 'name"; SELECT * FROM privileged_information; --': + 'vercelliott; SELECT * FROM privileged_information; --', + }, + ]; + dialect.values(new TqlValues(items)); + expect(queryBuilder.params).toEqual([ + items[0]?.['name"; SELECT * FROM privileged_information; --'], + items[0]?.email, + items[1]?.['name"; SELECT * FROM privileged_information; --'], + items[1]?.email, + ]); + expect(queryBuilder.query).toBe( + '("name""; SELECT * FROM privileged_information; --", "email") VALUES ($1, $2), ($3, $4)', + ); + }); + + it('retains complex types', () => { + const dialect = d(); + const items = [ + { + name: 'carouselliott', + email: 'wouldnt.you.like.to.know@vercel.com', + address: { street: 'go away' }, + }, + { + name: 'parallelliott', + email: 'wouldnt.you.like.to.know@vercel.com', + address: { street: 'go away' }, + }, + ]; + dialect.values(new TqlValues(items)); + expect(queryBuilder.params).toEqual([ + items[0]?.name, + items[0]?.email, + items[0]?.address, + items[1]?.name, + items[1]?.email, + items[1]?.address, + ]); + expect(queryBuilder.query).toBe( + '("name", "email", "address") VALUES ($1, $2, $3), ($4, $5, $6)', + ); + }); + + it('throws when subsequent value objects have missing keys', () => { + const dialect = d(); + const items = [ + { name: 'excelliott', email: 'nope@nunya.com' }, + { name: 'luddite' }, + ]; + let error: TqlError<'values_records_mismatch'> | null = null; + try { + dialect.values(new TqlValues(items)); + } catch (e) { + // @ts-expect-error this is a test, bro + error = e; + } + expect(error).toBeInstanceOf(TqlError); + expect(error?.code).toBe('values_records_mismatch'); + }); + }); + }); + + describe('set', () => { + it('correctly constructs the clause', () => { + const dialect = d(); + const setRecord = { name: 'vercelliott', 'address.zip': '00000' }; + dialect.set(new TqlSet(setRecord)); + expect(queryBuilder.params).toEqual(['vercelliott', '00000']); + expect(queryBuilder.query).toBe('SET "name" = $1, "address"."zip" = $2'); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const item = { + 'name"; SELECT * FROM privileged_information; --': + 'vercelliott; SELECT * FROM privileged_information; --', + email: 'wouldnt.you.like.to.know@vercel.com', + }; + dialect.set(new TqlSet(item)); + expect(queryBuilder.params).toEqual([ + item['name"; SELECT * FROM privileged_information; --'], + item.email, + ]); + expect(queryBuilder.query).toBe( + 'SET "name""; SELECT * FROM privileged_information; --" = $1, "email" = $2', + ); + }); + }); +}); diff --git a/packages/tql/src/dialects/postgres.ts b/packages/tql/src/dialects/postgres.ts new file mode 100644 index 000000000..0ef44506d --- /dev/null +++ b/packages/tql/src/dialects/postgres.ts @@ -0,0 +1,104 @@ +import { IdenticalColumnValidator } from '../values-object-validator'; +import type { DialectImpl } from '../types'; +import { BaseDialect } from '../dialect'; +import { + type TqlIdentifiers, + type TqlList, + type TqlParameter, + type TqlTemplateString, + type TqlSet, + type TqlValues, +} from '../nodes.js'; + +export class PostgresDialect extends BaseDialect implements DialectImpl { + string(str: TqlTemplateString): void { + this.appendToQuery(str.value); + } + + parameter(param: TqlParameter): void { + const paramNumber = this.appendToParams(param.value); + this.appendToQuery(`$${paramNumber}`); + } + + identifiers(ids: TqlIdentifiers): void { + if (Array.isArray(ids.values)) { + this.appendToQuery( + ids.values.map((v) => PostgresDialect.escapeIdentifier(v)).join(', '), + ); + } else { + this.appendToQuery(PostgresDialect.escapeIdentifier(ids.values)); + } + } + + list(vals: TqlList): void { + this.appendToQuery('('); + const queryItems: string[] = []; + for (const param of vals.values) { + const paramNumber = this.appendToParams(param); + queryItems.push(`$${paramNumber}`); + } + this.appendToQuery(queryItems.join(', ')); + this.appendToQuery(')'); + } + + values(entries: TqlValues): void { + if (Array.isArray(entries.values)) { + // it's multiple entries + const validator = new IdenticalColumnValidator(); + let first = true; + let columns: string[] = []; + const rows: string[] = []; + for (const entry of entries.values) { + validator.validate(entry); + if (first) { + first = false; + columns = Object.keys(entry); + // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method + this.appendToQuery( + `(${columns + .map(PostgresDialect.escapeIdentifier) + .join(', ')}) VALUES `, + ); + } + const queryItems: string[] = []; + for (const column of columns) { + const paramNumber = this.appendToParams(entry[column]); + queryItems.push(`$${paramNumber}`); + } + rows.push(`(${queryItems.join(', ')})`); + } + this.appendToQuery(rows.join(', ')); + } else { + // it's a single entry + const entry = entries.values; + const columns = Object.keys(entry); + // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method + this.appendToQuery( + `(${columns.map(PostgresDialect.escapeIdentifier).join(', ')}) VALUES `, + ); + const queryItems: string[] = []; + for (const column of columns) { + const paramNumber = this.appendToParams(entry[column]); + queryItems.push(`$${paramNumber}`); + } + this.appendToQuery(`(${queryItems.join(', ')})`); + } + } + + set(entry: TqlSet): void { + this.appendToQuery('SET '); + const columns = Object.keys(entry.values); + const queryItems: string[] = []; + for (const column of columns) { + const paramNumber = this.appendToParams(entry.values[column]); + queryItems.push( + `${PostgresDialect.escapeIdentifier(column)} = $${paramNumber}`, + ); + } + this.appendToQuery(queryItems.join(', ')); + } + + private static escapeIdentifier(value: string): string { + return `"${value.replace(/"/g, '""').replace(/\./g, '"."')}"`; + } +} diff --git a/packages/tql/src/dialects/test-dialect.ts b/packages/tql/src/dialects/test-dialect.ts new file mode 100644 index 000000000..cdf06966e --- /dev/null +++ b/packages/tql/src/dialects/test-dialect.ts @@ -0,0 +1,44 @@ +import type { Dialect, DialectImpl } from '../types'; +import { BaseDialect } from '../dialect'; +import type { TqlQuery } from '../nodes'; + +export function createTestDialect(): { + Dialect: Dialect; + mocks: { + string: jest.MockedFunction; + parameter: jest.MockedFunction; + identifiers: jest.MockedFunction; + list: jest.MockedFunction; + values: jest.MockedFunction; + set: jest.MockedFunction; + preprocess: jest.MockedFunction; + postprocess: jest.MockedFunction; + }; +} { + const mocks = { + string: jest.fn(), + parameter: jest.fn(), + identifiers: jest.fn(), + list: jest.fn(), + values: jest.fn(), + set: jest.fn(), + preprocess: jest.fn((fragment) => fragment), + postprocess: jest.fn<[string, unknown[]], [string, unknown[]]>( + (query, params) => [query, params], + ), + }; + class TestDialect extends BaseDialect implements DialectImpl { + string = mocks.string; + parameter = mocks.parameter; + identifiers = mocks.identifiers; + list = mocks.list; + values = mocks.values; + set = mocks.set; + preprocess = mocks.preprocess; + postprocess = mocks.postprocess; + } + return { + Dialect: TestDialect, + mocks, + }; +} diff --git a/packages/tql/src/error.test.ts b/packages/tql/src/error.test.ts new file mode 100644 index 000000000..2a3877404 --- /dev/null +++ b/packages/tql/src/error.test.ts @@ -0,0 +1,27 @@ +import { TqlError } from './error'; + +// eslint-disable-next-line jest/prefer-lowercase-title -- it's a class man, calm down +describe('TqlError', () => { + it('should throw with a formatted message', () => { + expect(() => { + throw new TqlError('dialect_method_not_implemented', 'foo'); + }).toThrow('The dialect you are using does not implement this method: foo'); + }); + it('should pass instance checks', () => { + const err = new TqlError('dialect_method_not_implemented', 'foo'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(TqlError); + }); +}); + +// @ts-expect-error - Should error if too many arguments are provided given an error code +// eslint-disable-next-line no-new -- this is a typescript test, not a side effect +new TqlError('illegal_query_recursion', 'abc', 'abc'); + +// @ts-expect-error - Should error if too few arguments are provided given an error code +// eslint-disable-next-line no-new -- this is a typescript test, not a side effect +new TqlError('dialect_method_not_implemented'); + +// @ts-expect-error - Should error if an argument of the wrong type is provided given an error code +// eslint-disable-next-line no-new -- this is a typescript test, not a side effect +new TqlError('dialect_method_not_implemented', 1); diff --git a/packages/tql/src/error.ts b/packages/tql/src/error.ts new file mode 100644 index 000000000..5e3ad7e77 --- /dev/null +++ b/packages/tql/src/error.ts @@ -0,0 +1,42 @@ +export class TqlError extends Error { + constructor( + public code: T, + ...details: Parameters<(typeof messages)[T]> + ) { + // @ts-expect-error - This is super hard to type but it does work correctly + super(`tql: ${messages[code](...details)}`); + this.name = 'TqlError'; + } +} + +export interface ColumnDiff { + template: string[]; + plus: string[]; + minus: string[]; +} + +const messages = { + untemplated_sql_call: () => + "It looks like you tried to call a tagged template function as a regular JavaScript function. If your code looks like tql('SELECT *'), it should instead look like sql`SELECT *`", + dialect_method_not_implemented: (method: string) => + `The dialect you are using does not implement this method: ${method}`, + values_records_mismatch: (diff: ColumnDiff) => + formatValuesRecordsMismatchMessage(diff), + values_records_empty: () => + 'The records passed to `values` must not be empty.', + illegal_query_recursion: () => + 'Found a nested call to `query`. If you need to nest queries, use `fragment`.', +} as const satisfies Record string>; + +function formatValuesRecordsMismatchMessage(diff: ColumnDiff): string { + let message = `The records passed to \`values\` were invalid. Each record must have the same columns as all other records. Based on the first record's columns:\n - ${diff.template.join( + '\n - ', + )}`; + if (diff.minus.length > 0) { + message += `\n\nThese columns are missing:\n - ${diff.minus.join('\n - ')}`; + } + if (diff.plus.length > 0) { + message += `\n\nThese columns are extra:\n - ${diff.plus.join('\n - ')}`; + } + return message; +} diff --git a/packages/tql/src/index.test.ts b/packages/tql/src/index.test.ts new file mode 100644 index 000000000..cb6896a8a --- /dev/null +++ b/packages/tql/src/index.test.ts @@ -0,0 +1,306 @@ +import { + TqlQuery, + TqlFragment, + TqlIdentifiers, + TqlList, + TqlParameter, + TqlTemplateString, + TqlValues, +} from './nodes'; +import { createTestDialect } from './dialects/test-dialect'; +import type { Tql } from './types'; +import { createQueryBuilder } from './utils'; +import { PostgresDialect, init } from './index'; + +describe('exports', () => { + const { Dialect, mocks } = createTestDialect(); + let query: Tql['query']; + let fragment: Tql['fragment']; + let identifiers: Tql['identifiers']; + let list: Tql['list']; + let values: Tql['values']; + let unsafe: Tql['unsafe']; + + beforeEach(() => { + jest.clearAllMocks(); + ({ query, fragment, identifiers, list, values, unsafe } = init({ + dialect: Dialect, + })); + }); + + // This suite should really just verify that `query` is adhering to the contract it has with Dialect + // i.e. that it's calling the correct dialect methods with the correct data given a certain input. + describe('query and fragment', () => { + it('only calls the dialect `string` method for a simple query', () => { + query`SELECT * FROM users`; + expect(mocks.preprocess).toHaveBeenCalledTimes(1); + expect(mocks.preprocess).toHaveBeenCalledWith( + new TqlQuery([new TqlTemplateString('SELECT * FROM users')]), + ); + expect(mocks.string).toHaveBeenCalledTimes(1); + expect(mocks.string).toHaveBeenCalledWith( + new TqlTemplateString('SELECT * FROM users'), + ); + expect(mocks.parameter).not.toHaveBeenCalled(); + expect(mocks.identifiers).not.toHaveBeenCalled(); + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.values).not.toHaveBeenCalled(); + expect(mocks.postprocess).toHaveBeenCalledTimes(1); + expect(mocks.postprocess).toHaveBeenCalledWith('', []); + }); + + it('allows arrays as values', () => { + const filters = [ + fragment`AND user_id = ${1234}`, + fragment`AND user_name = ${'retelliott'}`, + 1234, + ]; + query`SELECT * FROM users WHERE 1=1 ${filters}`; + expect(mocks.preprocess).toHaveBeenCalledTimes(1); + expect(mocks.preprocess).toHaveBeenCalledWith( + new TqlQuery([ + new TqlTemplateString('SELECT * FROM users WHERE 1=1 '), + new TqlFragment([ + new TqlTemplateString('AND user_id = '), + new TqlParameter(1234), + new TqlTemplateString(''), + ]), + new TqlFragment([ + new TqlTemplateString('AND user_name = '), + new TqlParameter('retelliott'), + new TqlTemplateString(''), + ]), + new TqlParameter(1234), + new TqlTemplateString(''), + ]), + ); + expect(mocks.string).toHaveBeenCalledTimes(6); + expect(mocks.string).toHaveBeenNthCalledWith( + 1, + new TqlTemplateString('SELECT * FROM users WHERE 1=1 '), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 2, + new TqlTemplateString('AND user_id = '), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 3, + new TqlTemplateString(''), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 4, + new TqlTemplateString('AND user_name = '), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 5, + new TqlTemplateString(''), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 6, + new TqlTemplateString(''), + ); + expect(mocks.parameter).toHaveBeenCalledTimes(3); + expect(mocks.parameter).toHaveBeenNthCalledWith( + 1, + new TqlParameter(1234), + ); + expect(mocks.parameter).toHaveBeenNthCalledWith( + 2, + new TqlParameter('retelliott'), + ); + expect(mocks.parameter).toHaveBeenNthCalledWith( + 3, + new TqlParameter(1234), + ); + expect(mocks.identifiers).not.toHaveBeenCalled(); + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.values).not.toHaveBeenCalled(); + expect(mocks.postprocess).toHaveBeenCalledTimes(1); + expect(mocks.postprocess).toHaveBeenCalledWith('', []); + }); + + it('works recursively', () => { + query`SELECT * FROM (${fragment`SELECT * FROM users WHERE user_id = ${1234}`});`; + expect(mocks.preprocess).toHaveBeenCalledTimes(1); + expect(mocks.preprocess).toHaveBeenCalledWith( + new TqlQuery([ + new TqlTemplateString('SELECT * FROM ('), + new TqlFragment([ + new TqlTemplateString('SELECT * FROM users WHERE user_id = '), + new TqlParameter(1234), + new TqlTemplateString(''), + ]), + new TqlTemplateString(');'), + ]), + ); + expect(mocks.string).toHaveBeenCalledTimes(4); + expect(mocks.string).toHaveBeenNthCalledWith( + 1, + new TqlTemplateString('SELECT * FROM ('), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 2, + new TqlTemplateString('SELECT * FROM users WHERE user_id = '), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 3, + new TqlTemplateString(''), + ); + expect(mocks.string).toHaveBeenNthCalledWith( + 4, + new TqlTemplateString(');'), + ); + expect(mocks.parameter).toHaveBeenCalledTimes(1); + expect(mocks.parameter).toHaveBeenCalledWith(new TqlParameter(1234)); + expect(mocks.identifiers).not.toHaveBeenCalled(); + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.values).not.toHaveBeenCalled(); + expect(mocks.postprocess).toHaveBeenCalledTimes(1); + expect(mocks.postprocess).toHaveBeenCalledWith('', []); + }); + }); + + describe('identifiers', () => { + it('creates an identifiers object', () => { + const result = identifiers(['column_name_1', 'column_name_2']); + expect(result).toBeInstanceOf(TqlIdentifiers); + expect(result.values).toEqual(['column_name_1', 'column_name_2']); + }); + }); + + describe('list', () => { + it('creates a list object', () => { + const result = list(['column_name_1', 'column_name_2']); + expect(result).toBeInstanceOf(TqlList); + expect(result.values).toEqual(['column_name_1', 'column_name_2']); + }); + }); + + describe('values', () => { + it('creates a values object given a single object', () => { + const value = { one: 1, two: 2, three: { number: 3 } }; + const result = values(value); + expect(result).toBeInstanceOf(TqlValues); + expect(result.values).toEqual(value); + }); + + it('creates a values object given an array of objects', () => { + const value = [ + { one: 1, two: 2, three: { number: 3 } }, + { one: 1, two: 2, three: { number: 3 } }, + ]; + const result = values(value); + expect(result).toBeInstanceOf(TqlValues); + expect(result.values).toEqual(value); + }); + }); + + describe('unsafe', () => { + it('creates a TqlTemplateString object', () => { + const result = unsafe(`SELECT * FROM users WHERE id = ${1234}`); + expect(result).toBeInstanceOf(TqlTemplateString); + expect(result.value).toEqual('SELECT * FROM users WHERE id = 1234'); + }); + }); +}); + +describe('integration', () => { + describe('postgres', () => { + let query: Tql['query']; + let fragment: Tql['fragment']; + let identifiers: Tql['identifiers']; + let list: Tql['list']; + let values: Tql['values']; + + beforeEach(() => { + const qb = createQueryBuilder(); + qb.appendToParams = jest.fn().mockImplementation(qb.appendToParams); + qb.appendToQuery = jest.fn().mockImplementation(qb.appendToQuery); + ({ query, fragment, identifiers, list, values } = init({ + dialect: PostgresDialect, + })); + }); + + it('produces a correct SELECT query', () => { + const [q, params] = query` + SELECT * FROM users WHERE user_id = ${1234}; + `; + expect(q).toBe( + '\n SELECT * FROM users WHERE user_id = $1;\n ', + ); + expect(params).toEqual([1234]); + }); + + it('produces a correct INSERT query', () => { + const [q, params] = query` + INSERT INTO users ${values({ name: 'vercelliott' })}; + `; + expect(q).toBe( + '\n INSERT INTO users ("name") VALUES ($1);\n ', + ); + expect(params).toEqual(['vercelliott']); + }); + + it('produces a correct query with a list', () => { + const [q, params] = query` + SELECT * FROM users WHERE user_id IN ${list([1, 2, 3, 4])}; + `; + expect(q).toBe( + '\n SELECT * FROM users WHERE user_id IN ($1, $2, $3, $4);\n ', + ); + expect(params).toEqual([1, 2, 3, 4]); + }); + + it('produces a correct query with dynamic identifiers', () => { + const [q, params] = query` + SELECT ${identifiers('name')}, ${identifiers([ + 'email', + 'birthdate', + 'gender', + ])} FROM users WHERE user_id = ${1234}; + `; + expect(q).toBe( + '\n SELECT "name", "email", "birthdate", "gender" FROM users WHERE user_id = $1;\n ', + ); + expect(params).toEqual([1234]); + }); + + it('works with recursive queries, example from jsdoc', () => { + const familyName = 'Smith'; + const familyFragment = fragment`SELECT * FROM users WHERE family_name = ${familyName}`; + const [jobsHeldByFamilyMembersQuery, params] = query` + WITH family AS (${familyFragment}) + SELECT * FROM jobs INNER JOIN family ON jobs.user_id = family.user_id; + `; + expect(jobsHeldByFamilyMembersQuery).toBe(` + WITH family AS (SELECT * FROM users WHERE family_name = $1) + SELECT * FROM jobs INNER JOIN family ON jobs.user_id = family.user_id; + `); + expect(params).toEqual(['Smith']); + }); + + it('correctly parameterizes complex queries', () => { + const insertClause = fragment`INSERT INTO users ${values({ + name: 'vercelliott', + })}`; + const updateClause = fragment`UPDATE users SET name = ${'reselliott'} WHERE name = ${'vercelliott'}`; + const selectClause = fragment`SELECT * FROM users WHERE name = ${'reselliott'}`; + const [q, params] = query` + ${insertClause}; + ${updateClause}; + ${selectClause}; + `; + expect(q).toBe(` + INSERT INTO users ("name") VALUES ($1); + UPDATE users SET name = $2 WHERE name = $3; + SELECT * FROM users WHERE name = $4; + `); + expect(params).toEqual([ + 'vercelliott', + 'reselliott', + 'vercelliott', + 'reselliott', + ]); + }); + }); +}); diff --git a/packages/tql/src/index.ts b/packages/tql/src/index.ts new file mode 100644 index 000000000..58908f1a6 --- /dev/null +++ b/packages/tql/src/index.ts @@ -0,0 +1,84 @@ +import { createQueryBuilder, isTemplateStringsArray } from './utils'; +import { TqlError } from './error'; +import type { CompiledQuery, Init } from './types'; +import { + TqlIdentifiers, + TqlList, + TqlNode, + TqlParameter, + TqlQuery, + TqlFragment, + TqlTemplateString, + TqlValues, + type TqlNodeType, + TqlSet, +} from './nodes'; +import { build } from './build'; + +export type * from './nodes'; +export type * from './types'; +export { PostgresDialect } from './dialects/postgres'; + +export const init: Init = ({ dialect: Dialect }) => { + return { + query: (strings, ...values): CompiledQuery => { + const query = parseTemplate(TqlQuery, strings, values); + const qb = createQueryBuilder(); + const d = new Dialect(qb.appendToQuery, qb.appendToParams); + const preprocessed = d.preprocess(query); + build(d, preprocessed); + return d.postprocess(qb.query, qb.params); + }, + fragment: (strings, ...values) => + parseTemplate(TqlFragment, strings, values), + identifiers: (ids) => new TqlIdentifiers(ids), + list: (vals) => new TqlList(vals), + values: (entries) => new TqlValues(entries), + set: (entries) => new TqlSet(entries), + unsafe: (str) => new TqlTemplateString(str), + }; +}; + +function parseTemplate( + FragmentCtor: new ( + nodes: TqlNode>[], + ) => TResult, + strings: TemplateStringsArray, + values: unknown[], +): TResult { + if ( + !isTemplateStringsArray(strings) || + !Array.isArray(values) || + strings.length !== values.length + 1 + ) { + throw new TqlError('untemplated_sql_call'); + } + + const nodes: TqlNode>[] = []; + + let nodeInsertIndex = 0; + for (let i = 0; i < strings.length; i++) { + // @ts-expect-error -- the line above this makes this clearly valid + nodes[nodeInsertIndex++] = new TqlTemplateString(strings[i]); + + if (i === values.length) { + continue; + } + + const interpolatedValues = ( + Array.isArray(values[i]) ? values[i] : [values[i]] + ) as unknown[]; + + for (const value of interpolatedValues) { + if (!(value instanceof TqlNode)) { + nodes[nodeInsertIndex++] = new TqlParameter(value ?? null); // disallow undefined + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- this is silly generic stuff + nodes[nodeInsertIndex++] = value; + } + } + + return new FragmentCtor(nodes); +} diff --git a/packages/tql/src/nodes.test.ts b/packages/tql/src/nodes.test.ts new file mode 100644 index 000000000..16257e5a2 --- /dev/null +++ b/packages/tql/src/nodes.test.ts @@ -0,0 +1,57 @@ +import { + TqlIdentifiers, + TqlList, + TqlNode, + TqlParameter, + TqlQuery, + TqlFragment, + TqlTemplateString, + TqlValues, +} from './nodes'; + +describe('nodes', () => { + it.each([ + { + type: 'identifiers', + Ctor: TqlIdentifiers, + instance: new TqlIdentifiers(['foo', 'bar']), + }, + { + type: 'list', + Ctor: TqlList, + instance: new TqlList([1, 2, 3]), + }, + { + type: 'parameter', + Ctor: TqlParameter, + instance: new TqlParameter(1), + }, + { + type: 'string', + Ctor: TqlTemplateString, + instance: new TqlTemplateString('foo'), + }, + { + type: 'values', + Ctor: TqlValues, + instance: new TqlValues([{ foo: 'bar' }]), + }, + { + type: 'query', + Ctor: TqlQuery, + instance: new TqlQuery([]), + }, + { + type: 'fragment', + Ctor: TqlFragment, + instance: new TqlFragment([]), + }, + ])( + 'should pass instanceof checks for each node type', + ({ type, Ctor, instance }) => { + expect(instance.type).toBe(type); + expect(instance).toBeInstanceOf(Ctor); + expect(instance).toBeInstanceOf(TqlNode); + }, + ); +}); diff --git a/packages/tql/src/nodes.ts b/packages/tql/src/nodes.ts new file mode 100644 index 000000000..005b7ead2 --- /dev/null +++ b/packages/tql/src/nodes.ts @@ -0,0 +1,109 @@ +import type { SetObject, ValuesObject } from './types'; + +export const tqlNodeTypes = [ + 'list', + 'parameter', + 'string', + 'values', + 'update-set', + 'query', + 'fragment', + 'identifiers', +] as const; + +export type TqlNodeType = (typeof tqlNodeTypes)[number]; + +export class TqlNode { + constructor(public readonly type: T) {} +} + +/** + * A list of identifiers. Can be used to provide multiple column names in a SELECT or GROUP BY clause, for example, or + * multiple table names in a comma-separated JOIN. The dialect is responsible for formatting the list appropriately. + */ +export class TqlIdentifiers extends TqlNode<'identifiers'> { + constructor(public readonly values: string | string[]) { + super('identifiers'); + } +} + +/** + * A list of values for use in an `IN` clause. + */ +export class TqlList extends TqlNode<'list'> { + constructor(public readonly values: unknown[]) { + super('list'); + } +} + +/** + * A parameter value. + */ +export class TqlParameter extends TqlNode<'parameter'> { + constructor(public readonly value: unknown) { + super('parameter'); + } +} + +/** + * A string literal. These are ONLY created by the `tql` template tag -- i.e. these strings are written by the developer, + * not the user. + */ +export class TqlTemplateString extends TqlNode<'string'> { + constructor(public readonly value: string) { + super('string'); + } +} + +/** + * A VALUES clause. The ValuesObject is either a record or an array of records. The record keys are column names + * and the record values are the values for each column. The dialect should write both "sides" of the VALUES clause, + * i.e. `("col_1", "col_2") VALUES ($1, $2)`. + */ +export class TqlValues extends TqlNode<'values'> { + constructor(public readonly values: ValuesObject) { + super('values'); + } +} + +/** + * A SET clause. Given a record, the record keys are column names and the corresponding values are the values for that column. + * The dialect should write the full SET clause, i.e. `SET "col_1" = $1, "col_2" = $2`. + */ +export class TqlSet extends TqlNode<'update-set'> { + constructor(public readonly values: SetObject) { + super('update-set'); + } +} + +/** + * This represents the input to a `fragment` tagged function. It is a list of nodes, which can be either strings (represented by {@link TqlTemplateString}) + * or any other {@link TqlNode} type, including other {@link TqlFragment} instances. (This would occur in nested calls to `fragment`.) It cannot contain + * {@link TqlQuery} instances. + * + * ### Developer note: Why are there both Query and Fragment types? + * + * Fundamentally, the value returned from a `query` function needs compile to a string and a list of parameters. In order to be able to + * recursively nest calls to `query`, we _also_ need to be able to determine that whatever "thing" is returned from `query` can be told + * apart from any other JavaScript object, and it must be doable in such a way that it couldn't be faked by something coming from outside + * the application (eg. a JSON document passed to `JSON.parse`). This basically leaves us with two possibilities: + * + * 1. Make the `query` function recursively-nestable by having it return a class instance with a `build` method. This would mean every call + * to `query` would have to end in a call to `build`, which makes the most-common usecase (a non-nested query) worse. + * 2. Have two separate functions, one which returns a nestable value, and the other which returns a compiled query. Hence, `query` and `fragment`. + */ +export class TqlFragment extends TqlNode<'fragment'> { + constructor(public readonly nodes: TqlNode>[]) { + super('fragment'); + } +} + +/** + * This represents the input to a `query` tagged function. It is a list of nodes, which can be either strings (represented by {@link TqlTemplateString}) + * or any other {@link TqlNode} type, including {@link TqlFragment} instances. It cannot recursively include {@link TqlQuery} instances. + */ +export class TqlQuery extends TqlNode<'query'> { + constructor(public readonly nodes: TqlNode>[]) { + super('query'); + } +} diff --git a/packages/tql/src/types.ts b/packages/tql/src/types.ts new file mode 100644 index 000000000..9cdbcf478 --- /dev/null +++ b/packages/tql/src/types.ts @@ -0,0 +1,186 @@ +import type { + TqlIdentifiers, + TqlList, + TqlParameter, + TqlQuery, + TqlFragment, + TqlTemplateString, + TqlValues, + TqlSet, +} from './nodes'; + +export type SetObject = Record; +export type ValuesObject = Record | Record[]; + +export interface DialectImpl { + preprocess: (fragment: TqlQuery) => TqlQuery; + string: (str: TqlTemplateString) => void; + parameter: (param: TqlParameter) => void; + identifiers: (ids: TqlIdentifiers) => void; + list: (vals: TqlList) => void; + values: (entries: TqlValues) => void; + set: (entries: TqlSet) => void; + postprocess: (query: string, params: unknown[]) => [string, unknown[]]; +} + +export type Dialect = new ( + appendToQuery: (...values: string[]) => number, + appendToParams: (...values: unknown[]) => number, +) => DialectImpl; + +export type CompiledQuery = [string, unknown[]]; + +export interface Tql { + /** + * Builds a SQL query out of a JavaScript tagged template. + * @example + * Examples are using the Postgres dialect for their output. + * + * ### A simple query: + * ```ts + * const userId = 1234; + * const [q, params] = query`SELECT * FROM users WHERE id = ${userId};`; + * // ["SELECT * FROM users WHERE id = $1;", [1234]] + * ``` + * + * ### Insert values: + * ```ts + * // This uses an array of records, but you can also use a single record if you only need to insert one row. + * const rows = [ + * { name: 'Alice', age: 30 }, + * { name: 'Bob', age: 40 }, + * ] + * const [q, params] = query`INSERT INTO users ${values(rows)}`; + * // ['INSERT INTO users ("name", "age") VALUES ($1, $2), ($3, $4);', ['Alice', 30, 'Bob', 40]] + * ``` + * + * ### A single dynamic identifier: + * ```ts + * const column = 'name'; + * const [q, params] = query`SELECT ${identifier(column)} FROM users`; + * // ['SELECT "name" FROM users', []] + * ``` + * + * ### Dynamic identifiers: + * ```ts + * const columns = ['name', 'age']; + * const [q, params] = query`SELECT ${identifiers(columns)} FROM users`; + * // ['SELECT "name", "age" FROM users', []] + * ``` + * + * @returns A tuple of the query string and the parameters to be passed to the database driver. + */ + query: (strings: TemplateStringsArray, ...values: unknown[]) => CompiledQuery; + + /** + * Builds a SQL fragment out of a JavaScript tagged template. Works the same as {@link query}, but returns a value + * that can be nested recursively and included in calls to {@link query}. + * + * ### Build a query out of multiple variable declarations: + * ```ts + * const familyName = 'Smith'; + * const familyFragment = fragment`SELECT * FROM users WHERE family_name = ${familyName}`; + * const [jobsHeldByFamilyMembersQuery, params] = query` + * WITH family AS (${familyFragment}) + * SELECT * FROM jobs INNER JOIN family ON jobs.user_id = family.user_id; + * `; + * // [ + * // ` + * // WITH family AS (SELECT * FROM users WHERE family_name = $1) + * // SELECT * FROM jobs INNER JOIN family ON jobs.user_id = family.user_id; + * // `, + * // ['Smith'] + * // ] + * ``` + * @returns A value that can be included recursively in other calls to {@link fragment} and in calls to {@link query}. + */ + fragment: ( + strings: TemplateStringsArray, + ...values: unknown[] + ) => TqlFragment; + + /** + * The same as {@link identifier}, but for multiple identifiers. These will be comma-separated by the driver. + * @param ids - The IDs to escape. + * @returns A representation of the identifiers that will be escaped by {@link query}. + */ + identifiers: (ids: string | string[]) => TqlIdentifiers; + + /** + * For use with the IN operator or anywhere else a parenthesis-list of values is needed. + * + * @example + * ```ts + * const [q, params] = query`SELECT * FROM users WHERE id IN ${list([1, 2, 3])}`; + * // ['SELECT * FROM users WHERE id IN ($1, $2, $3)', [1, 2, 3]] + * ``` + * + * @param vals - The values to build into a list. + * @returns A representation of the list that will be escaped by {@link query}. + */ + list: (vals: unknown[]) => TqlList; + + /** + * For use with the VALUES clause of an INSERT statement. Can insert one or multiple rows. If multiple + * rows are provided, the columns must be the same for each row. A different number of columns or the same + * number of columns with different names will throw a runtime error. + * + * @example + * ### A single row: + * ```ts + * const value = { name: 'Alice', age: 30 }; + * const [q, params] = query`INSERT INTO users ${values(value)}`; + * // ['INSERT INTO users ("name", "age") VALUES ($1, $2);', ['Alice', 30]] + * ``` + * + * ### Multiple rows: + * ```ts + * const values = [ + * { name: 'Alice', age: 30 }, + * { name: 'Bob', age: 40 }, + * ]; + * const [q, params] = query`INSERT INTO users ${values(values)}`; + * // ['INSERT INTO users ("name", "age") VALUES ($1, $2), ($3, $4);', ['Alice', 30, 'Bob', 40]] + * ``` + * + * @param entries - The value or values to insert. + * @returns A representation of the values that will be escaped by {@link query}. + */ + values: (entries: ValuesObject) => TqlValues; + + /** + * A SET clause. Given a record, the record keys are column names and the corresponding values are the values for that column. + * + * @example + * ### Update a record + * ```ts + * const userId = 1234; + * const updatedUser = { name: 'vercelliott' }; + * const [q, params] = query`UPDATE users ${set(updatedUser)} WHERE user_id = ${userId};`; + * // ['UPDATE users SET "name" = $1 WHERE user_id = $2;', ['vercelliott', 1234]] + * + * @param entry An object representing this SET clause. + * @returns A representation of the SET clause to be passed to {@link query}. + * ``` + */ + set: (entry: SetObject) => TqlSet; + + /** + * A raw string that will be inserted into the query as-is. + * **WARNING**: This WILL expose your query to SQL injection attacks if you use it with user input. It should + * _only_ be used with trusted, escaped input. + * @example + * ### Expose yourself to SQL injection attacks: + * ```ts + * const userInputName = "Robert'); DROP TABLE students; --"; + * const [q, params] = query(`INSERT INTO students ("name") VALUES ('${unsafe(userInputName)});`); + * // INSERT INTO students ("name") VALUES ('Robert'); DROP TABLE students; --'); + * ``` + * Don't do this, obviously. + * + * @returns A representation of the string that will be inserted into the query as-is by {@link query} and {@link fragment}. + */ + unsafe: (str: string) => TqlTemplateString; +} + +export type Init = (options: { dialect: Dialect }) => Tql; diff --git a/packages/tql/src/utils.test.ts b/packages/tql/src/utils.test.ts new file mode 100644 index 000000000..0e010064c --- /dev/null +++ b/packages/tql/src/utils.test.ts @@ -0,0 +1,25 @@ +import { isTemplateStringsArray } from './utils'; + +function getTemplateStringsArray( + strings: TemplateStringsArray, + ..._values: unknown[] +): TemplateStringsArray { + return strings; +} + +describe('isTemplateStringsArray', () => { + it('should return true for a TemplateStringsArray', () => { + const templateStringsArray = getTemplateStringsArray`hi ${'mom'}, hi ${'dad'}`; + expect(isTemplateStringsArray(templateStringsArray)).toBe(true); + }); + it('should return false for a non-TemplateStringsArray', () => { + expect(isTemplateStringsArray('foo')).toBe(false); + expect(isTemplateStringsArray(1)).toBe(false); + expect(isTemplateStringsArray(true)).toBe(false); + expect(isTemplateStringsArray({})).toBe(false); + expect(isTemplateStringsArray({ raw: 'hi' })).toBe(false); + expect(isTemplateStringsArray([])).toBe(false); + expect(isTemplateStringsArray(null)).toBe(false); + expect(isTemplateStringsArray(undefined)).toBe(false); + }); +}); diff --git a/packages/tql/src/utils.ts b/packages/tql/src/utils.ts new file mode 100644 index 000000000..2dda62c5e --- /dev/null +++ b/packages/tql/src/utils.ts @@ -0,0 +1,43 @@ +export function isTemplateStringsArray( + value: unknown, +): value is TemplateStringsArray { + return ( + Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'raw') + ); +} + +// TODO: Test +export function createQueryBuilder(): { + readonly query: string; + readonly params: unknown[]; + appendToQuery: (...values: string[]) => number; + appendToParams: (...values: unknown[]) => number; +} { + let query = ''; + const params: unknown[] = []; + const appendToQuery = (...values: string[]): number => { + query += values.join(''); + return query.length; + }; + const appendToParams = (...values: unknown[]): number => { + return params.push(...values); + }; + return { + get query(): string { + return query; + }, + get params(): unknown[] { + return params; + }, + appendToQuery, + appendToParams, + }; +} + +// TODO: test +export function pluralize(str: string, plural: boolean, suffix = 's'): string { + if (plural) { + return `${str}${suffix}`; + } + return str; +} diff --git a/packages/tql/src/values-object-validator.test.ts b/packages/tql/src/values-object-validator.test.ts new file mode 100644 index 000000000..0a5a91fe5 --- /dev/null +++ b/packages/tql/src/values-object-validator.test.ts @@ -0,0 +1,67 @@ +import { IdenticalColumnValidator } from './values-object-validator'; + +// eslint-disable-next-line jest/prefer-lowercase-title -- it's a class name +describe('IdenticalColumnValidator', () => { + it('should not throw when provided multiple records with the same keys, even in different orders', () => { + const validator = new IdenticalColumnValidator(); + expect(() => { + validator.validate({ one: 1, two: 2, three: 3 }); + validator.validate({ three: 3, one: 1, two: 2 }); + validator.validate({ two: 2, three: 3, one: 1 }); + }).not.toThrow(); + }); + it('should throw when passed a record with no keys', () => { + const validator = new IdenticalColumnValidator(); + expect(() => { + validator.validate({}); + }).toThrow('tql: The records passed to `values` must not be empty.'); + }); + it('should throw when passed a record with more keys than the first record', () => { + const validator = new IdenticalColumnValidator(); + expect(() => { + validator.validate({ one: 1, two: 2, three: 3 }); + validator.validate({ one: 1, two: 2, three: 3, four: 4 }); + }).toThrow( + `tql: The records passed to \`values\` were invalid. Each record must have the same columns as all other records. Based on the first record's columns: + - one + - two + - three + +These columns are extra: + - four`, + ); + }); + it('should throw when passed a record with fewer keys than the first record', () => { + const validator = new IdenticalColumnValidator(); + expect(() => { + validator.validate({ one: 1, two: 2, three: 3 }); + validator.validate({ one: 1, two: 2 }); + }).toThrow( + `tql: The records passed to \`values\` were invalid. Each record must have the same columns as all other records. Based on the first record's columns: + - one + - two + - three + +These columns are missing: + - three`, + ); + }); + it('should throw when passed a record with different keys than the first record', () => { + const validator = new IdenticalColumnValidator(); + expect(() => { + validator.validate({ one: 1, two: 2, three: 3 }); + validator.validate({ one: 1, two: 2, four: 4 }); + }).toThrow( + `tql: The records passed to \`values\` were invalid. Each record must have the same columns as all other records. Based on the first record's columns: + - one + - two + - three + +These columns are missing: + - three + +These columns are extra: + - four`, + ); + }); +}); diff --git a/packages/tql/src/values-object-validator.ts b/packages/tql/src/values-object-validator.ts new file mode 100644 index 000000000..87cdd8787 --- /dev/null +++ b/packages/tql/src/values-object-validator.ts @@ -0,0 +1,56 @@ +import { TqlError, type ColumnDiff } from './error'; + +/** + * Given a record, validates that the record has the same keys as all prior records passed to `validate`. + */ +export class IdenticalColumnValidator { + private readonly columns = new Set(); + + validate(entry: Record): void { + if (this.columns.size === 0) { + const cols = Object.keys(entry); + if (cols.length === 0) throw new TqlError('values_records_empty'); + cols.forEach((key) => this.columns.add(key)); + return; + } + + const currentRecordColumns = new Set(Object.keys(entry)); + if (this.columns.size !== currentRecordColumns.size) { + throw new TqlError( + 'values_records_mismatch', + diffColumns(this.columns, currentRecordColumns), + ); + } + for (const column of currentRecordColumns) { + if (!this.columns.has(column)) { + throw new TqlError( + 'values_records_mismatch', + diffColumns(this.columns, currentRecordColumns), + ); + } + } + } +} + +function diffColumns(template: Set, mismatch: Set): ColumnDiff { + const plus: string[] = []; + const minus: string[] = []; + const templateColumns = [...template.values()]; + for (const templateColumn of templateColumns) { + if (!mismatch.has(templateColumn)) { + minus.push(templateColumn); + } + } + + for (const mismatchedColumn of mismatch.values()) { + if (!template.has(mismatchedColumn)) { + plus.push(mismatchedColumn); + } + } + + return { + template: templateColumns, + plus, + minus, + }; +} diff --git a/packages/tql/tsconfig.json b/packages/tql/tsconfig.json new file mode 100644 index 000000000..226905bae --- /dev/null +++ b/packages/tql/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src"] +} diff --git a/packages/tql/tsup.config.js b/packages/tql/tsup.config.js new file mode 100644 index 000000000..0b5a5da08 --- /dev/null +++ b/packages/tql/tsup.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +// eslint-disable-next-line import/no-default-export -- [@vercel/style-guide@5 migration] +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + splitting: true, + sourcemap: true, + minify: false, + clean: true, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aaf0c560..e659ef85e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 0.2.5 ts-jest: specifier: 29.1.1 - version: 29.1.1(@babel/core@7.23.2)(esbuild@0.18.3)(jest@29.7.0)(typescript@5.2.2) + version: 29.1.1(@babel/core@7.23.2)(jest@29.7.0)(typescript@5.2.2) turbo: specifier: 1.10.16 version: 1.10.16 @@ -301,6 +301,48 @@ importers: specifier: 5.2.2 version: 5.2.2 + packages/tql: + devDependencies: + '@changesets/cli': + specifier: 2.26.2 + version: 2.26.2 + '@edge-runtime/jest-environment': + specifier: 2.3.6 + version: 2.3.6 + '@edge-runtime/types': + specifier: 2.2.6 + version: 2.2.6 + '@types/jest': + specifier: 29.5.7 + version: 29.5.7 + '@types/node': + specifier: 20.8.10 + version: 20.8.10 + eslint: + specifier: 8.52.0 + version: 8.52.0 + eslint-config-custom: + specifier: workspace:* + version: link:../../tooling/eslint-config-custom + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.8.10) + prettier: + specifier: 3.0.3 + version: 3.0.3 + ts-jest: + specifier: 29.1.1 + version: 29.1.1(@babel/core@7.23.2)(esbuild@0.18.3)(jest@29.7.0)(typescript@5.2.2) + tsconfig: + specifier: workspace:* + version: link:../../tooling/tsconfig + tsup: + specifier: 7.2.0 + version: 7.2.0(typescript@5.2.2) + typescript: + specifier: 5.2.2 + version: 5.2.2 + test/next: dependencies: '@types/node': @@ -480,15 +522,6 @@ packages: eslint-visitor-keys: 2.1.0 semver: 6.3.1 - /@babel/generator@7.22.10: - resolution: {integrity: sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.11 - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - jsesc: 2.5.2 - /@babel/generator@7.23.0: resolution: {integrity: sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==} engines: {node: '>=6.9.0'} @@ -750,14 +783,6 @@ packages: transitivePeerDependencies: - supports-color - /@babel/types@7.22.11: - resolution: {integrity: sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.20 - to-fast-properties: 2.0.0 - /@babel/types@7.23.0: resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==} engines: {node: '>=6.9.0'} @@ -772,7 +797,7 @@ packages: /@changesets/apply-release-plan@6.1.4: resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/config': 2.3.1 '@changesets/get-version-range-type': 0.3.2 '@changesets/git': 2.0.0 @@ -790,7 +815,7 @@ packages: /@changesets/assemble-release-plan@5.2.4: resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/errors': 0.1.4 '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 @@ -808,7 +833,7 @@ packages: resolution: {integrity: sha512-dnWrJTmRR8bCHikJHl9b9HW3gXACCehz4OasrXpMp7sx97ECuBGGNjJhjPhdZNCvMy9mn4BWdplI323IbqsRig==} hasBin: true dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/apply-release-plan': 6.1.4 '@changesets/assemble-release-plan': 5.2.4 '@changesets/changelog-git': 0.1.14 @@ -824,7 +849,7 @@ packages: '@changesets/write': 0.2.3 '@manypkg/get-packages': 1.1.3 '@types/is-ci': 3.0.0 - '@types/semver': 7.5.0 + '@types/semver': 7.5.4 ansi-colors: 4.1.3 chalk: 2.4.2 enquirer: 2.3.6 @@ -837,7 +862,7 @@ packages: p-limit: 2.3.0 preferred-pm: 3.0.3 resolve-from: 5.0.0 - semver: 7.5.3 + semver: 7.5.4 spawndamnit: 2.0.0 term-size: 2.2.1 tty-table: 4.2.1 @@ -925,7 +950,7 @@ packages: /@changesets/read@0.5.9: resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/git': 2.0.0 '@changesets/logger': 0.0.5 '@changesets/parse': 0.3.16 @@ -946,7 +971,7 @@ packages: /@changesets/write@0.2.3: resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/types': 5.2.1 fs-extra: 7.0.1 human-id: 1.0.2 @@ -1451,7 +1476,7 @@ packages: dependencies: '@babel/core': 7.23.2 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.20 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -1461,7 +1486,7 @@ packages: jest-regex-util: 29.6.3 jest-util: 29.7.0 micromatch: 4.0.5 - pirates: 4.0.5 + pirates: 4.0.6 slash: 3.0.0 write-file-atomic: 4.0.2 transitivePeerDependencies: @@ -1525,7 +1550,7 @@ packages: /@manypkg/find-root@1.1.0: resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -1534,7 +1559,7 @@ packages: /@manypkg/get-packages@1.1.3: resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -1877,10 +1902,6 @@ packages: resolution: {integrity: sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==} dev: false - /@types/semver@7.5.0: - resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} - dev: true - /@types/semver@7.5.4: resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==} @@ -5020,10 +5041,10 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/core': 7.23.2 - '@babel/generator': 7.22.10 + '@babel/generator': 7.23.0 '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.23.2) '@babel/plugin-syntax-typescript': 7.21.4(@babel/core@7.23.2) - '@babel/types': 7.22.11 + '@babel/types': 7.23.0 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 @@ -5954,14 +5975,9 @@ packages: engines: {node: '>=6'} dev: true - /pirates@4.0.5: - resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} - engines: {node: '>= 6'} - /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - dev: false /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} @@ -6813,20 +6829,6 @@ packages: react: 18.2.0 dev: false - /sucrase@3.32.0: - resolution: {integrity: sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==} - engines: {node: '>=8'} - hasBin: true - dependencies: - '@jridgewell/gen-mapping': 0.3.3 - commander: 4.1.1 - glob: 7.1.6 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.5 - ts-interface-checker: 0.1.13 - dev: true - /sucrase@3.34.0: resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} engines: {node: '>=8'} @@ -6839,7 +6841,6 @@ packages: mz: 2.7.0 pirates: 4.0.6 ts-interface-checker: 0.1.13 - dev: false /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -7053,6 +7054,40 @@ packages: yargs-parser: 21.1.1 dev: true + /ts-jest@29.1.1(@babel/core@7.23.2)(jest@29.7.0)(typescript@5.2.2): + resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.23.2 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.8.10) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.4 + typescript: 5.2.2 + yargs-parser: 21.1.1 + dev: true + /ts-node@10.9.1(@types/node@20.8.10)(typescript@5.2.2): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -7125,7 +7160,7 @@ packages: resolve-from: 5.0.0 rollup: 3.21.2 source-map: 0.8.0-beta.0 - sucrase: 3.32.0 + sucrase: 3.34.0 tree-kill: 1.2.2 typescript: 5.2.2 transitivePeerDependencies: From f4c346f567653ea850cc3a5451df44fa4cde558d Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 27 Nov 2023 11:36:14 -0700 Subject: [PATCH 2/9] changeset --- .changeset/unlucky-apricots-shop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/unlucky-apricots-shop.md diff --git a/.changeset/unlucky-apricots-shop.md b/.changeset/unlucky-apricots-shop.md new file mode 100644 index 000000000..d4e2c2791 --- /dev/null +++ b/.changeset/unlucky-apricots-shop.md @@ -0,0 +1,5 @@ +--- +'@vercel/tql': patch +--- + +feat: First release From 3f5625a981839cbc0f64b21c717646675335fa4a Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Mon, 27 Nov 2023 11:42:35 -0700 Subject: [PATCH 3/9] fix: lint --- packages/tql/src/build.ts | 7 +++++-- packages/tql/src/dialects/postgres.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/tql/src/build.ts b/packages/tql/src/build.ts index 0a2991cf9..3921a83ee 100644 --- a/packages/tql/src/build.ts +++ b/packages/tql/src/build.ts @@ -11,11 +11,14 @@ export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { 'update-set': dialect.set.bind(dialect), string: dialect.string.bind(dialect), parameter: dialect.parameter.bind(dialect), - fragment: (node) => build(dialect, node), + fragment: (node) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- see below + build(dialect, node); + }, query: (): void => { throw new TqlError('illegal_query_recursion'); }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- this is technically possible to get right, but really hard and unimportant } satisfies { [key in TqlNodeType]: (node: any) => void }; for (const node of ast.nodes) { actions[node.type](node); diff --git a/packages/tql/src/dialects/postgres.ts b/packages/tql/src/dialects/postgres.ts index 0ef44506d..a93da6b3a 100644 --- a/packages/tql/src/dialects/postgres.ts +++ b/packages/tql/src/dialects/postgres.ts @@ -53,9 +53,9 @@ export class PostgresDialect extends BaseDialect implements DialectImpl { if (first) { first = false; columns = Object.keys(entry); - // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method this.appendToQuery( `(${columns + // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method .map(PostgresDialect.escapeIdentifier) .join(', ')}) VALUES `, ); @@ -72,8 +72,8 @@ export class PostgresDialect extends BaseDialect implements DialectImpl { // it's a single entry const entry = entries.values; const columns = Object.keys(entry); - // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method this.appendToQuery( + // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method `(${columns.map(PostgresDialect.escapeIdentifier).join(', ')}) VALUES `, ); const queryItems: string[] = []; From fd204ff02cc24d77abbbd6c7066ce839e8e5ad7d Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Tue, 28 Nov 2023 13:32:15 -0700 Subject: [PATCH 4/9] fix: Misc review --- packages/tql/README.md | 2 +- packages/tql/src/build.test.ts | 116 ++++++++++++++++++++++++++ packages/tql/src/build.ts | 5 +- packages/tql/src/dialects/postgres.ts | 8 +- packages/tql/src/nodes.ts | 12 +-- packages/tql/src/utils.test.ts | 26 +++++- packages/tql/src/utils.ts | 9 -- 7 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 packages/tql/src/build.test.ts diff --git a/packages/tql/README.md b/packages/tql/README.md index fb0a596e5..b58b89203 100644 --- a/packages/tql/README.md +++ b/packages/tql/README.md @@ -4,7 +4,7 @@ pnpm i @vercel/tql ``` -TQL (Template-SQL -- unfortunately, T-SQL is already taken!) is a lightweight library for writing SQL in TypeScript: +TQL is a lightweight library for writing SQL in TypeScript: ```ts import { init, PostgresDialect } from '@vercel/tql'; diff --git a/packages/tql/src/build.test.ts b/packages/tql/src/build.test.ts new file mode 100644 index 000000000..f127efc52 --- /dev/null +++ b/packages/tql/src/build.test.ts @@ -0,0 +1,116 @@ +import { build } from './build'; +import { TqlError } from './error'; +import type { TqlNodeType } from './nodes'; +import { + TqlFragment, + TqlIdentifiers, + TqlList, + TqlParameter, + TqlQuery, + TqlSet, + TqlTemplateString, + TqlValues, +} from './nodes'; + +describe('build', () => { + const actions = { + identifiers: jest.fn(), + list: jest.fn(), + values: jest.fn(), + set: jest.fn(), + 'template-string': jest.fn(), + parameter: jest.fn(), + fragment: jest.fn(), + query: jest.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- this is technically possible to get right, but really hard and unimportant + } satisfies { [key in TqlNodeType]: (node: any) => void }; + + it.each([ + { + type: 'identifiers', + node: new TqlQuery([new TqlIdentifiers('hello')]), + expect: () => { + expect(actions.identifiers).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'list', + node: new TqlQuery([new TqlList(['hello', 'world'])]), + expect: () => { + expect(actions.list).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'values', + node: new TqlQuery([new TqlValues({ hello: 'world' })]), + expect: () => { + expect(actions.values).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'set', + node: new TqlQuery([new TqlSet({ hello: 'world' })]), + expect: () => { + expect(actions.set).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'template-string', + node: new TqlQuery([new TqlTemplateString('hello')]), + expect: () => { + expect(actions['template-string']).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'parameter', + node: new TqlQuery([new TqlParameter('hello')]), + expect: () => { + expect(actions.parameter).toHaveBeenCalledTimes(1); + }, + }, + { + type: 'fragment', + node: new TqlFragment([new TqlFragment([])]), + expect: () => { + expect(actions.fragment).not.toHaveBeenCalledTimes(1); + }, + }, + { + type: 'query', + node: new TqlQuery([]), + expect: () => { + expect(actions.query).not.toHaveBeenCalled(); + }, + }, + ])( + 'calls the correct method given a node type: $type', + ({ node, expect }) => { + // @ts-expect-error - Missing preprocess and postprocess, but not needed to test this + build(actions, node); + expect(); + }, + ); + + it('throws when trying to nest queries in queries or fragments', () => { + const nestedQueryInQuery = (): void => { + // @ts-expect-error - Same as above + build(actions, new TqlQuery([new TqlQuery()])); + }; + const nestedQueryInFragment = (): void => { + // @ts-expect-error - Same as above + build(actions, new TqlFragment([new TqlQuery()])); + }; + const assertIsCorrectError = (fn: () => void): void => { + let error: Error | null = null; + try { + fn(); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(TqlError); + expect(error).toHaveProperty('code', 'illegal_query_recursion'); + }; + assertIsCorrectError(nestedQueryInQuery); + assertIsCorrectError(nestedQueryInFragment); + }); +}); diff --git a/packages/tql/src/build.ts b/packages/tql/src/build.ts index 3921a83ee..feb3a5d5e 100644 --- a/packages/tql/src/build.ts +++ b/packages/tql/src/build.ts @@ -2,14 +2,13 @@ import { TqlError } from './error'; import { type TqlQuery, type TqlFragment, type TqlNodeType } from './nodes'; import type { DialectImpl } from './types'; -// TODO: test export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { const actions = { identifiers: dialect.identifiers.bind(dialect), list: dialect.list.bind(dialect), values: dialect.values.bind(dialect), - 'update-set': dialect.set.bind(dialect), - string: dialect.string.bind(dialect), + set: dialect.set.bind(dialect), + 'template-string': dialect.string.bind(dialect), parameter: dialect.parameter.bind(dialect), fragment: (node) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- see below diff --git a/packages/tql/src/dialects/postgres.ts b/packages/tql/src/dialects/postgres.ts index a93da6b3a..0de7a962f 100644 --- a/packages/tql/src/dialects/postgres.ts +++ b/packages/tql/src/dialects/postgres.ts @@ -55,8 +55,7 @@ export class PostgresDialect extends BaseDialect implements DialectImpl { columns = Object.keys(entry); this.appendToQuery( `(${columns - // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method - .map(PostgresDialect.escapeIdentifier) + .map((column) => PostgresDialect.escapeIdentifier(column)) .join(', ')}) VALUES `, ); } @@ -73,8 +72,9 @@ export class PostgresDialect extends BaseDialect implements DialectImpl { const entry = entries.values; const columns = Object.keys(entry); this.appendToQuery( - // eslint-disable-next-line @typescript-eslint/unbound-method -- this rule is idiotic, this is a static method - `(${columns.map(PostgresDialect.escapeIdentifier).join(', ')}) VALUES `, + `(${columns + .map((column) => PostgresDialect.escapeIdentifier(column)) + .join(', ')}) VALUES `, ); const queryItems: string[] = []; for (const column of columns) { diff --git a/packages/tql/src/nodes.ts b/packages/tql/src/nodes.ts index 005b7ead2..d2e41f17a 100644 --- a/packages/tql/src/nodes.ts +++ b/packages/tql/src/nodes.ts @@ -3,9 +3,9 @@ import type { SetObject, ValuesObject } from './types'; export const tqlNodeTypes = [ 'list', 'parameter', - 'string', + 'template-string', 'values', - 'update-set', + 'set', 'query', 'fragment', 'identifiers', @@ -49,9 +49,9 @@ export class TqlParameter extends TqlNode<'parameter'> { * A string literal. These are ONLY created by the `tql` template tag -- i.e. these strings are written by the developer, * not the user. */ -export class TqlTemplateString extends TqlNode<'string'> { +export class TqlTemplateString extends TqlNode<'template-string'> { constructor(public readonly value: string) { - super('string'); + super('template-string'); } } @@ -70,9 +70,9 @@ export class TqlValues extends TqlNode<'values'> { * A SET clause. Given a record, the record keys are column names and the corresponding values are the values for that column. * The dialect should write the full SET clause, i.e. `SET "col_1" = $1, "col_2" = $2`. */ -export class TqlSet extends TqlNode<'update-set'> { +export class TqlSet extends TqlNode<'set'> { constructor(public readonly values: SetObject) { - super('update-set'); + super('set'); } } diff --git a/packages/tql/src/utils.test.ts b/packages/tql/src/utils.test.ts index 0e010064c..66d65f614 100644 --- a/packages/tql/src/utils.test.ts +++ b/packages/tql/src/utils.test.ts @@ -1,4 +1,4 @@ -import { isTemplateStringsArray } from './utils'; +import { createQueryBuilder, isTemplateStringsArray } from './utils'; function getTemplateStringsArray( strings: TemplateStringsArray, @@ -23,3 +23,27 @@ describe('isTemplateStringsArray', () => { expect(isTemplateStringsArray(undefined)).toBe(false); }); }); + +describe('createQueryBuilder', () => { + it('appends to the query string', () => { + const qb = createQueryBuilder(); + const result = qb.appendToQuery('hi'); + expect(qb.query).toBe('hi'); + expect(result).toBe(2); + }); + it('appends to the parameters array', () => { + const qb = createQueryBuilder(); + const result = qb.appendToParams('hi'); + expect(qb.params).toEqual(['hi']); + expect(result).toBe(1); + }); + it('reacts to additional changes', () => { + const qb = createQueryBuilder(); + const result = qb.appendToParams('hi'); + expect(qb.params).toEqual(['hi']); + expect(result).toBe(1); + const secondResult = qb.appendToParams('world'); + expect(qb.params).toEqual(['hi', 'world']); + expect(secondResult).toBe(2); + }); +}); diff --git a/packages/tql/src/utils.ts b/packages/tql/src/utils.ts index 2dda62c5e..951dca8ec 100644 --- a/packages/tql/src/utils.ts +++ b/packages/tql/src/utils.ts @@ -6,7 +6,6 @@ export function isTemplateStringsArray( ); } -// TODO: Test export function createQueryBuilder(): { readonly query: string; readonly params: unknown[]; @@ -33,11 +32,3 @@ export function createQueryBuilder(): { appendToParams, }; } - -// TODO: test -export function pluralize(str: string, plural: boolean, suffix = 's'): string { - if (plural) { - return `${str}${suffix}`; - } - return str; -} From 4eb42bc6acf3ef3f12b60d5859ac43e32cb620d8 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Tue, 28 Nov 2023 13:36:39 -0700 Subject: [PATCH 5/9] fix: lint --- packages/tql/src/build.test.ts | 6 ++--- packages/tql/src/build.ts | 2 +- packages/tql/src/dialects/postgres.test.ts | 2 +- packages/tql/src/dialects/postgres.ts | 2 +- packages/tql/src/dialects/test-dialect.ts | 6 ++--- packages/tql/src/index.test.ts | 28 +++++++++++----------- packages/tql/src/nodes.test.ts | 2 +- packages/tql/src/nodes.ts | 6 ++--- packages/tql/src/types.ts | 2 +- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/tql/src/build.test.ts b/packages/tql/src/build.test.ts index f127efc52..46777f2f3 100644 --- a/packages/tql/src/build.test.ts +++ b/packages/tql/src/build.test.ts @@ -18,7 +18,7 @@ describe('build', () => { list: jest.fn(), values: jest.fn(), set: jest.fn(), - 'template-string': jest.fn(), + templateString: jest.fn(), parameter: jest.fn(), fragment: jest.fn(), query: jest.fn(), @@ -55,10 +55,10 @@ describe('build', () => { }, }, { - type: 'template-string', + type: 'templateString', node: new TqlQuery([new TqlTemplateString('hello')]), expect: () => { - expect(actions['template-string']).toHaveBeenCalledTimes(1); + expect(actions.templateString).toHaveBeenCalledTimes(1); }, }, { diff --git a/packages/tql/src/build.ts b/packages/tql/src/build.ts index feb3a5d5e..90e3daf35 100644 --- a/packages/tql/src/build.ts +++ b/packages/tql/src/build.ts @@ -8,7 +8,7 @@ export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { list: dialect.list.bind(dialect), values: dialect.values.bind(dialect), set: dialect.set.bind(dialect), - 'template-string': dialect.string.bind(dialect), + templateString: dialect.templateString.bind(dialect), parameter: dialect.parameter.bind(dialect), fragment: (node) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- see below diff --git a/packages/tql/src/dialects/postgres.test.ts b/packages/tql/src/dialects/postgres.test.ts index c605af6fc..4411b0527 100644 --- a/packages/tql/src/dialects/postgres.test.ts +++ b/packages/tql/src/dialects/postgres.test.ts @@ -26,7 +26,7 @@ describe('tql dialect: Postgres', () => { describe('string', () => { it('appends the string', () => { const dialect = d(); - dialect.string(new TqlTemplateString('hi')); + dialect.templateString(new TqlTemplateString('hi')); expect(queryBuilder.params).toEqual([]); expect(queryBuilder.query).toBe('hi'); }); diff --git a/packages/tql/src/dialects/postgres.ts b/packages/tql/src/dialects/postgres.ts index 0de7a962f..4edcd958d 100644 --- a/packages/tql/src/dialects/postgres.ts +++ b/packages/tql/src/dialects/postgres.ts @@ -11,7 +11,7 @@ import { } from '../nodes.js'; export class PostgresDialect extends BaseDialect implements DialectImpl { - string(str: TqlTemplateString): void { + templateString(str: TqlTemplateString): void { this.appendToQuery(str.value); } diff --git a/packages/tql/src/dialects/test-dialect.ts b/packages/tql/src/dialects/test-dialect.ts index cdf06966e..93e03321a 100644 --- a/packages/tql/src/dialects/test-dialect.ts +++ b/packages/tql/src/dialects/test-dialect.ts @@ -5,7 +5,7 @@ import type { TqlQuery } from '../nodes'; export function createTestDialect(): { Dialect: Dialect; mocks: { - string: jest.MockedFunction; + templateString: jest.MockedFunction; parameter: jest.MockedFunction; identifiers: jest.MockedFunction; list: jest.MockedFunction; @@ -16,7 +16,7 @@ export function createTestDialect(): { }; } { const mocks = { - string: jest.fn(), + templateString: jest.fn(), parameter: jest.fn(), identifiers: jest.fn(), list: jest.fn(), @@ -28,7 +28,7 @@ export function createTestDialect(): { ), }; class TestDialect extends BaseDialect implements DialectImpl { - string = mocks.string; + templateString = mocks.templateString; parameter = mocks.parameter; identifiers = mocks.identifiers; list = mocks.list; diff --git a/packages/tql/src/index.test.ts b/packages/tql/src/index.test.ts index cb6896a8a..126d37fee 100644 --- a/packages/tql/src/index.test.ts +++ b/packages/tql/src/index.test.ts @@ -37,8 +37,8 @@ describe('exports', () => { expect(mocks.preprocess).toHaveBeenCalledWith( new TqlQuery([new TqlTemplateString('SELECT * FROM users')]), ); - expect(mocks.string).toHaveBeenCalledTimes(1); - expect(mocks.string).toHaveBeenCalledWith( + expect(mocks.templateString).toHaveBeenCalledTimes(1); + expect(mocks.templateString).toHaveBeenCalledWith( new TqlTemplateString('SELECT * FROM users'), ); expect(mocks.parameter).not.toHaveBeenCalled(); @@ -74,28 +74,28 @@ describe('exports', () => { new TqlTemplateString(''), ]), ); - expect(mocks.string).toHaveBeenCalledTimes(6); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenCalledTimes(6); + expect(mocks.templateString).toHaveBeenNthCalledWith( 1, new TqlTemplateString('SELECT * FROM users WHERE 1=1 '), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 2, new TqlTemplateString('AND user_id = '), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 3, new TqlTemplateString(''), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 4, new TqlTemplateString('AND user_name = '), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 5, new TqlTemplateString(''), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 6, new TqlTemplateString(''), ); @@ -133,20 +133,20 @@ describe('exports', () => { new TqlTemplateString(');'), ]), ); - expect(mocks.string).toHaveBeenCalledTimes(4); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenCalledTimes(4); + expect(mocks.templateString).toHaveBeenNthCalledWith( 1, new TqlTemplateString('SELECT * FROM ('), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 2, new TqlTemplateString('SELECT * FROM users WHERE user_id = '), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 3, new TqlTemplateString(''), ); - expect(mocks.string).toHaveBeenNthCalledWith( + expect(mocks.templateString).toHaveBeenNthCalledWith( 4, new TqlTemplateString(');'), ); diff --git a/packages/tql/src/nodes.test.ts b/packages/tql/src/nodes.test.ts index 16257e5a2..cf1917a7c 100644 --- a/packages/tql/src/nodes.test.ts +++ b/packages/tql/src/nodes.test.ts @@ -27,7 +27,7 @@ describe('nodes', () => { instance: new TqlParameter(1), }, { - type: 'string', + type: 'templateString', Ctor: TqlTemplateString, instance: new TqlTemplateString('foo'), }, diff --git a/packages/tql/src/nodes.ts b/packages/tql/src/nodes.ts index d2e41f17a..4cf77dcf5 100644 --- a/packages/tql/src/nodes.ts +++ b/packages/tql/src/nodes.ts @@ -3,7 +3,7 @@ import type { SetObject, ValuesObject } from './types'; export const tqlNodeTypes = [ 'list', 'parameter', - 'template-string', + 'templateString', 'values', 'set', 'query', @@ -49,9 +49,9 @@ export class TqlParameter extends TqlNode<'parameter'> { * A string literal. These are ONLY created by the `tql` template tag -- i.e. these strings are written by the developer, * not the user. */ -export class TqlTemplateString extends TqlNode<'template-string'> { +export class TqlTemplateString extends TqlNode<'templateString'> { constructor(public readonly value: string) { - super('template-string'); + super('templateString'); } } diff --git a/packages/tql/src/types.ts b/packages/tql/src/types.ts index 9cdbcf478..35dee2a36 100644 --- a/packages/tql/src/types.ts +++ b/packages/tql/src/types.ts @@ -14,7 +14,7 @@ export type ValuesObject = Record | Record[]; export interface DialectImpl { preprocess: (fragment: TqlQuery) => TqlQuery; - string: (str: TqlTemplateString) => void; + templateString: (str: TqlTemplateString) => void; parameter: (param: TqlParameter) => void; identifiers: (ids: TqlIdentifiers) => void; list: (vals: TqlList) => void; From fbcb3d759dec3c228df2fcd46c8113f1edf08adf Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Wed, 29 Nov 2023 17:28:21 -0700 Subject: [PATCH 6/9] feat: Add `join` API to `fragment` --- packages/tql/README.md | 24 ++++++++++++++++ packages/tql/src/error.ts | 2 ++ packages/tql/src/nodes.test.ts | 51 ++++++++++++++++++++++++++++++++++ packages/tql/src/nodes.ts | 14 ++++++++++ 4 files changed, 91 insertions(+) diff --git a/packages/tql/README.md b/packages/tql/README.md index b58b89203..2a83179a9 100644 --- a/packages/tql/README.md +++ b/packages/tql/README.md @@ -114,6 +114,30 @@ const [q, params] = query`SELECT * FROM users ${whereClause}`; Fragments can be nested recursively, so the possibilities are endless. +If you need to combine a group of fragments, you can use `fragment.join`, which works a bit like Python's `String.join` API: + +```ts +const maxAge = 30; +const minAge = 25; +const firstName = undefined; + +const filters = []; +if (maxAge) filters.push(fragment`age < ${maxAge}`); +if (minAge) filters.push(fragment`age > ${minAge}`); +if (firstName) filters.push(fragment`firstName = ${firstName}`); + +let whereClause = fragment``; +if (filters.length > 0) { + joinedFilters = fragment` AND `.join(...filters); + whereClause = fragment`WHERE ${joinedFilters}`; +} +const [q, params] = query`SELECT * FROM users ${whereClause};`; +// output: [ +// 'SELECT * FROM users WHERE age < $1 AND age > $2;', +// [30, 25] +// ] +``` + ### Identifiers Need to dynamically insert identifiers? diff --git a/packages/tql/src/error.ts b/packages/tql/src/error.ts index 5e3ad7e77..6c2dbe983 100644 --- a/packages/tql/src/error.ts +++ b/packages/tql/src/error.ts @@ -26,6 +26,8 @@ const messages = { 'The records passed to `values` must not be empty.', illegal_query_recursion: () => 'Found a nested call to `query`. If you need to nest queries, use `fragment`.', + illegal_non_fragment_join: () => + 'Cannot join non-fragment values to fragments, as this could result in SQL injection.', } as const satisfies Record string>; function formatValuesRecordsMismatchMessage(diff: ColumnDiff): string { diff --git a/packages/tql/src/nodes.test.ts b/packages/tql/src/nodes.test.ts index cf1917a7c..412bc36b3 100644 --- a/packages/tql/src/nodes.test.ts +++ b/packages/tql/src/nodes.test.ts @@ -1,3 +1,4 @@ +import { TqlError } from './error'; import { TqlIdentifiers, TqlList, @@ -55,3 +56,53 @@ describe('nodes', () => { }, ); }); + +// eslint-disable-next-line jest/prefer-lowercase-title -- This is a class name +describe('TqlFragment', () => { + it('joins other fragments using itself as a delimiter', () => { + const delimiter = new TqlFragment([new TqlTemplateString('\n')]); + const fragmentsToJoin = [ + new TqlFragment([new TqlTemplateString('SELECT *')]), + new TqlFragment([new TqlTemplateString('FROM users')]), + new TqlFragment([ + new TqlTemplateString('WHERE user_id = '), + new TqlParameter(1234), + new TqlTemplateString(';'), + ]), + ]; + const result = delimiter.join(...fragmentsToJoin); + expect(result).toEqual( + new TqlFragment([ + new TqlTemplateString('SELECT *'), + new TqlTemplateString('\n'), + new TqlTemplateString('FROM users'), + new TqlTemplateString('\n'), + new TqlTemplateString('WHERE user_id = '), + new TqlParameter(1234), + new TqlTemplateString(';'), + ]), + ); + }); + + it('throws when it finds an imposter', () => { + const delimiter = new TqlFragment([new TqlTemplateString('\n')]); + const fragmentsToJoin = [ + new TqlFragment([new TqlTemplateString('SELECT *')]), + new TqlFragment([new TqlTemplateString('FROM users')]), + '; DROP TABLE users;--', + new TqlFragment([ + new TqlTemplateString('WHERE user_id = '), + new TqlParameter(1234), + new TqlTemplateString(';'), + ]), + ] as TqlFragment[]; + let error: Error | null = null; + try { + delimiter.join(...fragmentsToJoin); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(TqlError); + expect(error).toHaveProperty('code', 'illegal_non_fragment_join'); + }); +}); diff --git a/packages/tql/src/nodes.ts b/packages/tql/src/nodes.ts index 4cf77dcf5..b82b074c2 100644 --- a/packages/tql/src/nodes.ts +++ b/packages/tql/src/nodes.ts @@ -1,3 +1,4 @@ +import { TqlError } from './error'; import type { SetObject, ValuesObject } from './types'; export const tqlNodeTypes = [ @@ -96,6 +97,19 @@ export class TqlFragment extends TqlNode<'fragment'> { constructor(public readonly nodes: TqlNode>[]) { super('fragment'); } + + join(...fragments: TqlFragment[]): TqlFragment { + if (fragments.length === 0) return new TqlFragment([]); + if (!fragments.every((fragment) => fragment instanceof TqlFragment)) { + throw new TqlError('illegal_non_fragment_join'); + } + const nodes = [...(fragments.shift()?.nodes ?? [])]; + for (const fragment of fragments) { + nodes.push(...this.nodes); + nodes.push(...fragment.nodes); + } + return new TqlFragment(nodes); + } } /** From 6361d068e0a7e4f843cff4a3ff6a21d032ad59e1 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Thu, 30 Nov 2023 16:10:37 -0700 Subject: [PATCH 7/9] fix: Add protections against non-tql-nodes in template --- packages/tql/src/build.test.ts | 70 ++++++++++++++++++++-------------- packages/tql/src/build.ts | 10 ++++- packages/tql/src/error.ts | 3 ++ 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/packages/tql/src/build.test.ts b/packages/tql/src/build.test.ts index 46777f2f3..4ebebadeb 100644 --- a/packages/tql/src/build.test.ts +++ b/packages/tql/src/build.test.ts @@ -1,6 +1,6 @@ import { build } from './build'; +import { createTestDialect } from './dialects/test-dialect'; import { TqlError } from './error'; -import type { TqlNodeType } from './nodes'; import { TqlFragment, TqlIdentifiers, @@ -11,94 +11,95 @@ import { TqlTemplateString, TqlValues, } from './nodes'; +import { DialectImpl } from './types'; describe('build', () => { - const actions = { - identifiers: jest.fn(), - list: jest.fn(), - values: jest.fn(), - set: jest.fn(), - templateString: jest.fn(), - parameter: jest.fn(), - fragment: jest.fn(), - query: jest.fn(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- this is technically possible to get right, but really hard and unimportant - } satisfies { [key in TqlNodeType]: (node: any) => void }; + const { Dialect, mocks } = createTestDialect(); + let instance: DialectImpl; + beforeEach(() => { + instance = new Dialect( + () => { + return 0; + }, + () => { + return 0; + }, + ); + }); it.each([ { type: 'identifiers', node: new TqlQuery([new TqlIdentifiers('hello')]), expect: () => { - expect(actions.identifiers).toHaveBeenCalledTimes(1); + expect(mocks.identifiers).toHaveBeenCalledTimes(1); }, }, { type: 'list', node: new TqlQuery([new TqlList(['hello', 'world'])]), expect: () => { - expect(actions.list).toHaveBeenCalledTimes(1); + expect(mocks.list).toHaveBeenCalledTimes(1); }, }, { type: 'values', node: new TqlQuery([new TqlValues({ hello: 'world' })]), expect: () => { - expect(actions.values).toHaveBeenCalledTimes(1); + expect(mocks.values).toHaveBeenCalledTimes(1); }, }, { type: 'set', node: new TqlQuery([new TqlSet({ hello: 'world' })]), expect: () => { - expect(actions.set).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledTimes(1); }, }, { type: 'templateString', node: new TqlQuery([new TqlTemplateString('hello')]), expect: () => { - expect(actions.templateString).toHaveBeenCalledTimes(1); + expect(mocks.templateString).toHaveBeenCalledTimes(1); }, }, { type: 'parameter', node: new TqlQuery([new TqlParameter('hello')]), expect: () => { - expect(actions.parameter).toHaveBeenCalledTimes(1); + expect(mocks.parameter).toHaveBeenCalledTimes(1); }, }, { type: 'fragment', - node: new TqlFragment([new TqlFragment([])]), + node: new TqlFragment([new TqlFragment([new TqlTemplateString('hi')])]), expect: () => { - expect(actions.fragment).not.toHaveBeenCalledTimes(1); + expect(mocks.templateString).toHaveBeenCalledTimes(1); }, }, { type: 'query', - node: new TqlQuery([]), + node: new TqlQuery([new TqlFragment([new TqlTemplateString('hi')])]), expect: () => { - expect(actions.query).not.toHaveBeenCalled(); + expect(mocks.templateString).toHaveBeenCalledTimes(1); }, }, ])( 'calls the correct method given a node type: $type', ({ node, expect }) => { - // @ts-expect-error - Missing preprocess and postprocess, but not needed to test this - build(actions, node); + build(instance, node); expect(); }, ); it('throws when trying to nest queries in queries or fragments', () => { const nestedQueryInQuery = (): void => { - // @ts-expect-error - Same as above - build(actions, new TqlQuery([new TqlQuery()])); + // @ts-expect-error - This is against the rules, but someone could try + build(instance, new TqlQuery([new TqlQuery()])); }; const nestedQueryInFragment = (): void => { - // @ts-expect-error - Same as above - build(actions, new TqlFragment([new TqlQuery()])); + // @ts-expect-error - This is against the rules, but someone could try + build(instance, new TqlFragment([new TqlQuery()])); }; const assertIsCorrectError = (fn: () => void): void => { let error: Error | null = null; @@ -113,4 +114,17 @@ describe('build', () => { assertIsCorrectError(nestedQueryInQuery); assertIsCorrectError(nestedQueryInFragment); }); + + it('throws if someone sneaks in a non-TQL-node', () => { + // @ts-expect-error - Yes, this is impossible with TypeScript + const q = new TqlQuery([new TqlTemplateString('hi'), 'hi']); + let error: Error | null = null; + try { + build(instance, q); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(TqlError); + expect(error).toHaveProperty('code', 'illegal_node_type_in_build'); + }); }); diff --git a/packages/tql/src/build.ts b/packages/tql/src/build.ts index 90e3daf35..b4ff1f50c 100644 --- a/packages/tql/src/build.ts +++ b/packages/tql/src/build.ts @@ -1,5 +1,10 @@ import { TqlError } from './error'; -import { type TqlQuery, type TqlFragment, type TqlNodeType } from './nodes'; +import { + type TqlQuery, + type TqlFragment, + type TqlNodeType, + TqlNode, +} from './nodes'; import type { DialectImpl } from './types'; export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { @@ -20,6 +25,9 @@ export function build(dialect: DialectImpl, ast: TqlQuery | TqlFragment): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- this is technically possible to get right, but really hard and unimportant } satisfies { [key in TqlNodeType]: (node: any) => void }; for (const node of ast.nodes) { + if (!(node instanceof TqlNode)) { + throw new TqlError('illegal_node_type_in_build', node); + } actions[node.type](node); } } diff --git a/packages/tql/src/error.ts b/packages/tql/src/error.ts index 6c2dbe983..e3f1e08ca 100644 --- a/packages/tql/src/error.ts +++ b/packages/tql/src/error.ts @@ -28,6 +28,9 @@ const messages = { 'Found a nested call to `query`. If you need to nest queries, use `fragment`.', illegal_non_fragment_join: () => 'Cannot join non-fragment values to fragments, as this could result in SQL injection.', + illegal_node_type_in_build: (badNode: unknown) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- In this instance, this is fine + `Encountered a non-TQL-node type while trying to build a query. This could indicate attempted SQL injection. Received: ${badNode}`, } as const satisfies Record string>; function formatValuesRecordsMismatchMessage(diff: ColumnDiff): string { From 333c3984192410fcbd293a43662b3212c5deda7c Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Thu, 30 Nov 2023 16:11:26 -0700 Subject: [PATCH 8/9] fix: lint --- packages/tql/src/build.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tql/src/build.test.ts b/packages/tql/src/build.test.ts index 4ebebadeb..acf36fb04 100644 --- a/packages/tql/src/build.test.ts +++ b/packages/tql/src/build.test.ts @@ -11,7 +11,7 @@ import { TqlTemplateString, TqlValues, } from './nodes'; -import { DialectImpl } from './types'; +import type { DialectImpl } from './types'; describe('build', () => { const { Dialect, mocks } = createTestDialect(); From 5d4a302b50e2bf56411b50b6a747eac0b363b602 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Thu, 30 Nov 2023 16:12:05 -0700 Subject: [PATCH 9/9] fix: Clear mocks --- packages/tql/src/build.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tql/src/build.test.ts b/packages/tql/src/build.test.ts index acf36fb04..35a475374 100644 --- a/packages/tql/src/build.test.ts +++ b/packages/tql/src/build.test.ts @@ -25,6 +25,7 @@ describe('build', () => { return 0; }, ); + jest.clearAllMocks(); }); it.each([