From 4220015b51c5f95a8b1fddee19adfe1490a5bca1 Mon Sep 17 00:00:00 2001 From: Andrew <44983823+andrewvasilchuk@users.noreply.github.com> Date: Sat, 5 Aug 2023 13:25:24 +0300 Subject: [PATCH 01/34] perf: faster sanitize date Use `String#slice` instead of `String#replace` since it is ~15% faster in this particular case --- src/sanitization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sanitization.ts b/src/sanitization.ts index efb5f26..ecf4220 100644 --- a/src/sanitization.ts +++ b/src/sanitization.ts @@ -44,7 +44,7 @@ function sanitize(value: Value): string { } if (value instanceof Date) { - return quote(value.toISOString().replace('Z', '')) + return quote(value.toISOString().slice(0, -1)) } return quote(value.toString()) From a556f3d80182e8bb11be74672f98002263f0c5c9 Mon Sep 17 00:00:00 2001 From: yutak23 Date: Tue, 28 Nov 2023 14:09:05 +0900 Subject: [PATCH 02/34] feat: change to generic type for ExecutedQuery --- __tests__/index.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e978176..328a32e 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -580,6 +580,67 @@ describe('execute', () => { const got = await connection.execute('select document from documents') expect(got).toEqual(want) + expect(got.rows[0].document).toEqual(want.rows[0].document) + }) + + test('allows setting the type of rows in the query results', async () => { + const mockResponse = { + session: mockSession, + result: { + fields: [{ name: ':vtg1', type: 'INT32' }, { name: 'null' }], + rows: [{ lengths: ['1', '-1'], values: 'MQ==' }] + }, + timing: 1 + } + + const want: ExecutedQuery = { + headers: [':vtg1', 'null'], + types: { ':vtg1': 'INT32', null: 'NULL' }, + fields: [ + { name: ':vtg1', type: 'INT32' }, + { name: 'null', type: 'NULL' } + ], + rows: [{ ':vtg1': 1, null: null }], + size: 1, + statement: 'SELECT 1, null from dual;', + rowsAffected: 0, + insertId: '0', + time: 1000 + } + + interface Got { + ':vtg1'?: number + null?: null + } + + const isGot = (object: any): object is Got => { + return ':vtg1' in object && 'null' in object + } + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(null) + return mockResponse + }) + + const connection = connect(config) + const got: ExecutedQuery = await connection.execute('SELECT 1, null from dual;') + + expect(got).toEqual(want) + expect(isGot(got.rows[0])).toBe(true) + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(mockSession) + return mockResponse + }) + + const got2: ExecutedQuery = await connection.execute('SELECT 1, null from dual;') + + expect(got2).toEqual(want) + expect(isGot(got2.rows[0])).toBe(true) }) }) diff --git a/src/index.ts b/src/index.ts index 144cfd2..0d3e90b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export { hex } from './text.js' import { decode } from './text.js' import { Version } from './version.js' -type Row = Record | any[] +type Row | any[]> = T interface VitessError { message: string @@ -24,10 +24,10 @@ export class DatabaseError extends Error { type Types = Record -export interface ExecutedQuery { +export interface ExecutedQuery { headers: string[] types: Types - rows: Row[] + rows: Row[] fields: Field[] size: number statement: string From 4808cdc178501e212c5fab06f49b9c370af23bd2 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Wed, 13 Dec 2023 16:03:06 +0100 Subject: [PATCH 03/34] Function overloading for better type checking --- __tests__/index.test.ts | 84 +++++++++++++++++++++++++++++++++++------ src/index.ts | 83 ++++++++++++++++++++++++---------------- 2 files changed, 123 insertions(+), 44 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 328a32e..985f10c 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -143,7 +143,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1', 'null'], types: { ':vtg1': 'INT32', null: 'NULL' }, fields: [ @@ -192,7 +192,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: ['null'], types: { null: 'NULL' }, fields: [{ name: 'null', type: 'NULL' }], @@ -238,7 +238,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1'], types: { ':vtg1': 'INT32' }, rows: [[1]], @@ -273,7 +273,7 @@ describe('execute', () => { mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, mockResponse) const query = 'CREATE TABLE `foo` (bar json);' - const want: ExecutedQuery = { + const want = { headers: [], types: {}, fields: [], @@ -303,7 +303,7 @@ describe('execute', () => { mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, mockResponse) const query = "UPDATE `foo` SET bar='planetscale'" - const want: ExecutedQuery = { + const want = { headers: [], types: {}, fields: [], @@ -334,7 +334,7 @@ describe('execute', () => { mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, mockResponse) const query = "INSERT INTO `foo` (bar) VALUES ('planetscale');" - const want: ExecutedQuery = { + const want = { headers: [], types: {}, fields: [], @@ -418,7 +418,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1'], rows: [{ ':vtg1': 1 }], types: { ':vtg1': 'INT32' }, @@ -452,7 +452,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1'], types: { ':vtg1': 'INT32' }, fields: [{ name: ':vtg1', type: 'INT32' }], @@ -486,7 +486,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1'], types: { ':vtg1': 'INT64' }, fields: [{ name: ':vtg1', type: 'INT64' }], @@ -521,7 +521,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: [':vtg1'], types: { ':vtg1': 'INT64' }, fields: [{ name: ':vtg1', type: 'INT64' }], @@ -558,7 +558,7 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { headers: ['document'], types: { document: 'JSON' }, fields: [{ name: 'document', type: 'JSON' }], @@ -593,7 +593,67 @@ describe('execute', () => { timing: 1 } - const want: ExecutedQuery = { + const want = { + headers: [':vtg1', 'null'], + types: { ':vtg1': 'INT32', null: 'NULL' }, + fields: [ + { name: ':vtg1', type: 'INT32' }, + { name: 'null', type: 'NULL' } + ], + rows: [{ ':vtg1': 1, null: null }], + size: 1, + statement: 'SELECT 1, null from dual;', + rowsAffected: 0, + insertId: '0', + time: 1000 + } + + interface Got { + ':vtg1'?: number + null?: null + } + + const isGot = (object: any): object is Got => { + return ':vtg1' in object && 'null' in object + } + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(null) + return mockResponse + }) + + const connection = connect(config) + const got = await connection.execute('SELECT 1, null from dual;') + + expect(got).toEqual(want) + expect(isGot(got.rows[0])).toBe(true) + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(mockSession) + return mockResponse + }) + + const got2 = await connection.execute('SELECT 1, null from dual;') + + expect(got2).toEqual(want) + expect(isGot(got2.rows[0])).toBe(true) + }) + + test('allows setting the type of rows in the query results', async () => { + const mockResponse = { + session: mockSession, + result: { + fields: [{ name: ':vtg1', type: 'INT32' }, { name: 'null' }], + rows: [{ lengths: ['1', '-1'], values: 'MQ==' }] + }, + timing: 1 + } + + const want = { headers: [':vtg1', 'null'], types: { ':vtg1': 'INT32', null: 'NULL' }, fields: [ diff --git a/src/index.ts b/src/index.ts index 0d3e90b..81e4bd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,6 @@ export { hex } from './text.js' import { decode } from './text.js' import { Version } from './version.js' -type Row | any[]> = T - interface VitessError { message: string code: string @@ -24,10 +22,10 @@ export class DatabaseError extends Error { type Types = Record -export interface ExecutedQuery { +export interface ExecutedQuery { headers: string[] types: Types - rows: Row[] + rows: T[] fields: Field[] size: number statement: string @@ -100,18 +98,9 @@ interface QueryResult { rows?: QueryResultRow[] } -type ExecuteAs = 'array' | 'object' - -type ExecuteOptions = { - as?: ExecuteAs - cast?: Cast -} - type ExecuteArgs = object | any[] | null -const defaultExecuteOptions: ExecuteOptions = { - as: 'object' -} +type ExecuteAs = 'array' | 'object' export class Client { private config: Config @@ -124,12 +113,22 @@ export class Client { return this.connection().transaction(fn) } - async execute( + async execute>( + query: string, + args?: ExecuteArgs, + options?: { as?: 'object'; cast?: Cast } + ): Promise> + async execute( + query: string, + args: ExecuteArgs, + options: { as: 'array'; cast?: Cast } + ): Promise> + async execute( query: string, args: ExecuteArgs = null, - options: ExecuteOptions = defaultExecuteOptions - ): Promise { - return this.connection().execute(query, args, options) + options: any = { as: 'object' } + ): Promise> { + return this.connection().execute(query, args, options) } connection(): Connection { @@ -146,12 +145,22 @@ class Tx { this.conn = conn } - async execute( + async execute>( + query: string, + args?: ExecuteArgs, + options?: { as?: 'object'; cast?: Cast } + ): Promise> + async execute( + query: string, + args: ExecuteArgs, + options: { as: 'array'; cast?: Cast } + ): Promise> + async execute( query: string, args: ExecuteArgs = null, - options: ExecuteOptions = defaultExecuteOptions - ): Promise { - return this.conn.execute(query, args, options) + options: any = { as: 'object' } + ): Promise> { + return this.conn.execute(query, args, options) } } @@ -209,11 +218,21 @@ export class Connection { await this.createSession() } - async execute( + async execute>( + query: string, + args?: ExecuteArgs, + options?: { as?: 'object'; cast?: Cast } + ): Promise> + async execute( + query: string, + args: ExecuteArgs, + options: { as: 'array'; cast?: Cast } + ): Promise> + async execute( query: string, args: ExecuteArgs = null, - options: ExecuteOptions = defaultExecuteOptions - ): Promise { + options: any = { as: 'object' } + ): Promise> { const url = new URL('/psdb.v1alpha1.Database/Execute', this.url) const formatter = this.config.format || format @@ -244,7 +263,7 @@ export class Connection { } const castFn = options.cast || this.config.cast || cast - const rows = result ? parse(result, castFn, options.as || 'object') : [] + const rows = result ? parse(result, castFn, options.as || 'object') : [] const headers = fields.map((f) => f.name) const typeByName = (acc, { name, type }) => ({ ...acc, [name]: type }) @@ -307,28 +326,28 @@ export function connect(config: Config): Connection { return new Connection(config) } -function parseArrayRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): Row { +function parseArrayRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) return fields.map((field, ix) => { return cast(field, row[ix]) - }) + }) as T } -function parseObjectRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): Row { +function parseObjectRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) return fields.reduce((acc, field, ix) => { acc[field.name] = cast(field, row[ix]) return acc - }, {} as Row) + }, {} as T) } -function parse(result: QueryResult, cast: Cast, returnAs: ExecuteAs): Row[] { +function parse(result: QueryResult, cast: Cast, returnAs: ExecuteAs): T[] { const fields = result.fields const rows = result.rows ?? [] return rows.map((row) => - returnAs === 'array' ? parseArrayRow(fields, row, cast) : parseObjectRow(fields, row, cast) + returnAs === 'array' ? parseArrayRow(fields, row, cast) : parseObjectRow(fields, row, cast) ) } From c31d98a2128a1c8d00e6f72c50a78aeeb89f0333 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Wed, 13 Dec 2023 17:57:34 +0100 Subject: [PATCH 04/34] No longer needed --- __tests__/index.test.ts | 123 +--------------------------------------- 1 file changed, 1 insertion(+), 122 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 985f10c..78861f1 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,5 +1,5 @@ import SqlString from 'sqlstring' -import { cast, connect, format, hex, ExecutedQuery, DatabaseError } from '../dist/index' +import { cast, connect, format, hex, DatabaseError } from '../dist/index' import { fetch, MockAgent, setGlobalDispatcher } from 'undici' import packageJSON from '../package.json' @@ -580,127 +580,6 @@ describe('execute', () => { const got = await connection.execute('select document from documents') expect(got).toEqual(want) - expect(got.rows[0].document).toEqual(want.rows[0].document) - }) - - test('allows setting the type of rows in the query results', async () => { - const mockResponse = { - session: mockSession, - result: { - fields: [{ name: ':vtg1', type: 'INT32' }, { name: 'null' }], - rows: [{ lengths: ['1', '-1'], values: 'MQ==' }] - }, - timing: 1 - } - - const want = { - headers: [':vtg1', 'null'], - types: { ':vtg1': 'INT32', null: 'NULL' }, - fields: [ - { name: ':vtg1', type: 'INT32' }, - { name: 'null', type: 'NULL' } - ], - rows: [{ ':vtg1': 1, null: null }], - size: 1, - statement: 'SELECT 1, null from dual;', - rowsAffected: 0, - insertId: '0', - time: 1000 - } - - interface Got { - ':vtg1'?: number - null?: null - } - - const isGot = (object: any): object is Got => { - return ':vtg1' in object && 'null' in object - } - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { - expect(opts.headers['Authorization']).toMatch(/Basic /) - const bodyObj = JSON.parse(opts.body.toString()) - expect(bodyObj.session).toEqual(null) - return mockResponse - }) - - const connection = connect(config) - const got = await connection.execute('SELECT 1, null from dual;') - - expect(got).toEqual(want) - expect(isGot(got.rows[0])).toBe(true) - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { - expect(opts.headers['Authorization']).toMatch(/Basic /) - const bodyObj = JSON.parse(opts.body.toString()) - expect(bodyObj.session).toEqual(mockSession) - return mockResponse - }) - - const got2 = await connection.execute('SELECT 1, null from dual;') - - expect(got2).toEqual(want) - expect(isGot(got2.rows[0])).toBe(true) - }) - - test('allows setting the type of rows in the query results', async () => { - const mockResponse = { - session: mockSession, - result: { - fields: [{ name: ':vtg1', type: 'INT32' }, { name: 'null' }], - rows: [{ lengths: ['1', '-1'], values: 'MQ==' }] - }, - timing: 1 - } - - const want = { - headers: [':vtg1', 'null'], - types: { ':vtg1': 'INT32', null: 'NULL' }, - fields: [ - { name: ':vtg1', type: 'INT32' }, - { name: 'null', type: 'NULL' } - ], - rows: [{ ':vtg1': 1, null: null }], - size: 1, - statement: 'SELECT 1, null from dual;', - rowsAffected: 0, - insertId: '0', - time: 1000 - } - - interface Got { - ':vtg1'?: number - null?: null - } - - const isGot = (object: any): object is Got => { - return ':vtg1' in object && 'null' in object - } - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { - expect(opts.headers['Authorization']).toMatch(/Basic /) - const bodyObj = JSON.parse(opts.body.toString()) - expect(bodyObj.session).toEqual(null) - return mockResponse - }) - - const connection = connect(config) - const got: ExecutedQuery = await connection.execute('SELECT 1, null from dual;') - - expect(got).toEqual(want) - expect(isGot(got.rows[0])).toBe(true) - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { - expect(opts.headers['Authorization']).toMatch(/Basic /) - const bodyObj = JSON.parse(opts.body.toString()) - expect(bodyObj.session).toEqual(mockSession) - return mockResponse - }) - - const got2: ExecutedQuery = await connection.execute('SELECT 1, null from dual;') - - expect(got2).toEqual(want) - expect(isGot(got2.rows[0])).toBe(true) }) }) From 42b2e8003d606ce97639d6b01e26aff5783da2fa Mon Sep 17 00:00:00 2001 From: Ayrton Date: Thu, 14 Dec 2023 12:20:29 +0100 Subject: [PATCH 05/34] Backwards compatible `ExecutedQuery` --- src/index.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 81e4bd9..0d1500c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export { hex } from './text.js' import { decode } from './text.js' import { Version } from './version.js' +type Row = T extends 'array' ? any[] : T extends 'object' ? Record : never + interface VitessError { message: string code: string @@ -22,7 +24,7 @@ export class DatabaseError extends Error { type Types = Record -export interface ExecutedQuery { +export interface ExecutedQuery | Row<'object'>> { headers: string[] types: Types rows: T[] @@ -98,10 +100,10 @@ interface QueryResult { rows?: QueryResultRow[] } -type ExecuteArgs = object | any[] | null - type ExecuteAs = 'array' | 'object' +type ExecuteArgs = object | any[] | null + export class Client { private config: Config @@ -113,17 +115,17 @@ export class Client { return this.connection().transaction(fn) } - async execute>( + async execute>( query: string, args?: ExecuteArgs, options?: { as?: 'object'; cast?: Cast } ): Promise> - async execute( + async execute>( query: string, args: ExecuteArgs, options: { as: 'array'; cast?: Cast } ): Promise> - async execute( + async execute | Row<'array'>>( query: string, args: ExecuteArgs = null, options: any = { as: 'object' } @@ -145,17 +147,17 @@ class Tx { this.conn = conn } - async execute>( + async execute>( query: string, args?: ExecuteArgs, options?: { as?: 'object'; cast?: Cast } ): Promise> - async execute( + async execute>( query: string, args: ExecuteArgs, options: { as: 'array'; cast?: Cast } ): Promise> - async execute( + async execute | Row<'array'>>( query: string, args: ExecuteArgs = null, options: any = { as: 'object' } @@ -218,17 +220,17 @@ export class Connection { await this.createSession() } - async execute>( + async execute>( query: string, args?: ExecuteArgs, options?: { as?: 'object'; cast?: Cast } ): Promise> - async execute( + async execute>( query: string, args: ExecuteArgs, options: { as: 'array'; cast?: Cast } ): Promise> - async execute( + async execute | Row<'array'>>( query: string, args: ExecuteArgs = null, options: any = { as: 'object' } @@ -326,7 +328,7 @@ export function connect(config: Config): Connection { return new Connection(config) } -function parseArrayRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { +function parseArrayRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) return fields.map((field, ix) => { @@ -334,7 +336,7 @@ function parseArrayRow(fields: Field[], rawRow: QueryResultRow, cast: }) as T } -function parseObjectRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { +function parseObjectRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) return fields.reduce((acc, field, ix) => { From f4f7177b542d5ece0a3e174a24fd2c27d299df0e Mon Sep 17 00:00:00 2001 From: Ayrton Date: Thu, 14 Dec 2023 23:36:10 +0100 Subject: [PATCH 06/34] Update README.md (#155) --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ca6b37..0cfb01c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# PlanetScale Serverless Driver for JavaScript +![PlanetScale serverless driver for JavaScript](https://github.com/planetscale/database-js/assets/440926/0dfab33f-b01f-4814-ae40-c5fe5cbe94e3) + +# PlanetScale serverless driver for JavaScript A Fetch API-compatible PlanetScale database driver for serverless and edge compute platforms that require HTTP external connections, such as Cloudflare Workers or Vercel Edge Functions ## Installation -``` +```sh npm install @planetscale/database ``` @@ -224,11 +226,15 @@ const arrays = conn.execute(query, [1], { as: 'array' }) ## Development -``` +```sh npm install npm test ``` +## Need help? + +Get help from [the PlanetScale support team](https://support.planetscale.com/), or [join our community on Discord](https://discord.gg/EqrcEf2dGv) or [GitHub discussion board](https://github.com/planetscale/discussion/discussions) to see how others are using PlanetScale. + ## License Distributed under the Apache 2.0 license. See LICENSE for details. From b02532cef59cf84e01c0d89c1a7ee15e83770d87 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 15 Dec 2023 00:42:27 +0100 Subject: [PATCH 07/34] 1.12.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd6217e..41e1cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.11.0", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.11.0", + "version": "1.12.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index c47d3fb..19588f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.11.0", + "version": "1.12.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 83eb781..9b30310 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.11.0' +export const Version = '1.12.0' From e68c20eab9737a7f663b7efaf08631399df3dec4 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 15 Dec 2023 00:43:22 +0100 Subject: [PATCH 08/34] 1.13.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41e1cea..2c57267 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.12.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.12.0", + "version": "1.13.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index 19588f4..16f6c5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.12.0", + "version": "1.13.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 9b30310..57ca641 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.12.0' +export const Version = '1.13.0' From b7c7bc1a70bf63ad400b78a2a6dbe3049914ba66 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 15 Dec 2023 11:27:36 +0100 Subject: [PATCH 09/34] Strict check --- __tests__/index.test.ts | 32 +++++++++++++------------- src/index.ts | 50 ++++++++++++++++++++++++----------------- src/sanitization.ts | 2 +- src/text.ts | 2 +- tsconfig.json | 2 +- 5 files changed, 49 insertions(+), 39 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 78861f1..d7e284e 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,5 +1,5 @@ import SqlString from 'sqlstring' -import { cast, connect, format, hex, DatabaseError } from '../dist/index' +import { cast, connect, format, hex, DatabaseError, type Cast } from '../dist/index' import { fetch, MockAgent, setGlobalDispatcher } from 'undici' import packageJSON from '../package.json' @@ -29,7 +29,7 @@ describe('config', () => { result: { fields: [], rows: [] } } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toEqual(`Basic ${btoa('someuser:password')}`) expect(opts.headers['User-Agent']).toEqual(`database-js/${packageJSON.version}`) return mockResponse @@ -46,7 +46,7 @@ describe('config', () => { result: { fields: [], rows: [] } } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toEqual(`Basic ${btoa('someuser:password')}`) expect(opts.headers['User-Agent']).toEqual(`database-js/${packageJSON.version}`) return mockResponse @@ -158,7 +158,7 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toMatch(/Basic /) const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.session).toEqual(null) @@ -170,7 +170,7 @@ describe('execute', () => { expect(got).toEqual(want) - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toMatch(/Basic /) const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.session).toEqual(mockSession) @@ -204,7 +204,7 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toMatch(/Basic /) const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.session).toEqual(null) @@ -216,7 +216,7 @@ describe('execute', () => { expect(got).toEqual(want) - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toMatch(/Basic /) const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.session).toEqual(mockSession) @@ -250,7 +250,7 @@ describe('execute', () => { insertId: '0' } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { expect(opts.headers['Authorization']).toMatch(/Basic /) const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.session).toEqual(null) @@ -430,7 +430,7 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.query).toEqual(want.statement) return mockResponse @@ -464,7 +464,7 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.query).toEqual(want.statement) return mockResponse @@ -498,13 +498,13 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.query).toEqual(want.statement) return mockResponse }) - const inflate = (field, value) => (field.type === 'INT64' ? BigInt(value) : value) + const inflate: Cast = (field, value) => (field.type === 'INT64' ? BigInt(value as string) : value) const connection = connect({ ...config, cast: inflate }) const got = await connection.execute('select 1 from dual') @@ -533,13 +533,13 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.query).toEqual(want.statement) return mockResponse }) - const connInflate = (field, value) => (field.type === 'INT64' ? 'I am a biggish int' : value) - const inflate = (field, value) => (field.type === 'INT64' ? BigInt(value) : value) + const connInflate: Cast = (field, value) => (field.type === 'INT64' ? 'I am a biggish int' : value) + const inflate: Cast = (field, value) => (field.type === 'INT64' ? BigInt(value as string) : value) const connection = connect({ ...config, cast: inflate }) const got = await connection.execute('select 1 from dual', {}, { cast: connInflate }) @@ -570,7 +570,7 @@ describe('execute', () => { time: 1000 } - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts) => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { const bodyObj = JSON.parse(opts.body.toString()) expect(bodyObj.query).toEqual(want.statement) return mockResponse diff --git a/src/index.ts b/src/index.ts index 0d1500c..37de97d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,8 @@ export interface ExecutedQuery | Row<'object'>> { time: number } +type Fetch = (input: string, init?: Req) => Promise + type Req = { method: string headers: Record @@ -52,14 +54,15 @@ type Res = { } export type Cast = typeof cast +type Format = typeof format export interface Config { url?: string username?: string password?: string host?: string - fetch?: (input: string, init?: Req) => Promise - format?: (query: string, args: any) => string + fetch?: Fetch + format?: Format cast?: Cast } @@ -102,7 +105,7 @@ interface QueryResult { type ExecuteAs = 'array' | 'object' -type ExecuteArgs = object | any[] | null +type ExecuteArgs = Record | any[] | null export class Client { private config: Config @@ -178,17 +181,19 @@ function buildURL(url: URL): string { export class Connection { private config: Config + private fetch: Fetch private session: QuerySession | null private url: string constructor(config: Config) { - this.session = null - this.config = { ...config } - - if (typeof fetch !== 'undefined') { - this.config.fetch ||= fetch + if (!config.fetch && typeof fetch === 'undefined') { + throw new Error('No `fetch` implementation available') } + this.config = { ...config } + this.fetch = config.fetch || fetch + this.session = null + if (config.url) { const url = new URL(config.url) this.config.username = url.username @@ -240,7 +245,10 @@ export class Connection { const formatter = this.config.format || format const sql = args ? formatter(query, args) : query - const saved = await postJSON(this.config, url, { query: sql, session: this.session }) + const saved = await postJSON(this.config, this.fetch, url, { + query: sql, + session: this.session + }) const { result, session, error, timing } = saved if (session) { @@ -268,7 +276,7 @@ export class Connection { const rows = result ? parse(result, castFn, options.as || 'object') : [] const headers = fields.map((f) => f.name) - const typeByName = (acc, { name, type }) => ({ ...acc, [name]: type }) + const typeByName = (acc: Types, { name, type }: Field) => ({ ...acc, [name]: type }) const types = fields.reduce(typeByName, {}) const timingSeconds = timing ?? 0 @@ -287,15 +295,14 @@ export class Connection { private async createSession(): Promise { const url = new URL('/psdb.v1alpha1.Database/CreateSession', this.url) - const { session } = await postJSON(this.config, url) + const { session } = await postJSON(this.config, this.fetch, url) this.session = session return session } } -async function postJSON(config: Config, url: string | URL, body = {}): Promise { +async function postJSON(config: Config, fetch: Fetch, url: string | URL, body = {}): Promise { const auth = btoa(`${config.username}:${config.password}`) - const { fetch } = config const response = await fetch(url.toString(), { method: 'POST', body: JSON.stringify(body), @@ -328,7 +335,7 @@ export function connect(config: Config): Connection { return new Connection(config) } -function parseArrayRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { +function parseArrayRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) return fields.map((field, ix) => { @@ -336,17 +343,20 @@ function parseArrayRow>(fields: Field[], rawRow: QueryResultRow }) as T } -function parseObjectRow>(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { +function parseObjectRow(fields: Field[], rawRow: QueryResultRow, cast: Cast): T { const row = decodeRow(rawRow) - return fields.reduce((acc, field, ix) => { - acc[field.name] = cast(field, row[ix]) - return acc - }, {} as T) + return fields.reduce( + (acc, field, ix) => { + acc[field.name] = cast(field, row[ix]) + return acc + }, + {} as Record> + ) as T } function parse(result: QueryResult, cast: Cast, returnAs: ExecuteAs): T[] { - const fields = result.fields + const fields = result.fields ?? [] const rows = result.rows ?? [] return rows.map((row) => returnAs === 'array' ? parseArrayRow(fields, row, cast) : parseObjectRow(fields, row, cast) diff --git a/src/sanitization.ts b/src/sanitization.ts index ecf4220..53b5f08 100644 --- a/src/sanitization.ts +++ b/src/sanitization.ts @@ -1,7 +1,7 @@ type Stringable = { toString: () => string } type Value = null | undefined | number | boolean | string | Array | Date | Stringable -export function format(query: string, values: Value[] | Record): string { +export function format(query: string, values: Record | any[]): string { return Array.isArray(values) ? replacePosition(query, values) : replaceNamed(query, values) } diff --git a/src/text.ts b/src/text.ts index 9680948..7f4cbba 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,6 +1,6 @@ const decoder = new TextDecoder('utf-8') -export function decode(text: string | null): string { +export function decode(text: string | null | undefined): string { return text ? decoder.decode(Uint8Array.from(bytes(text))) : '' } diff --git a/tsconfig.json b/tsconfig.json index 1015c04..542c2d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "es2020", "target": "es2020", - "strict": false, + "strict": true, "declaration": true, "outDir": "dist", "removeComments": true, From b4bdee097ee67d326f8f8bb6b9d36fa82f47906c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 18 Dec 2023 17:34:07 +0100 Subject: [PATCH 10/34] Expose `config` as a public property (#153) Expose connection config as a public property In Prisma's driver adapter for Planetscale, we need to be able to get database name from the connection string. --- __tests__/index.test.ts | 12 ++++++++++++ src/index.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 78861f1..3a187c5 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -56,6 +56,18 @@ describe('config', () => { const got = await connection.execute('SELECT 1 from dual;') expect(got).toBeDefined() }) + + test('exposes config as a public field', async () => { + const config = { url: 'mysql://someuser:password@example.com/db' } + const connection = connect(config) + expect(connection.config).toEqual({ + fetch: expect.any(Function), + host: 'example.com', + username: 'someuser', + password: 'password', + url: 'mysql://someuser:password@example.com/db' + }) + }) }) describe('transaction', () => { diff --git a/src/index.ts b/src/index.ts index 0d1500c..f926833 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,7 +105,7 @@ type ExecuteAs = 'array' | 'object' type ExecuteArgs = object | any[] | null export class Client { - private config: Config + public readonly config: Config constructor(config: Config) { this.config = config @@ -177,7 +177,7 @@ function buildURL(url: URL): string { } export class Connection { - private config: Config + public readonly config: Config private session: QuerySession | null private url: string From 5b91c6216f8ef6b8ce7c73b3cc610804833cead1 Mon Sep 17 00:00:00 2001 From: jkomyno Date: Thu, 18 Jan 2024 19:37:22 +0100 Subject: [PATCH 11/34] test(sanitization): add test showcasing current string quotes around bigint params --- __tests__/sanitization.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/__tests__/sanitization.test.ts b/__tests__/sanitization.test.ts index 0c06a73..96c09bf 100644 --- a/__tests__/sanitization.test.ts +++ b/__tests__/sanitization.test.ts @@ -36,6 +36,12 @@ describe('sanitization', () => { expect(format(query, [12, 42])).toEqual(expected) }) + test('formats bigint values', () => { + const query = 'select 1 from user where id=? and id2=? and id3=?' + const expected = 'select 1 from user where id=12 and id2=42 and id3=9223372036854775807' + expect(format(query, [12n, 42n, 9223372036854775807n])).toEqual(expected) + }) + test('formats string values', () => { const query = 'select 1 from user where state=?' const expected = "select 1 from user where state='active'" From 10d94ac8f0034ec5f02999ef48b67c34c541c1ae Mon Sep 17 00:00:00 2001 From: jkomyno Date: Thu, 18 Jan 2024 19:37:36 +0100 Subject: [PATCH 12/34] fix(sanitization): avoid adding quotes to bigint numbers --- src/sanitization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sanitization.ts b/src/sanitization.ts index ecf4220..7b4c25b 100644 --- a/src/sanitization.ts +++ b/src/sanitization.ts @@ -27,7 +27,7 @@ function sanitize(value: Value): string { return 'null' } - if (typeof value === 'number') { + if (['number', 'bigint'].includes(typeof value)) { return String(value) } From 87bb4ceb97cc57f9ca57d822b6246a9746709130 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Thu, 18 Jan 2024 20:22:32 +0100 Subject: [PATCH 13/34] 1.14.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c57267..c3530b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.13.0", + "version": "1.14.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index 16f6c5c..9e4c43c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.13.0", + "version": "1.14.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 57ca641..233a988 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.13.0' +export const Version = '1.14.0' From ecae72385e141c696be42a32341fb165ec2a024c Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 26 Jan 2024 17:44:16 +0100 Subject: [PATCH 14/34] Remove run-time error --- src/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 04f4fad..0312a08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,12 +186,8 @@ export class Connection { private url: string constructor(config: Config) { - if (!config.fetch && typeof fetch === 'undefined') { - throw new Error('No `fetch` implementation available') - } - - this.config = { ...config } - this.fetch = config.fetch || fetch + this.config = config + this.fetch = config.fetch || fetch! this.session = null if (config.url) { From 26516a3f43b7aec5012977e59b11f7427bfd1e5d Mon Sep 17 00:00:00 2001 From: Ayrton Date: Tue, 30 Jan 2024 12:04:14 +0100 Subject: [PATCH 15/34] Extract type --- src/index.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0312a08..7c11143 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,12 @@ type ExecuteAs = 'array' | 'object' type ExecuteArgs = Record | any[] | null +type ExecuteOptions = T extends 'array' + ? { as?: 'object'; cast?: Cast } + : T extends 'object' + ? { as: 'array'; cast?: Cast } + : never + export class Client { public readonly config: Config @@ -121,12 +127,12 @@ export class Client { async execute>( query: string, args?: ExecuteArgs, - options?: { as?: 'object'; cast?: Cast } + options?: ExecuteOptions<'object'> ): Promise> async execute>( query: string, args: ExecuteArgs, - options: { as: 'array'; cast?: Cast } + options: ExecuteOptions<'array'> ): Promise> async execute | Row<'array'>>( query: string, @@ -153,12 +159,12 @@ class Tx { async execute>( query: string, args?: ExecuteArgs, - options?: { as?: 'object'; cast?: Cast } + options?: ExecuteOptions<'object'> ): Promise> async execute>( query: string, args: ExecuteArgs, - options: { as: 'array'; cast?: Cast } + options: ExecuteOptions<'array'> ): Promise> async execute | Row<'array'>>( query: string, @@ -224,12 +230,12 @@ export class Connection { async execute>( query: string, args?: ExecuteArgs, - options?: { as?: 'object'; cast?: Cast } + options?: ExecuteOptions<'object'> ): Promise> async execute>( query: string, args: ExecuteArgs, - options: { as: 'array'; cast?: Cast } + options: ExecuteOptions<'array'> ): Promise> async execute | Row<'array'>>( query: string, From 4b126f5975e3fd23d182ae177efbd2e25a70fd4a Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Wed, 31 Jan 2024 12:13:42 -0800 Subject: [PATCH 16/34] Encode a Buffer as hexadecimal This handles better for BLOB type columns. Without this, it basically would have only worked for ASCII binary data since it gets Buffer.toString()'d, but then does an extra quoting and sanitization pass. For binary data, which we can assume is coming from a Buffer, we want to send as hexadecimal. Fixes #161 --- src/sanitization.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sanitization.ts b/src/sanitization.ts index ecdd5d6..5e6d9a4 100644 --- a/src/sanitization.ts +++ b/src/sanitization.ts @@ -47,6 +47,10 @@ function sanitize(value: Value): string { return quote(value.toISOString().slice(0, -1)) } + if (value instanceof Buffer) { + return `0x${value.toString('hex')}` + } + return quote(value.toString()) } From 7ea9e2a2156c168d8443dadac465d668514afa23 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Wed, 31 Jan 2024 21:58:29 +0100 Subject: [PATCH 17/34] Cast blobs as binary buffers --- src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7c11143..0fdaf25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -404,12 +404,13 @@ export function cast(field: Field, value: string | null): any { case 'TIME': case 'DATETIME': case 'TIMESTAMP': - case 'BLOB': case 'BIT': - case 'VARBINARY': case 'BINARY': case 'GEOMETRY': return value + case 'BLOB': + case 'VARBINARY': + return new Buffer(value, "binary"); case 'JSON': return JSON.parse(decode(value)) default: From de52a28ca6d092c62468623308b5a38a85e1ad1d Mon Sep 17 00:00:00 2001 From: ayrton Date: Wed, 31 Jan 2024 20:59:18 +0000 Subject: [PATCH 18/34] Use browser compatible Uint8Array --- __tests__/index.test.ts | 5 +++++ __tests__/text.test.ts | 15 ++++++++++++++- jest.config.ts | 6 ++++-- src/index.ts | 14 +++++++------- src/sanitization.ts | 6 ++++-- src/text.ts | 11 ++++++++++- 6 files changed, 44 insertions(+), 13 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 8b75e15..1aa2dfc 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -626,6 +626,11 @@ describe('cast', () => { expect(cast({ name: 'test', type: 'FLOAT64' }, '2.32')).toEqual(2.32) }) + test('casts binary data to array of 8-bit unsigned integers', () => { + expect(cast({ name: 'test', type: 'BLOB' }, '')).toEqual(new Uint8Array([])) + expect(cast({ name: 'test', type: 'BLOB' }, 'Å')).toEqual(new Uint8Array([197])) + }) + test('casts JSON string to JSON object', () => { expect(cast({ name: 'test', type: 'JSON' }, '{ "foo": "bar" }')).toStrictEqual({ foo: 'bar' }) }) diff --git a/__tests__/text.test.ts b/__tests__/text.test.ts index 7fcb358..75cbad4 100644 --- a/__tests__/text.test.ts +++ b/__tests__/text.test.ts @@ -1,4 +1,4 @@ -import { decode, hex } from '../src/text' +import { decode, hex, uint8Array, uint8ArrayToHex } from '../src/text' describe('text', () => { describe('decode', () => { @@ -32,4 +32,17 @@ describe('text', () => { expect(hex('aa')).toEqual('0x6161') }) }) + + describe('uint8Array', () => { + test('converts to an array of 8-bit unsigned integers', () => { + expect(uint8Array('')).toEqual(new Uint8Array([])) + expect(uint8Array('Å')).toEqual(new Uint8Array([197])) + }) + }) + + describe('uint8ArrayToHex', () => { + test('converts an array of 8-bit unsigned integers to hex', () => { + expect(uint8ArrayToHex(new Uint8Array([197]))).toEqual('0xc5') + }) + }) }) diff --git a/jest.config.ts b/jest.config.ts index 38eead4..ffe9db7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,7 +16,7 @@ export default { testEnvironment: 'node', // Automatically clear mock calls, instances, contexts and results before every test - clearMocks: true + clearMocks: true, // Indicates whether the coverage information should be collected while executing the test // collectCoverage: false, @@ -90,7 +90,9 @@ export default { // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, + moduleNameMapper: { + './text.js': './text' + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/src/index.ts b/src/index.ts index 0fdaf25..a88d55b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { format } from './sanitization.js' export { format } from './sanitization.js' +import { decode, uint8Array } from './text.js' export { hex } from './text.js' -import { decode } from './text.js' import { Version } from './version.js' type Row = T extends 'array' ? any[] : T extends 'object' ? Record : never @@ -379,7 +379,7 @@ function decodeRow(row: QueryResultRow): Array { } export function cast(field: Field, value: string | null): any { - if (value === '' || value == null) { + if (value == null) { return value } @@ -393,7 +393,7 @@ export function cast(field: Field, value: string | null): any { case 'UINT24': case 'UINT32': case 'YEAR': - return parseInt(value, 10) + return value ? parseInt(value, 10) : value case 'FLOAT32': case 'FLOAT64': return parseFloat(value) @@ -404,15 +404,15 @@ export function cast(field: Field, value: string | null): any { case 'TIME': case 'DATETIME': case 'TIMESTAMP': - case 'BIT': - case 'BINARY': case 'GEOMETRY': return value case 'BLOB': + case 'BIT': case 'VARBINARY': - return new Buffer(value, "binary"); + case 'BINARY': + return uint8Array(value) case 'JSON': - return JSON.parse(decode(value)) + return value ? JSON.parse(decode(value)) : value default: return decode(value) } diff --git a/src/sanitization.ts b/src/sanitization.ts index 5e6d9a4..e646bc8 100644 --- a/src/sanitization.ts +++ b/src/sanitization.ts @@ -1,3 +1,5 @@ +import { uint8ArrayToHex } from './text.js' + type Stringable = { toString: () => string } type Value = null | undefined | number | boolean | string | Array | Date | Stringable @@ -47,8 +49,8 @@ function sanitize(value: Value): string { return quote(value.toISOString().slice(0, -1)) } - if (value instanceof Buffer) { - return `0x${value.toString('hex')}` + if (value instanceof Uint8Array) { + return uint8ArrayToHex(value) } return quote(value.toString()) diff --git a/src/text.ts b/src/text.ts index 7f4cbba..80fb74e 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,7 +1,7 @@ const decoder = new TextDecoder('utf-8') export function decode(text: string | null | undefined): string { - return text ? decoder.decode(Uint8Array.from(bytes(text))) : '' + return text ? decoder.decode(uint8Array(text)) : '' } export function hex(text: string): string { @@ -9,6 +9,15 @@ export function hex(text: string): string { return `0x${digits.join('')}` } +export function uint8Array(text: string): Uint8Array { + return Uint8Array.from(bytes(text)) +} + +export function uint8ArrayToHex(uint8: Uint8Array): string { + const digits = Array.from(uint8).map((i) => i.toString(16).padStart(2, '0')) + return `0x${digits.join('')}` +} + function bytes(text: string): number[] { return text.split('').map((c) => c.charCodeAt(0)) } From b59368d3c202de7d99fac7960afee068628d3f2a Mon Sep 17 00:00:00 2001 From: Ayrton Date: Thu, 1 Feb 2024 12:48:00 +0100 Subject: [PATCH 19/34] Returning an empty stering for ints is more confusing than a NaN --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a88d55b..ea0365e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -393,7 +393,7 @@ export function cast(field: Field, value: string | null): any { case 'UINT24': case 'UINT32': case 'YEAR': - return value ? parseInt(value, 10) : value + return parseInt(value, 10) case 'FLOAT32': case 'FLOAT64': return parseFloat(value) From 218b85c107e922ab140686f817a065376c425e7d Mon Sep 17 00:00:00 2001 From: Ayrton Date: Thu, 1 Feb 2024 12:49:04 +0100 Subject: [PATCH 20/34] Geometry is binary data --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ea0365e..3436f9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -404,12 +404,12 @@ export function cast(field: Field, value: string | null): any { case 'TIME': case 'DATETIME': case 'TIMESTAMP': - case 'GEOMETRY': return value case 'BLOB': case 'BIT': case 'VARBINARY': case 'BINARY': + case 'GEOMETRY': return uint8Array(value) case 'JSON': return value ? JSON.parse(decode(value)) : value From 19a65a62037e5435f0fb76c59ed052d5ee863813 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Feb 2024 12:47:16 +0100 Subject: [PATCH 21/34] 1.15.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3530b0..48310f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.14.0", + "version": "1.15.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index 9e4c43c..c4cabd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.14.0", + "version": "1.15.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 233a988..9ecb0a7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.14.0' +export const Version = '1.15.0' From 27fc879fcb09ca1ad41a15909869d5025aa97b79 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 9 Feb 2024 22:08:02 +0100 Subject: [PATCH 22/34] Don't return 8-bit signed integers for binary text data --- __tests__/index.test.ts | 5 +++++ src/index.ts | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 1aa2dfc..899b819 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -629,6 +629,11 @@ describe('cast', () => { test('casts binary data to array of 8-bit unsigned integers', () => { expect(cast({ name: 'test', type: 'BLOB' }, '')).toEqual(new Uint8Array([])) expect(cast({ name: 'test', type: 'BLOB' }, 'Å')).toEqual(new Uint8Array([197])) + expect(cast({ name: 'test', type: 'VARBINARY' }, 'Å')).toEqual(new Uint8Array([197])) + }) + + test('casts binary text data to text', () => { + expect(cast({ name: 'test', type: 'VARBINARY', flags: 4225 }, 'table')).toEqual('table') }) test('casts JSON string to JSON object', () => { diff --git a/src/index.ts b/src/index.ts index 3436f9c..0454bae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -407,13 +407,28 @@ export function cast(field: Field, value: string | null): any { return value case 'BLOB': case 'BIT': - case 'VARBINARY': - case 'BINARY': case 'GEOMETRY': return uint8Array(value) + case 'BINARY': + case 'VARBINARY': + return isText(field) ? value : uint8Array(value) case 'JSON': return value ? JSON.parse(decode(value)) : value default: return decode(value) } } + +enum Flags { + NONE = 0, + ISINTEGRAL = 256, + ISUNSIGNED = 512, + ISFLOAT = 1024, + ISQUOTED = 2048, + ISTEXT = 4096, + ISBINARY = 8192 +} + +function isText(field: Field): boolean { + return ((field.flags ?? 0) & Flags.ISTEXT) === Flags.ISTEXT +} From 0412106eb6a6971a0b80470e6bacb7a61991690a Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 9 Feb 2024 22:40:20 +0100 Subject: [PATCH 23/34] Doc --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index 0454bae..b97446b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -419,6 +419,10 @@ export function cast(field: Field, value: string | null): any { } } +/** + * https://github.com/planetscale/vitess-types/blob/main/src/vitess/query/v17/query.proto#L98-L106 + */ + enum Flags { NONE = 0, ISINTEGRAL = 256, From 01529104a26d601e95c33b1004f7c5c5a9eb5701 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 9 Feb 2024 22:50:08 +0100 Subject: [PATCH 24/34] 1.16.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48310f7..6176705 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.15.0", + "version": "1.16.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index c4cabd0..6589376 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.15.0", + "version": "1.16.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 9ecb0a7..3d59361 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.15.0' +export const Version = '1.16.0' From d78921c2f0dc0717768ae201cf11cb7b69cec30b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:30:12 +0000 Subject: [PATCH 25/34] Bump undici from 5.22.1 to 5.28.3 Bumps [undici](https://github.com/nodejs/undici) from 5.22.1 to 5.28.3. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v5.22.1...v5.28.3) --- updated-dependencies: - dependency-name: undici dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6176705..4a46ad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -714,6 +714,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -1960,18 +1969,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4758,15 +4755,6 @@ "node": ">=8" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -5086,12 +5074,12 @@ } }, "node_modules/undici": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", - "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", + "version": "5.28.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", + "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", "dev": true, "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { "node": ">=14.0" From 4948f53ad940db2aa7c53d2850d639d6ef295953 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:28:09 +0000 Subject: [PATCH 26/34] Bump undici from 5.28.3 to 5.28.4 Bumps [undici](https://github.com/nodejs/undici) from 5.28.3 to 5.28.4. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v5.28.3...v5.28.4) --- updated-dependencies: - dependency-name: undici dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a46ad9..c3ca435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5074,9 +5074,9 @@ } }, "node_modules/undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dev": true, "dependencies": { "@fastify/busboy": "^2.0.0" From ac5bdc42759ce2c5076befd48ae72c434474a917 Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Thu, 25 Apr 2024 14:58:15 -0700 Subject: [PATCH 27/34] Fix cast logic (#174) * Fix cast logic There's a lot to unpack here, but the tl;dr is to refer to the charset ultimately to determine if the data is UTF8, if it is, we can decode it to a UTF8 string. This fixes behavior around CHAR/TEXT fields with a binary collation, being surfaces as BINARY/BLOB types by MySQL. For all intents and purposes, BLOB/BINARY/CHAR/TEXT are all effectively identical and interchangeable, the only differentiator is their charset. Either they are a UTF-8 charset, or a binary charset or some other charset. Fixes #169 --------- Co-authored-by: Ayrton --- __tests__/cast.test.ts | 126 +++++++++ __tests__/golden.test.ts | 130 ++++++++++ __tests__/index.test.ts | 38 +-- __tests__/text.test.ts | 14 +- golden/README.md | 15 ++ golden/cli.txt | 5 + golden/database.json | 535 +++++++++++++++++++++++++++++++++++++++ golden/dual.json | 18 ++ golden/generate.sh | 17 ++ golden/test.sql | 95 +++++++ src/cast.ts | 94 +++++++ src/index.ts | 63 +---- src/text.ts | 2 +- 13 files changed, 1053 insertions(+), 99 deletions(-) create mode 100644 __tests__/cast.test.ts create mode 100644 __tests__/golden.test.ts create mode 100644 golden/README.md create mode 100644 golden/cli.txt create mode 100644 golden/database.json create mode 100644 golden/dual.json create mode 100755 golden/generate.sh create mode 100644 golden/test.sql create mode 100644 src/cast.ts diff --git a/__tests__/cast.test.ts b/__tests__/cast.test.ts new file mode 100644 index 0000000..d1f557c --- /dev/null +++ b/__tests__/cast.test.ts @@ -0,0 +1,126 @@ +import { cast } from '../src/cast' + +describe('cast', () => { + test('casts NULL values', () => { + expect( + cast( + { + name: 'email', + type: 'VARCHAR' + }, + null + ) + ).toEqual(null) + }) + + test('casts INT64, UINT64 values', () => { + expect( + cast( + { + name: 'id', + type: 'UINT64' + }, + '1' + ) + ).toEqual('1') + }) + + test('casts DATETIME, DATE, TIMESTAMP, TIME values', () => { + expect( + cast( + { + name: 'created_at', + type: 'DATETIME' + }, + '2024-01-01 00:00:00' + ) + ).toEqual('2024-01-01 00:00:00') + }) + + test('casts DECIMAL values', () => { + expect( + cast( + { + name: 'decimal', + type: 'DECIMAL' + }, + '5.4' + ) + ).toEqual('5.4') + }) + + test('casts JSON values', () => { + expect( + cast( + { + name: 'metadata', + type: 'JSON' + }, + '{ "color": "blue" }' + ) + ).toStrictEqual({ color: 'blue' }) + }) + + test('casts INT8, UINT8, INT16, UINT16, INT24, UINT24, INT32, UINT32, INT64, UINT64, YEAR values', () => { + expect( + cast( + { + name: 'verified', + type: 'INT8' + }, + '1' + ) + ).toEqual(1) + expect( + cast( + { + name: 'age', + type: 'INT32' + }, + '21' + ) + ).toEqual(21) + }) + + test('casts FLOAT32, FLOAT64 values', () => { + expect( + cast( + { + name: 'float', + type: 'FLOAT32' + }, + '20.4' + ) + ).toEqual(20.4) + expect( + cast( + { + name: 'double', + type: 'FLOAT64' + }, + '101.4' + ) + ).toEqual(101.4) + }) + + test('casts BLOB, BIT, GEOMETRY, BINARY, VARBINARY values', () => { + /** See e2e tests in __tests__/golden.test.ts. */ + }) + + test('casts BINARY, VARBINARY string values', () => { + /** See e2e tests in __tests__/golden.test.ts. */ + }) + + test('casts VARCHAR values', () => { + expect( + cast( + { + name: 'email', + type: 'VARCHAR', + charset: 255 + }, + 'user@planetscale.com' + ) + ).toEqual('user@planetscale.com') + }) +}) diff --git a/__tests__/golden.test.ts b/__tests__/golden.test.ts new file mode 100644 index 0000000..4c064f8 --- /dev/null +++ b/__tests__/golden.test.ts @@ -0,0 +1,130 @@ +import { connect } from '../dist/index' +import { fetch, MockAgent, setGlobalDispatcher } from 'undici' + +import database from '../golden/database.json' +import dual from '../golden/dual.json' + +const mockHosts = ['http://localhost:8080', 'https://example.com'] +const EXECUTE_PATH = '/psdb.v1alpha1.Database/Execute' + +const mockAgent = new MockAgent() +mockAgent.disableNetConnect() + +setGlobalDispatcher(mockAgent) + +const config = { + username: 'someuser', + password: 'password', + host: 'example.com', + fetch +} + +// Provide the base url to the request +const mockPool = mockAgent.get((value) => mockHosts.includes(value)) +const mockSession = 42 + +describe('golden', () => { + test('runs e2e database tests', async () => { + const mockResponse = { + session: mockSession, + result: database, + timing: 1 + } + + const want = { + id: '1', + a: 1, + b: 1, + c: 1, + d: 1, + e: '1', + f: '1.1', + g: '1.1', + h: 1.1, + i: 1.1, + j: uint8ArrayFromHex('0x07'), + k: '1000-01-01', + l: '1000-01-01 01:01:01', + m: '1970-01-01 00:01:01', + n: '01:01:01', + o: 2006, + p: 'p', + q: 'q', + r: uint8ArrayFromHex('0x72000000'), + s: uint8ArrayFromHex('0x73'), + t: uint8ArrayFromHex('0x74'), + u: uint8ArrayFromHex('0x75'), + v: uint8ArrayFromHex('0x76'), + w: uint8ArrayFromHex('0x77'), + x: 'x', + y: 'y', + z: 'z', + aa: 'aa', + ab: 'foo', + ac: 'foo,bar', + ad: { ad: null }, + ae: uint8ArrayFromHex( + '0x0000000001020000000300000000000000000000000000000000000000000000000000F03F000000000000F03F00000000000000400000000000000000' + ), + af: uint8ArrayFromHex('0x000000000101000000000000000000F03F000000000000F03F'), + ag: uint8ArrayFromHex( + '0x0000000001020000000300000000000000000000000000000000000000000000000000F03F000000000000F03F00000000000000400000000000000000' + ), + ah: uint8ArrayFromHex( + '0x00000000010300000002000000040000000000000000000000000000000000000000000000000000000000000000000840000000000000084000000000000000000000000000000000000000000000000004000000000000000000F03F000000000000F03F000000000000F03F00000000000000400000000000000040000000000000F03F000000000000F03F000000000000F03F' + ), + ai: 1, + aj: 1, + ak: 1, + al: '1', + xa: 'xa', + xb: 'xb', + xc: uint8ArrayFromHex('0x78630000'), + xd: 'xd', + NULL: null + } + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(null) + return mockResponse + }) + + const connection = connect(config) + const got = await connection.execute('xxx') + + expect(got.rows[0]).toEqual(want) + }) + + test('runs e2e dual tests', async () => { + const mockResponse = { + session: mockSession, + result: dual, + timing: 1 + } + + const want = { + a: 'ÿ' + } + + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { + expect(opts.headers['Authorization']).toMatch(/Basic /) + const bodyObj = JSON.parse(opts.body.toString()) + expect(bodyObj.session).toEqual(null) + return mockResponse + }) + + const connection = connect(config) + const got = await connection.execute('xxx') + + expect(got.rows[0]).toEqual(want) + }) +}) + +function uint8ArrayFromHex(text: string) { + if (text.startsWith('0x')) { + text = text.slice(2) + } + return Uint8Array.from((text.match(/.{1,2}/g) ?? []).map((byte) => parseInt(byte, 16))) +} diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 899b819..76d86e1 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,11 +1,17 @@ import SqlString from 'sqlstring' -import { cast, connect, format, hex, DatabaseError, type Cast } from '../dist/index' +import { connect, format, hex, DatabaseError, type Cast } from '../dist/index' import { fetch, MockAgent, setGlobalDispatcher } from 'undici' import packageJSON from '../package.json' const mockHosts = ['http://localhost:8080', 'https://example.com'] const CREATE_SESSION_PATH = '/psdb.v1alpha1.Database/CreateSession' const EXECUTE_PATH = '/psdb.v1alpha1.Database/Execute' + +const mockAgent = new MockAgent() +mockAgent.disableNetConnect() + +setGlobalDispatcher(mockAgent) + const config = { username: 'someuser', password: 'password', @@ -13,11 +19,6 @@ const config = { fetch } -const mockAgent = new MockAgent() -mockAgent.disableNetConnect() - -setGlobalDispatcher(mockAgent) - // Provide the base url to the request const mockPool = mockAgent.get((value) => mockHosts.includes(value)) const mockSession = 42 @@ -615,28 +616,3 @@ describe('hex', () => { expect(hex('\0')).toEqual('0x00') }) }) - -describe('cast', () => { - test('casts int to number', () => { - expect(cast({ name: 'test', type: 'INT8' }, '12')).toEqual(12) - }) - - test('casts float to number', () => { - expect(cast({ name: 'test', type: 'FLOAT32' }, '2.32')).toEqual(2.32) - expect(cast({ name: 'test', type: 'FLOAT64' }, '2.32')).toEqual(2.32) - }) - - test('casts binary data to array of 8-bit unsigned integers', () => { - expect(cast({ name: 'test', type: 'BLOB' }, '')).toEqual(new Uint8Array([])) - expect(cast({ name: 'test', type: 'BLOB' }, 'Å')).toEqual(new Uint8Array([197])) - expect(cast({ name: 'test', type: 'VARBINARY' }, 'Å')).toEqual(new Uint8Array([197])) - }) - - test('casts binary text data to text', () => { - expect(cast({ name: 'test', type: 'VARBINARY', flags: 4225 }, 'table')).toEqual('table') - }) - - test('casts JSON string to JSON object', () => { - expect(cast({ name: 'test', type: 'JSON' }, '{ "foo": "bar" }')).toStrictEqual({ foo: 'bar' }) - }) -}) diff --git a/__tests__/text.test.ts b/__tests__/text.test.ts index 75cbad4..9639be2 100644 --- a/__tests__/text.test.ts +++ b/__tests__/text.test.ts @@ -1,25 +1,25 @@ -import { decode, hex, uint8Array, uint8ArrayToHex } from '../src/text' +import { decodeUtf8, hex, uint8Array, uint8ArrayToHex } from '../src/text' describe('text', () => { - describe('decode', () => { + describe('decodeUtf8', () => { test('decodes ascii bytes', () => { - expect(decode('a')).toEqual('a') + expect(decodeUtf8('a')).toEqual('a') }) test('decodes empty string', () => { - expect(decode('')).toEqual('') + expect(decodeUtf8('')).toEqual('') }) test('decodes null value', () => { - expect(decode(null)).toEqual('') + expect(decodeUtf8(null)).toEqual('') }) test('decodes undefined value', () => { - expect(decode(undefined)).toEqual('') + expect(decodeUtf8(undefined)).toEqual('') }) test('decodes multi-byte characters', () => { - expect(decode('\xF0\x9F\xA4\x94')).toEqual('🤔') + expect(decodeUtf8('\xF0\x9F\xA4\x94')).toEqual('🤔') }) }) diff --git a/golden/README.md b/golden/README.md new file mode 100644 index 0000000..b74c154 --- /dev/null +++ b/golden/README.md @@ -0,0 +1,15 @@ +# Golden tests + +This generates a "golden" test result that feeds the "parse e2e" test suite. + +The intent is a full round trip with a known table that exercises every column type with known correct data. + +This excercises different collations, charsets, every integer type. + +`test.sql` acts as the seed data against a PlanetScale branch, then we fetch the data back with `curl`. + +We can run different queries inside the `generate.sh` script either against the current data, or tests that run against `dual`. + +The results are stored back in `$case.json` and we directly import these JSON files into the test suite. + +Along with this is a `cli.txt` which is the result of running `select * from test` in a mysql CLI dumping the full human readable table. This table is a good reference for what is expected to be human readable or not. Raw binary data is represented as hexadecimal, vs UTF8 strings are readable. diff --git a/golden/cli.txt b/golden/cli.txt new file mode 100644 index 0000000..0cdfb0c --- /dev/null +++ b/golden/cli.txt @@ -0,0 +1,5 @@ ++----+------+------+------+------+------+------+------+------+------+------------+------------+---------------------+---------------------+----------+------+------+------+------------+------------+------------+------------+------------+------------+------+------+------+------+------+---------+--------------+------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+------+------+------+------+------+------------+------+ +| id | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o | p | q | r | s | t | u | v | w | x | y | z | aa | ab | ac | ad | ae | af | ag | ah | ai | aj | ak | al | xa | xb | xc | xd | ++----+------+------+------+------+------+------+------+------+------+------------+------------+---------------------+---------------------+----------+------+------+------+------------+------------+------------+------------+------------+------------+------+------+------+------+------+---------+--------------+------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+------+------+------+------+------+------------+------+ +| 1 | 1 | 1 | 1 | 1 | 1 | 1.1 | 1.1 | 1.1 | 1.1 | 0x07 | 1000-01-01 | 1000-01-01 01:01:01 | 1970-01-01 00:01:01 | 01:01:01 | 2006 | p | q | 0x72000000 | 0x73 | 0x74 | 0x75 | 0x76 | 0x77 | x | y | z | aa | foo | foo,bar | {"ad": null} | 0x0000000001020000000300000000000000000000000000000000000000000000000000F03F000000000000F03F00000000000000400000000000000000 | 0x000000000101000000000000000000F03F000000000000F03F | 0x0000000001020000000300000000000000000000000000000000000000000000000000F03F000000000000F03F00000000000000400000000000000000 | 0x00000000010300000002000000040000000000000000000000000000000000000000000000000000000000000000000840000000000000084000000000000000000000000000000000000000000000000004000000000000000000F03F000000000000F03F000000000000F03F00000000000000400000000000000040000000000000F03F000000000000F03F000000000000F03F | 1 | 1 | 1 | 1 | xa | xb | 0x78630000 | xd | ++----+------+------+------+------+------+------+------+------+------+------------+------------+---------------------+---------------------+----------+------+------+------+------------+------------+------------+------------+------------+------------+------+------+------+------+------+---------+--------------+------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+------+------+------+------+------+------------+------+ diff --git a/golden/database.json b/golden/database.json new file mode 100644 index 0000000..c26f3a8 --- /dev/null +++ b/golden/database.json @@ -0,0 +1,535 @@ +{ + "fields": [ + { + "name": "id", + "type": "INT64", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "id", + "columnLength": 20, + "charset": 63, + "flags": 49667 + }, + { + "name": "a", + "type": "INT8", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "a", + "columnLength": 4, + "charset": 63, + "flags": 32768 + }, + { + "name": "b", + "type": "INT16", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "b", + "columnLength": 6, + "charset": 63, + "flags": 32768 + }, + { + "name": "c", + "type": "INT24", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "c", + "columnLength": 9, + "charset": 63, + "flags": 32768 + }, + { + "name": "d", + "type": "INT32", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "d", + "columnLength": 11, + "charset": 63, + "flags": 32768 + }, + { + "name": "e", + "type": "INT64", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "e", + "columnLength": 20, + "charset": 63, + "flags": 32768 + }, + { + "name": "f", + "type": "DECIMAL", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "f", + "columnLength": 4, + "charset": 63, + "decimals": 1, + "flags": 32768 + }, + { + "name": "g", + "type": "DECIMAL", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "g", + "columnLength": 4, + "charset": 63, + "decimals": 1, + "flags": 32768 + }, + { + "name": "h", + "type": "FLOAT32", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "h", + "columnLength": 12, + "charset": 63, + "decimals": 31, + "flags": 32768 + }, + { + "name": "i", + "type": "FLOAT64", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "i", + "columnLength": 22, + "charset": 63, + "decimals": 31, + "flags": 32768 + }, + { + "name": "j", + "type": "BIT", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "j", + "columnLength": 3, + "charset": 63, + "flags": 32 + }, + { + "name": "k", + "type": "DATE", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "k", + "columnLength": 10, + "charset": 63, + "flags": 128 + }, + { + "name": "l", + "type": "DATETIME", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "l", + "columnLength": 19, + "charset": 63, + "flags": 128 + }, + { + "name": "m", + "type": "TIMESTAMP", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "m", + "columnLength": 19, + "charset": 63, + "flags": 128 + }, + { + "name": "n", + "type": "TIME", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "n", + "columnLength": 10, + "charset": 63, + "flags": 128 + }, + { + "name": "o", + "type": "YEAR", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "o", + "columnLength": 4, + "charset": 63, + "flags": 32864 + }, + { + "name": "p", + "type": "CHAR", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "p", + "columnLength": 16, + "charset": 255 + }, + { + "name": "q", + "type": "VARCHAR", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "q", + "columnLength": 16, + "charset": 255 + }, + { + "name": "r", + "type": "BINARY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "r", + "columnLength": 4, + "charset": 63, + "flags": 128 + }, + { + "name": "s", + "type": "VARBINARY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "s", + "columnLength": 4, + "charset": 63, + "flags": 128 + }, + { + "name": "t", + "type": "BLOB", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "t", + "columnLength": 255, + "charset": 63, + "flags": 144 + }, + { + "name": "u", + "type": "BLOB", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "u", + "columnLength": 65535, + "charset": 63, + "flags": 144 + }, + { + "name": "v", + "type": "BLOB", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "v", + "columnLength": 16777215, + "charset": 63, + "flags": 144 + }, + { + "name": "w", + "type": "BLOB", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "w", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "x", + "type": "TEXT", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "x", + "columnLength": 1020, + "charset": 255, + "flags": 16 + }, + { + "name": "y", + "type": "TEXT", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "y", + "columnLength": 262140, + "charset": 255, + "flags": 16 + }, + { + "name": "z", + "type": "TEXT", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "z", + "columnLength": 67108860, + "charset": 255, + "flags": 16 + }, + { + "name": "aa", + "type": "TEXT", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "aa", + "columnLength": 4294967295, + "charset": 255, + "flags": 16 + }, + { + "name": "ab", + "type": "ENUM", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ab", + "columnLength": 12, + "charset": 255, + "flags": 256 + }, + { + "name": "ac", + "type": "SET", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ac", + "columnLength": 28, + "charset": 255, + "flags": 2048 + }, + { + "name": "ad", + "type": "JSON", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ad", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "ae", + "type": "GEOMETRY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ae", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "af", + "type": "GEOMETRY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "af", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "ag", + "type": "GEOMETRY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ag", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "ah", + "type": "GEOMETRY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ah", + "columnLength": 4294967295, + "charset": 63, + "flags": 144 + }, + { + "name": "ai", + "type": "UINT8", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ai", + "columnLength": 3, + "charset": 63, + "flags": 32800 + }, + { + "name": "aj", + "type": "UINT24", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "aj", + "columnLength": 8, + "charset": 63, + "flags": 32800 + }, + { + "name": "ak", + "type": "UINT32", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "ak", + "columnLength": 10, + "charset": 63, + "flags": 32800 + }, + { + "name": "al", + "type": "UINT64", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "al", + "columnLength": 20, + "charset": 63, + "flags": 32800 + }, + { + "name": "xa", + "type": "BINARY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "xa", + "columnLength": 16, + "charset": 255, + "flags": 128 + }, + { + "name": "xb", + "type": "BINARY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "xb", + "columnLength": 16, + "charset": 255, + "flags": 128 + }, + { + "name": "xc", + "type": "BINARY", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "xc", + "columnLength": 4, + "charset": 63, + "flags": 128 + }, + { + "name": "xd", + "type": "BLOB", + "table": "test", + "orgTable": "test", + "database": "mattdb", + "orgName": "xd", + "columnLength": 262140, + "charset": 255, + "flags": 144 + }, + { + "name": "NULL", + "charset": 63, + "flags": 32896 + } + ], + "rows": [ + { + "lengths": [ + "1", + "1", + "1", + "1", + "1", + "1", + "3", + "3", + "3", + "3", + "1", + "10", + "19", + "19", + "8", + "4", + "1", + "1", + "4", + "1", + "1", + "1", + "1", + "1", + "1", + "1", + "1", + "2", + "3", + "7", + "12", + "61", + "25", + "61", + "149", + "1", + "1", + "1", + "1", + "2", + "2", + "4", + "2", + "-1" + ], + "values": "MTExMTExMS4xMS4xMS4xMS4xBzEwMDAtMDEtMDExMDAwLTAxLTAxIDAxOjAxOjAxMTk3MC0wMS0wMSAwMDowMTowMTAxOjAxOjAxMjAwNnBxcgAAAHN0dXZ3eHl6YWFmb29mb28sYmFyeyJhZCI6IG51bGx9AAAAAAECAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAPA/AAAAAAAAAEAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAQIAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAAAAQAAAAAAAAAAAAAAAAAEDAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQAAAAAAAAAhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAADwPwAAAAAAAABAAAAAAAAAAEAAAAAAAADwPwAAAAAAAPA/AAAAAAAA8D8xMTExeGF4YnhjAAB4ZA==" + } + ] +} diff --git a/golden/dual.json b/golden/dual.json new file mode 100644 index 0000000..3b8b27b --- /dev/null +++ b/golden/dual.json @@ -0,0 +1,18 @@ +{ + "fields": [ + { + "name": "a", + "type": "VARCHAR", + "charset": 8, + "flags": 1 + } + ], + "rows": [ + { + "lengths": [ + "2" + ], + "values": "w78=" + } + ] +} diff --git a/golden/generate.sh b/golden/generate.sh new file mode 100755 index 0000000..3f182b3 --- /dev/null +++ b/golden/generate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -e + +run_sql() { + name=$1 + query=$2 + echo "{\"query\":\"$query\"}" | + curl -s -u $MYSQL_USER:$MYSQL_PWD \ + -d@- -H'content-type: application/json' \ + https://$MYSQL_HOST/psdb.v1alpha1.Database/Execute | jq .result > $name.json + jq . $name.json +} + +cat test.sql | mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PWD + +run_sql 'database' 'select *, NULL from `test`' +run_sql 'dual' 'select _latin1 0xff as a' diff --git a/golden/test.sql b/golden/test.sql new file mode 100644 index 0000000..98046b8 --- /dev/null +++ b/golden/test.sql @@ -0,0 +1,95 @@ +DROP TABLE IF EXISTS `test`; + +CREATE TABLE `test` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `a` TINYINT, + `b` SMALLINT, + `c` MEDIUMINT, + `d` INTEGER, + `e` BIGINT, + `f` DECIMAL(2, 1), + `g` NUMERIC(2, 1), + `h` FLOAT, + `i` DOUBLE, + `j` BIT(3), + `k` DATE, + `l` DATETIME, + `m` TIMESTAMP, + `n` TIME, + `o` YEAR, + `p` CHAR(4), + `q` VARCHAR(4), + `r` BINARY(4), + `s` VARBINARY(4), + `t` TINYBLOB, + `u` BLOB, + `v` MEDIUMBLOB, + `w` LONGBLOB, + `x` TINYTEXT, + `y` TEXT, + `z` MEDIUMTEXT, + `aa` LONGTEXT, + `ab` ENUM('foo', 'bar'), + `ac` SET('foo', 'bar'), + `ad` JSON, + `ae` GEOMETRY, + `af` POINT, + `ag` LINESTRING, + `ah` POLYGON, + `ai` TINYINT UNSIGNED, + `aj` MEDIUMINT UNSIGNED, + `ak` INTEGER UNSIGNED, + `al` BIGINT UNSIGNED, + + `xa` CHAR(4) COLLATE utf8mb4_bin, + `xb` CHAR(4) BINARY, + `xc` CHAR(4) CHARSET binary, + `xd` TEXT COLLATE utf8mb4_bin, + PRIMARY KEY(`id`) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `test` VALUES ( + NULL, + 1, + 1, + 1, + 1, + 1, + 1.1, + 1.1, + 1.1, + 1.1, + 0b111, + '1000-01-01', + '1000-01-01 01:01:01', + '1970-01-01 00:01:01', + '01:01:01', + 2006, + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + 'aa', + 'foo', + 'foo,bar', + '{"ad":null}', + ST_GeomFromText('LINESTRING(0 0, 1 1, 2 0)'), + POINT(1, 1), + ST_GeomFromText('LINESTRING(0 0, 1 1, 2 0)'), + ST_GeomFromText('Polygon((0 0,0 3,3 0,0 0),(1 1,1 2,2 1,1 1))'), + 1, + 1, + 1, + 1, + 'xa', + 'xb', + 'xc', + 'xd' +); diff --git a/src/cast.ts b/src/cast.ts new file mode 100644 index 0000000..fea0bac --- /dev/null +++ b/src/cast.ts @@ -0,0 +1,94 @@ +import type { Field } from './index.js' +import { decodeUtf8, uint8Array } from './text.js' + +/** + * https://github.com/vitessio/vitess/blame/v19.0.3/go/mysql/json/helpers.go#L86-L112 + */ + +export function cast(field: Field, value: any): any { + if (value == null) { + return value + } + + if (isBigInt(field)) { + return value + } + + if (isDateOrTime(field)) { + return value + } + + if (isDecimal(field)) { + return value + } + + if (isJson(field)) { + return JSON.parse(value) + } + + if (isIntegral(field)) { + return parseInt(value, 10) + } + + if (isFloat(field)) { + return parseFloat(value) + } + + if (isBinary(field)) { + return uint8Array(value) + } + + return decodeUtf8(value) +} + +/** + * These are integral, but we want to leave the `BigInt` casting to the caller. + * + * https://github.com/planetscale/database-js/pull/90 + */ + +const BIG_INT_FIELD_TYPES = ['INT64', 'UINT64'] + +function isBigInt(field: Field) { + return BIG_INT_FIELD_TYPES.includes(field.type) +} + +/** + * https://github.com/vitessio/vitess/blob/v19.0.3/go/sqltypes/type.go#L103-L106 + */ + +const DATE_OR_DATETIME_FIELD_TYPES = ['DATETIME', 'DATE', 'TIMESTAMP', 'TIME'] + +function isDateOrTime(field: Field) { + return DATE_OR_DATETIME_FIELD_TYPES.includes(field.type) +} + +function isDecimal(field: Field) { + return field.type === 'DECIMAL' +} + +function isJson(field: Field) { + return field.type === 'JSON' +} + +const INTEGRAL_FIELD_TYPES = ['INT8', 'INT16', 'INT24', 'INT32', 'UINT8', 'UINT16', 'UINT24', 'UINT32', 'YEAR'] + +function isIntegral(field: Field) { + return INTEGRAL_FIELD_TYPES.includes(field.type) +} + +const FLOAT_FIELD_TYPES = ['FLOAT32', 'FLOAT64'] + +function isFloat(field: Field) { + return FLOAT_FIELD_TYPES.includes(field.type) +} + +/** + * https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_character_set.html + */ + +const BinaryId = 63 + +function isBinary(field: Field) { + return field.charset === BinaryId +} diff --git a/src/index.ts b/src/index.ts index b97446b..f45a35a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ +import { cast } from './cast.js' +export { cast } from './cast.js' import { format } from './sanitization.js' export { format } from './sanitization.js' -import { decode, uint8Array } from './text.js' export { hex } from './text.js' import { Version } from './version.js' @@ -83,6 +84,7 @@ export interface Field { columnLength?: number | null charset?: number | null + decimals?: number flags?: number | null columnType?: string | null } @@ -377,62 +379,3 @@ function decodeRow(row: QueryResultRow): Array { return splice }) } - -export function cast(field: Field, value: string | null): any { - if (value == null) { - return value - } - - switch (field.type) { - case 'INT8': - case 'INT16': - case 'INT24': - case 'INT32': - case 'UINT8': - case 'UINT16': - case 'UINT24': - case 'UINT32': - case 'YEAR': - return parseInt(value, 10) - case 'FLOAT32': - case 'FLOAT64': - return parseFloat(value) - case 'DECIMAL': - case 'INT64': - case 'UINT64': - case 'DATE': - case 'TIME': - case 'DATETIME': - case 'TIMESTAMP': - return value - case 'BLOB': - case 'BIT': - case 'GEOMETRY': - return uint8Array(value) - case 'BINARY': - case 'VARBINARY': - return isText(field) ? value : uint8Array(value) - case 'JSON': - return value ? JSON.parse(decode(value)) : value - default: - return decode(value) - } -} - -/** - * https://github.com/planetscale/vitess-types/blob/main/src/vitess/query/v17/query.proto#L98-L106 - */ - -enum Flags { - NONE = 0, - ISINTEGRAL = 256, - ISUNSIGNED = 512, - ISFLOAT = 1024, - ISQUOTED = 2048, - ISTEXT = 4096, - ISBINARY = 8192 -} - -function isText(field: Field): boolean { - return ((field.flags ?? 0) & Flags.ISTEXT) === Flags.ISTEXT -} diff --git a/src/text.ts b/src/text.ts index 80fb74e..849bf07 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,6 +1,6 @@ const decoder = new TextDecoder('utf-8') -export function decode(text: string | null | undefined): string { +export function decodeUtf8(text: string | null | undefined): string { return text ? decoder.decode(uint8Array(text)) : '' } From a0a3c2f97f0e1ae5a00ad3642ff72de939eaa387 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Fri, 26 Apr 2024 22:31:56 +0200 Subject: [PATCH 28/34] 1.17.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3ca435..997b2fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.16.0", + "version": "1.17.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index 6589376..ac6ae21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.16.0", + "version": "1.17.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 3d59361..5e05895 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.16.0' +export const Version = '1.17.0' From 0bba9076bb083534e332c1883b3888ac2b563f92 Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Mon, 29 Apr 2024 08:59:04 -0700 Subject: [PATCH 29/34] cast: JSON is always UTF8 --- __tests__/golden.test.ts | 2 +- golden/database.json | 4 ++-- golden/test.sql | 2 +- src/cast.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/__tests__/golden.test.ts b/__tests__/golden.test.ts index 4c064f8..8277fe9 100644 --- a/__tests__/golden.test.ts +++ b/__tests__/golden.test.ts @@ -62,7 +62,7 @@ describe('golden', () => { aa: 'aa', ab: 'foo', ac: 'foo,bar', - ad: { ad: null }, + ad: { ad: null, foo: 'ü' }, ae: uint8ArrayFromHex( '0x0000000001020000000300000000000000000000000000000000000000000000000000F03F000000000000F03F00000000000000400000000000000000' ), diff --git a/golden/database.json b/golden/database.json index c26f3a8..9c5b616 100644 --- a/golden/database.json +++ b/golden/database.json @@ -514,7 +514,7 @@ "2", "3", "7", - "12", + "25", "61", "25", "61", @@ -529,7 +529,7 @@ "2", "-1" ], - "values": "MTExMTExMS4xMS4xMS4xMS4xBzEwMDAtMDEtMDExMDAwLTAxLTAxIDAxOjAxOjAxMTk3MC0wMS0wMSAwMDowMTowMTAxOjAxOjAxMjAwNnBxcgAAAHN0dXZ3eHl6YWFmb29mb28sYmFyeyJhZCI6IG51bGx9AAAAAAECAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAPA/AAAAAAAAAEAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAQIAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAAAAQAAAAAAAAAAAAAAAAAEDAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQAAAAAAAAAhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAPA/AAAAAAAA8D8AAAAAAADwPwAAAAAAAABAAAAAAAAAAEAAAAAAAADwPwAAAAAAAPA/AAAAAAAA8D8xMTExeGF4YnhjAAB4ZA==" + "values": "MTExMTExMS4xMS4xMS4xMS4xBzEwMDAtMDEtMDExMDAwLTAxLTAxIDAxOjAxOjAxMTk3MC0wMS0wMSAwMDowMTowMTAxOjAxOjAxMjAwNnBxcgAAAHN0dXZ3eHl6YWFmb29mb28sYmFyeyJhZCI6IG51bGwsICJmb28iOiAiw7wifQAAAAABAgAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAADwPwAAAAAAAABAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAADwPwAAAAAAAPA/AAAAAAECAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAPA/AAAAAAAAAEAAAAAAAAAAAAAAAAABAwAAAAIAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACEAAAAAAAAAIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAADwPwAAAAAAAPA/AAAAAAAA8D8AAAAAAAAAQAAAAAAAAABAAAAAAAAA8D8AAAAAAADwPwAAAAAAAPA/MTExMXhheGJ4YwAAeGQ=" } ] } diff --git a/golden/test.sql b/golden/test.sql index 98046b8..b6eddcc 100644 --- a/golden/test.sql +++ b/golden/test.sql @@ -79,7 +79,7 @@ INSERT INTO `test` VALUES ( 'aa', 'foo', 'foo,bar', - '{"ad":null}', + '{"ad":null,"foo":"ü"}', ST_GeomFromText('LINESTRING(0 0, 1 1, 2 0)'), POINT(1, 1), ST_GeomFromText('LINESTRING(0 0, 1 1, 2 0)'), diff --git a/src/cast.ts b/src/cast.ts index fea0bac..f13c6b8 100644 --- a/src/cast.ts +++ b/src/cast.ts @@ -23,7 +23,7 @@ export function cast(field: Field, value: any): any { } if (isJson(field)) { - return JSON.parse(value) + return JSON.parse(decodeUtf8(value)) } if (isIntegral(field)) { From 68ed111f7f810a0e8eaae961e6d0ed77de4d593e Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Mon, 29 Apr 2024 09:09:18 -0700 Subject: [PATCH 30/34] 1.18.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 997b2fa..4d2ef0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.17.0", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.17.0", + "version": "1.18.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index ac6ae21..e5c0924 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.17.0", + "version": "1.18.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index 5e05895..e1cf02d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.17.0' +export const Version = '1.18.0' From c3c87afb7836abea95a07b325d8d756bc3706dc0 Mon Sep 17 00:00:00 2001 From: Emma Hamilton Date: Wed, 24 Jul 2024 16:38:10 +1000 Subject: [PATCH 31/34] Fix formatting empty Uint8Array --- __tests__/sanitization.test.ts | 6 ++++++ __tests__/text.test.ts | 3 ++- src/text.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/__tests__/sanitization.test.ts b/__tests__/sanitization.test.ts index 96c09bf..de520ca 100644 --- a/__tests__/sanitization.test.ts +++ b/__tests__/sanitization.test.ts @@ -80,6 +80,12 @@ describe('sanitization', () => { expect(format(query, [state])).toEqual(expected) }) + test('formats empty Uint8Array', () => { + const query = 'select 1 from user where state = ?' + const expected = "select 1 from user where state = x''" + expect(format(query, [new Uint8Array([])])).toEqual(expected) + }) + test('escapes double quotes', () => { const query = 'select 1 from user where state = ?' const expected = 'select 1 from user where state = \'\\"a\\"\'' diff --git a/__tests__/text.test.ts b/__tests__/text.test.ts index 9639be2..63733ac 100644 --- a/__tests__/text.test.ts +++ b/__tests__/text.test.ts @@ -42,7 +42,8 @@ describe('text', () => { describe('uint8ArrayToHex', () => { test('converts an array of 8-bit unsigned integers to hex', () => { - expect(uint8ArrayToHex(new Uint8Array([197]))).toEqual('0xc5') + expect(uint8ArrayToHex(new Uint8Array([]))).toEqual("x''") + expect(uint8ArrayToHex(new Uint8Array([197]))).toEqual("x'c5'") }) }) }) diff --git a/src/text.ts b/src/text.ts index 849bf07..222ff27 100644 --- a/src/text.ts +++ b/src/text.ts @@ -15,7 +15,7 @@ export function uint8Array(text: string): Uint8Array { export function uint8ArrayToHex(uint8: Uint8Array): string { const digits = Array.from(uint8).map((i) => i.toString(16).padStart(2, '0')) - return `0x${digits.join('')}` + return `x'${digits.join('')}'` } function bytes(text: string): number[] { From 4911f4723b9530d47e303f44b595641226ba9d15 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 12 Aug 2024 11:45:08 +0200 Subject: [PATCH 32/34] Update license finder --- .github/workflows/licensing.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/licensing.yml b/.github/workflows/licensing.yml index 950889c..d77f549 100644 --- a/.github/workflows/licensing.yml +++ b/.github/workflows/licensing.yml @@ -17,5 +17,6 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 - - run: sudo gem install license_finder && npm install + - run: sudo gem install license_finder -v 7.1 + - run: npm install - run: license_finder From e794dd5f9d4814fafcb7710391b05b5ae9e5e47a Mon Sep 17 00:00:00 2001 From: Ayrton Date: Wed, 14 Aug 2024 13:34:50 +0200 Subject: [PATCH 33/34] 1.19.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d2ef0e..2cb1a32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@planetscale/database", - "version": "1.18.0", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@planetscale/database", - "version": "1.18.0", + "version": "1.19.0", "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.3", diff --git a/package.json b/package.json index e5c0924..5b61267 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@planetscale/database", - "version": "1.18.0", + "version": "1.19.0", "description": "A Fetch API-compatible PlanetScale database driver", "files": [ "dist" diff --git a/src/version.ts b/src/version.ts index e1cf02d..f15f4a7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const Version = '1.18.0' +export const Version = '1.19.0' From da881b840f1e574180dd2dc83c2a1898d243a253 Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Tue, 29 Oct 2024 08:35:18 -0700 Subject: [PATCH 34/34] README: remove extra docs --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index 0cfb01c..c1e18e5 100644 --- a/README.md +++ b/README.md @@ -82,23 +82,6 @@ const results = await conn.transaction(async (tx) => { console.log(results) ``` -### Usage with PlanetScale Boost - -To enable PlanetScale Boost, run `SET @@boost_cached_queries = true` once. All subsequent queries run on the same connection will use boost if it's enabled for the query pattern. Non-matching queries will run as normal. - -To learn more, visit: [Query caching with PlanetScale Boost](https://planetscale.com/docs/concepts/query-caching-with-planetscale-boost) - -```ts -const conn = client.connection() -// Enable boost for the connection -await conn.execute('SET @@boost_cached_queries = true') - -const results = await conn.execute('...') - -// Optionally, you may disable boost for the connection by setting to false -await conn.execute('SET @@boost_cached_queries = false') -``` - ### Custom fetch function Node.js version 18 includes a built-in global `fetch` function. When using an older version of Node.js, you can provide a custom fetch function implementation. We recommend the [`undici`][1] package on which Node's built-in fetch is based.