diff --git a/.changeset/grumpy-games-care.md b/.changeset/grumpy-games-care.md new file mode 100644 index 0000000..69637cc --- /dev/null +++ b/.changeset/grumpy-games-care.md @@ -0,0 +1,5 @@ +--- +"@sejohnson/tql": minor +--- + +feat: Snowflake adapter v0 diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..7efb991 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,11 @@ +{ + "mode": "exit", + "tag": "next", + "initialVersions": { + "@sejohnson/tql": "2.0.0" + }, + "changesets": [ + "grumpy-games-care", + "rotten-grapes-battle" + ] +} diff --git a/.changeset/rotten-grapes-battle.md b/.changeset/rotten-grapes-battle.md new file mode 100644 index 0000000..6ea868c --- /dev/null +++ b/.changeset/rotten-grapes-battle.md @@ -0,0 +1,5 @@ +--- +"@sejohnson/tql": patch +--- + +fix: Export the API diff --git a/packages/tql-template/CHANGELOG.md b/packages/tql-template/CHANGELOG.md index 4566acd..13b89a8 100644 --- a/packages/tql-template/CHANGELOG.md +++ b/packages/tql-template/CHANGELOG.md @@ -1,5 +1,17 @@ # @sejohnson/tql +## 2.1.0-next.1 + +### Patch Changes + +- 5de1a42: fix: Export the API + +## 2.1.0-next.0 + +### Minor Changes + +- dca2627: feat: Snowflake adapter v0 + ## 2.0.0 ### Major Changes diff --git a/packages/tql-template/package.json b/packages/tql-template/package.json index 5c84ddd..7503944 100644 --- a/packages/tql-template/package.json +++ b/packages/tql-template/package.json @@ -1,6 +1,6 @@ { "name": "@sejohnson/tql", - "version": "2.0.0", + "version": "2.1.0-next.1", "description": "A flexible, multi-dialect tagged template SQL query builder.", "repository": { "directory": "./packages/tql-template", diff --git a/packages/tql-template/src/dialects/postgres.spec.ts b/packages/tql-template/src/dialects/postgres.spec.ts index 7e80875..854ec15 100644 --- a/packages/tql-template/src/dialects/postgres.spec.ts +++ b/packages/tql-template/src/dialects/postgres.spec.ts @@ -72,7 +72,7 @@ describe('tql dialect: Postgres', () => { input: 'with.injection" FROM users; SELECT * FROM privileged_information;--', output: '"with"."injection"" FROM users; SELECT * FROM privileged_information;--"', }, - ])('escapes identifiers', ({ input, output }) => { + ])('escapes single identifiers', ({ input, output }) => { const dialect = d(); dialect.identifiers(new TqlIdentifiers(input)); expect(queryBuilder.params).toEqual([]); @@ -97,7 +97,7 @@ describe('tql dialect: Postgres', () => { input: ['with.injection" FROM users; SELECT * FROM privileged_information;--', 'blah'], output: '"with"."injection"" FROM users; SELECT * FROM privileged_information;--", "blah"', }, - ])('escapes identifiers', ({ input, output }) => { + ])('escapes multiple identifiers', ({ input, output }) => { const dialect = d(); dialect.identifiers(new TqlIdentifiers(input)); expect(queryBuilder.params).toEqual([]); diff --git a/packages/tql-template/src/dialects/snowflake.spec.ts b/packages/tql-template/src/dialects/snowflake.spec.ts new file mode 100644 index 0000000..815245c --- /dev/null +++ b/packages/tql-template/src/dialects/snowflake.spec.ts @@ -0,0 +1,286 @@ +import { it, describe, expect, beforeEach, vi } from 'vitest'; +import { SnowflakeDialect } from './snowflake.js'; +import { TqlTemplateString, TqlParameter, TqlIdentifiers, TqlList, TqlValues, TqlSet } from '../nodes.js'; +import { createQueryBuilder } from '../utils.js'; +import { TqlError } from '../error.js'; + +describe('tql dialect: Snowflake', () => { + let queryBuilder: ReturnType; + let d: () => SnowflakeDialect; + + beforeEach(() => { + const qb = createQueryBuilder(); + qb.appendToParams = vi.fn().mockImplementation(qb.appendToParams); + qb.appendToQuery = vi.fn().mockImplementation(qb.appendToQuery); + queryBuilder = qb; + d = (): SnowflakeDialect => new SnowflakeDialect(qb.appendToQuery, qb.appendToParams); + }); + + describe('templateString', () => { + it('appends the string', () => { + const dialect = d(); + dialect.templateString(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([identifier]); + expect(queryBuilder.query).toBe('IDENTIFIER(:1)'); + }); + + it.each([ + { input: 'with"quotes' }, + { + input: 'dotted.identifiers', + }, + { + input: 'with.injection" FROM users; SELECT * FROM privileged_information;--', + }, + ])('escapes single identifiers', ({ input }) => { + const dialect = d(); + dialect.identifiers(new TqlIdentifiers(input)); + expect(queryBuilder.params).toEqual([input]); + expect(queryBuilder.query).toBe('IDENTIFIER(:1)'); + }); + + it('adds multiple identifiers to the query', () => { + const dialect = d(); + const identifier = 'name'; + dialect.identifiers(new TqlIdentifiers([identifier, identifier, identifier])); + expect(queryBuilder.params).toEqual(['name', 'name', 'name']); + expect(queryBuilder.query).toBe('IDENTIFIER(:1), IDENTIFIER(:2), IDENTIFIER(:3)'); + }); + + it.each([ + { input: ['with"quotes', 'with"quotes'] }, + { + input: ['dotted.identifiers'], + }, + { + input: ['with.injection" FROM users; SELECT * FROM privileged_information;--', 'blah'], + }, + ])('escapes multiple identifiers', ({ input }) => { + const dialect = d(); + dialect.identifiers(new TqlIdentifiers(input)); + expect(queryBuilder.params).toEqual(input); + expect(queryBuilder.query).toBe(input.length === 1 ? 'IDENTIFIER(:1)' : 'IDENTIFIER(:1), IDENTIFIER(:2)'); + }); + }); + + 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(['name', 'email', item.name, item.email]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2)) VALUES (:3, :4)'); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const key1 = 'name"; SELECT * FROM privileged_information; --'; + const key2 = 'email'; + const item = { + [key1]: 'vercelliott; SELECT * FROM privileged_information; --', + [key2]: 'wouldnt.you.like.to.know@vercel.com', + }; + dialect.values(new TqlValues(item)); + expect(queryBuilder.params).toEqual([key1, key2, item[key1], item[key2]]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2)) VALUES (:3, :4)'); + }); + + 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(['name', 'email', 'address', item.name, item.email, item.address]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2), IDENTIFIER(:3)) VALUES (:4, :5, :6)'); + }); + }); + + 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([ + 'name', + 'email', + items[0].name, + items[0].email, + items[1].name, + items[1].email, + ]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2)) VALUES (:3, :4), (:5, :6)'); + }); + + 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([ + 'name', + 'email', + items[0].name, + items[0].email, + items[1].name, + items[1].email, + ]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2)) VALUES (:3, :4), (:5, :6)'); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const key1 = 'name"; SELECT * FROM privileged_information; --'; + const key2 = 'email'; + const items = [ + { + [key1]: 'vercelliott; SELECT * FROM privileged_information; --', + [key2]: 'wouldnt.you.like.to.know@vercel.com', + }, + { + [key2]: 'go-away@somewhere-else.com', + [key1]: 'vercelliott; SELECT * FROM privileged_information; --', + }, + ]; + dialect.values(new TqlValues(items)); + expect(queryBuilder.params).toEqual([ + key1, + key2, + items[0]['name"; SELECT * FROM privileged_information; --'], + items[0].email, + items[1]['name"; SELECT * FROM privileged_information; --'], + items[1].email, + ]); + expect(queryBuilder.query).toBe('(IDENTIFIER(:1), IDENTIFIER(:2)) VALUES (:3, :4), (:5, :6)'); + }); + + 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([ + 'name', + 'email', + 'address', + items[0].name, + items[0].email, + items[0].address, + items[1].name, + items[1].email, + items[1].address, + ]); + expect(queryBuilder.query).toBe( + '(IDENTIFIER(:1), IDENTIFIER(:2), IDENTIFIER(:3)) VALUES (:4, :5, :6), (:7, :8, :9)', + ); + }); + + 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) { + 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(['name', 'vercelliott', 'address.zip', '00000']); + expect(queryBuilder.query).toBe('SET IDENTIFIER(:1) = :2, IDENTIFIER(:3) = :4'); + }); + + it('avoids SQL injection from identifiers and values', () => { + const dialect = d(); + const key1 = 'name"; SELECT * FROM privileged_information; --'; + const key2 = 'email'; + const item = { + [key1]: 'vercelliott; SELECT * FROM privileged_information; --', + [key2]: 'wouldnt.you.like.to.know@vercel.com', + }; + dialect.set(new TqlSet(item)); + expect(queryBuilder.params).toEqual([ + key1, + item['name"; SELECT * FROM privileged_information; --'], + key2, + item.email, + ]); + expect(queryBuilder.query).toBe('SET IDENTIFIER(:1) = :2, IDENTIFIER(:3) = :4'); + }); + }); +}); diff --git a/packages/tql-template/src/dialects/snowflake.ts b/packages/tql-template/src/dialects/snowflake.ts new file mode 100644 index 0000000..a53cb09 --- /dev/null +++ b/packages/tql-template/src/dialects/snowflake.ts @@ -0,0 +1,109 @@ +import { IdenticalColumnValidator } from '../values-object-validator.js'; +import type { DialectImpl } from '../types.js'; +import { BaseDialect } from '../dialect.js'; +import { + type TqlIdentifiers, + type TqlList, + type TqlParameter, + type TqlTemplateString, + type TqlSet, + type TqlValues, +} from '../nodes.js'; + +export class SnowflakeDialect extends BaseDialect implements DialectImpl { + templateString(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.appendIdentifiers(ids.values); + } else { + this.appendIdentifier(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); + this.appendToQuery('('); + this.appendIdentifiers(columns); + this.appendToQuery(') 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); + this.appendToQuery('('); + this.appendIdentifiers(columns); + this.appendToQuery(') 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 columnParamNumber = this.appendToParams(column); + const valueParamNumber = this.appendToParams(entry.values[column]); + queryItems.push(`IDENTIFIER(:${columnParamNumber}) = :${valueParamNumber}`); + } + this.appendToQuery(queryItems.join(', ')); + } + + private appendIdentifier(value: string): void { + const paramNumber = this.appendToParams(value); + this.appendToQuery(`IDENTIFIER(:${paramNumber})`); + } + + private appendIdentifiers(values: string[]): void { + this.appendToQuery( + values + .map((v) => { + const paramNumber = this.appendToParams(v); + return `IDENTIFIER(:${paramNumber})`; + }) + .join(', '), + ); + } +} diff --git a/packages/tql-template/src/index.ts b/packages/tql-template/src/index.ts index 1c977bc..ac23a12 100644 --- a/packages/tql-template/src/index.ts +++ b/packages/tql-template/src/index.ts @@ -18,6 +18,7 @@ import type { Tql } from './types.js'; export type * from './nodes.ts'; export type * from './types.js'; export { PostgresDialect } from './dialects/postgres.js'; +export { SnowflakeDialect } from './dialects/snowflake.js'; export const init: Init = ({ dialect }) => { const fragment = Object.defineProperty(