From 3d9ff264f50d42138bbb47cd88837c19e5880763 Mon Sep 17 00:00:00 2001 From: bholmesdev Date: Tue, 30 Jan 2024 12:55:43 -0500 Subject: [PATCH] feat: add queries unit tests and fix related bugs --- packages/db/package.json | 4 +- packages/db/src/cli/queries.ts | 17 +- packages/db/test/unit/queries.test.js | 390 ++++++++++++++++++++++++++ 3 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 packages/db/test/unit/queries.test.js diff --git a/packages/db/package.json b/packages/db/package.json index 1d44e8a928..81f909b7a3 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -43,8 +43,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "mocha --exit --timeout 20000", - "test:match": "mocha --timeout 20000 -g" + "test": "mocha --exit --timeout 20000 \"test/*.js\" \"test/unit/**/*.js\"", + "test:match": "mocha --timeout 20000 \"test/*.js\" \"test/unit/*.js\" -g" }, "dependencies": { "@libsql/client": "0.4.0-pre.10", diff --git a/packages/db/src/cli/queries.ts b/packages/db/src/cli/queries.ts index 10c3ac5d05..77136c37b7 100644 --- a/packages/db/src/cli/queries.ts +++ b/packages/db/src/cli/queries.ts @@ -184,7 +184,7 @@ export async function getCollectionChangeQueries({ ([, field]) => hasPrimaryKey(field.old) || hasPrimaryKey(field.new) ); const recreateTableQueries = getRecreateTableQueries({ - unescapedCollectionName: collectionName, + collectionName, newCollection, added, hasDataLoss: dataLossCheck.dataLoss, @@ -406,24 +406,27 @@ function getAlterTableQueries( } function getRecreateTableQueries({ - unescapedCollectionName, + collectionName: unescCollectionName, newCollection, added, hasDataLoss, migrateHiddenPrimaryKey, }: { - unescapedCollectionName: string; + collectionName: string; newCollection: DBCollection; added: Record; hasDataLoss: boolean; migrateHiddenPrimaryKey: boolean; }): string[] { - const unescTempName = `${unescapedCollectionName}_${genTempTableName()}`; + const unescTempName = `${unescCollectionName}_${genTempTableName()}`; const tempName = sqlite.escapeName(unescTempName); - const collectionName = sqlite.escapeName(unescapedCollectionName); + const collectionName = sqlite.escapeName(unescCollectionName); if (hasDataLoss) { - return [`DROP TABLE ${collectionName}`, getCreateTableQuery(collectionName, newCollection)]; + return [ + `DROP TABLE ${collectionName}`, + getCreateTableQuery(unescCollectionName, newCollection), + ]; } const newColumns = [...Object.keys(newCollection.fields)]; if (migrateHiddenPrimaryKey) { @@ -482,7 +485,7 @@ function canRecreateTableWithoutDataLoss( if (!a.optional && !hasDefault(a)) { return { dataLoss: true, fieldName, reason: 'added-required' }; } - if (a.unique) { + if (!a.optional && a.unique) { return { dataLoss: true, fieldName, reason: 'added-unique' }; } } diff --git a/packages/db/test/unit/queries.test.js b/packages/db/test/unit/queries.test.js new file mode 100644 index 0000000000..d356111871 --- /dev/null +++ b/packages/db/test/unit/queries.test.js @@ -0,0 +1,390 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { getCollectionChangeQueries, getMigrationQueries } from '../../dist/cli/queries.js'; +import { collectionSchema } from '../../dist/types.js'; +import { getCreateTableQuery } from '../../dist/internal.js'; +import { field } from '../../dist/config.js'; + +const COLLECTION_NAME = 'Users'; + +const userInitial = collectionSchema.parse({ + fields: { + name: field.text(), + age: field.number(), + email: field.text({ unique: true }), + mi: field.text({ optional: true }), + }, + writable: false, +}); + +const defaultPromptResponse = { + allowDataLoss: false, + fieldRenames: new Proxy( + {}, + { + get: () => false, + } + ), + collectionRenames: new Proxy( + {}, + { + get: () => false, + } + ), +}; + +function userChangeQueries(oldCollection, newCollection, promptResponses = defaultPromptResponse) { + return getCollectionChangeQueries({ + collectionName: COLLECTION_NAME, + oldCollection, + newCollection, + promptResponses, + }); +} + +function configChangeQueries( + oldCollections, + newCollections, + promptResponses = defaultPromptResponse +) { + return getMigrationQueries({ + oldSnapshot: { schema: oldCollections, experimentalVersion: 1 }, + newSnapshot: { schema: newCollections, experimentalVersion: 1 }, + promptResponses, + }); +} + +describe('getMigrationQueries', () => { + it('should be empty when collections are the same', async () => { + const oldCollections = { [COLLECTION_NAME]: userInitial }; + const newCollections = { [COLLECTION_NAME]: userInitial }; + const queries = await configChangeQueries(oldCollections, newCollections); + expect(queries).to.deep.equal([]); + }); + + it('should create table for new collections', async () => { + const oldCollections = {}; + const newCollections = { [COLLECTION_NAME]: userInitial }; + const queries = await configChangeQueries(oldCollections, newCollections); + expect(queries).to.deep.equal([getCreateTableQuery(COLLECTION_NAME, userInitial)]); + }); + + it('should drop table for removed collections', async () => { + const oldCollections = { [COLLECTION_NAME]: userInitial }; + const newCollections = {}; + const queries = await configChangeQueries(oldCollections, newCollections); + expect(queries).to.deep.equal([`DROP TABLE "${COLLECTION_NAME}"`]); + }); + + it('should rename table for renamed collections', async () => { + const rename = 'Peeps'; + const oldCollections = { [COLLECTION_NAME]: userInitial }; + const newCollections = { [rename]: userInitial }; + const queries = await configChangeQueries(oldCollections, newCollections, { + ...defaultPromptResponse, + collectionRenames: { [rename]: COLLECTION_NAME }, + }); + expect(queries).to.deep.equal([`ALTER TABLE "${COLLECTION_NAME}" RENAME TO "${rename}"`]); + }); +}); + +describe('getCollectionChangeQueries', () => { + it('should be empty when collections are the same', async () => { + const queries = await userChangeQueries(userInitial, userInitial); + expect(queries).to.deep.equal([]); + }); + + it('should be empty when type updated to same underlying SQL type', async () => { + const blogInitial = collectionSchema.parse({ + ...userInitial, + fields: { + title: field.text(), + draft: field.boolean(), + }, + }); + const blogFinal = collectionSchema.parse({ + ...userInitial, + fields: { + ...blogInitial.fields, + draft: field.number(), + }, + }); + const queries = await userChangeQueries(blogInitial, blogFinal); + expect(queries).to.deep.equal([]); + }); + + describe('ALTER RENAME COLUMN', () => { + it('when renaming a field', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + }, + }; + userFinal.fields.middleInitial = userFinal.fields.mi; + delete userFinal.fields.mi; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + fieldRenames: { middleInitial: 'mi' }, + }); + expect(queries).to.deep.equal([ + `ALTER TABLE "${COLLECTION_NAME}" RENAME COLUMN "mi" TO "middleInitial"`, + ]); + }); + }); + + describe('Lossy table recreate', () => { + it('when changing a field type', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + age: field.text(), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + allowDataLoss: true, + }); + + expect(queries).to.deep.equal([ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('when adding a required field without a default', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + phoneNumber: field.text(), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + allowDataLoss: true, + }); + + expect(queries).to.deep.equal([ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`, + ]); + }); + }); + + describe('Lossless table recreate', () => { + it('when adding an optional unique field', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + phoneNumber: field.text({ unique: true, optional: true }), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + allowDataLoss: true, + }); + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when dropping unique column', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + }, + }; + delete userFinal.fields.email; + + const queries = await userChangeQueries(userInitial, userFinal); + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when updating to a runtime default', async () => { + const initial = collectionSchema.parse({ + ...userInitial, + fields: { + ...userInitial.fields, + age: field.date(), + }, + }); + + const userFinal = { + ...userInitial, + fields: { + ...initial.fields, + age: field.date({ default: 'now' }), + }, + }; + + const queries = await userChangeQueries(initial, userFinal); + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when adding a field with a runtime default', async () => { + const userFinal = { + fields: { + ...userInitial.fields, + birthday: field.date({ default: 'now' }), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal); + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + /** + * REASON: to follow the "expand" and "contract" migration model, + * you'll need to update the schema from NOT NULL to NULL. + * It's up to the user to ensure all data follows the new schema! + * + * @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes + */ + it('when changing a field to required', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + mi: field.text(), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + allowDataLoss: true, + }); + + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when changing a field to unique', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + age: field.number({ unique: true }), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal, { + ...defaultPromptResponse, + allowDataLoss: true, + }); + expect(queries).to.have.lengthOf(4); + + const tempTableName = getTempTableName(queries[0]); + expect(tempTableName).to.be.a('string'); + expect(queries).to.deep.equal([ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + }); + + describe('ALTER ADD COLUMN', () => { + it('when adding an optional field', async () => { + const userFinal = { + ...userInitial, + fields: { + ...userInitial.fields, + birthday: field.date({ optional: true }), + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal); + expect(queries).to.deep.equal(['ALTER TABLE "Users" ADD COLUMN "birthday" text']); + }); + + it('when adding a required field with default', async () => { + const defaultDate = new Date('2023-01-01'); + const userFinal = collectionSchema.parse({ + ...userInitial, + fields: { + ...userInitial.fields, + birthday: field.date({ default: new Date('2023-01-01') }), + }, + }); + + const queries = await userChangeQueries(userInitial, userFinal); + expect(queries).to.deep.equal([ + `ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`, + ]); + }); + }); + + describe('ALTER DROP COLUMN', () => { + it('when removing optional or required fields', async () => { + const userFinal = { + ...userInitial, + fields: { + name: userInitial.fields.name, + email: userInitial.fields.email, + }, + }; + + const queries = await userChangeQueries(userInitial, userFinal); + expect(queries).to.deep.equal([ + 'ALTER TABLE "Users" DROP COLUMN "age"', + 'ALTER TABLE "Users" DROP COLUMN "mi"', + ]); + }); + }); +}); + +/** @param {string} query */ +function getTempTableName(query) { + return query.match(/Users_([a-z0-9]+)/)?.[0]; +}