import chalk from 'chalk'; import Docker from 'dockerode'; import { getTableConfig, index, int, singlestoreTable, text } from 'drizzle-orm/singlestore-core'; import fs from 'fs'; import getPort from 'get-port'; import { Connection, createConnection } from 'mysql2/promise'; import { diffTestSchemasPushSingleStore } from 'tests/schemaDiffer'; import { v4 as uuid } from 'uuid'; import { afterAll, beforeAll, expect, test } from 'vitest'; let client: Connection; let singlestoreContainer: Docker.Container; async function createDockerDB(): Promise { const docker = new Docker(); const port = await getPort({ port: 3306 }); const image = 'ghcr.io/singlestore-labs/singlestoredb-dev:latest'; const pullStream = await docker.pull(image); await new Promise((resolve, reject) => docker.modem.followProgress(pullStream, (err) => err ? reject(err) : resolve(err)) ); singlestoreContainer = await docker.createContainer({ Image: image, Env: ['ROOT_PASSWORD=singlestore'], name: `drizzle-integration-tests-${uuid()}`, HostConfig: { AutoRemove: true, PortBindings: { '3306/tcp': [{ HostPort: `${port}` }], }, }, }); await singlestoreContainer.start(); await new Promise((resolve) => setTimeout(resolve, 4000)); return `singlestore://root:singlestore@localhost:${port}/`; } beforeAll(async () => { const connectionString = process.env.MYSQL_CONNECTION_STRING ?? (await createDockerDB()); const sleep = 1000; let timeLeft = 20000; let connected = false; let lastError: unknown | undefined; do { try { client = await createConnection(connectionString); await client.connect(); connected = true; break; } catch (e) { lastError = e; await new Promise((resolve) => setTimeout(resolve, sleep)); timeLeft -= sleep; } } while (timeLeft > 0); if (!connected) { console.error('Cannot connect to MySQL'); await client?.end().catch(console.error); await singlestoreContainer?.stop().catch(console.error); throw lastError; } await client.query('DROP DATABASE IF EXISTS drizzle;'); await client.query('CREATE DATABASE drizzle;'); await client.query('USE drizzle;'); }); afterAll(async () => { await client?.end().catch(console.error); await singlestoreContainer?.stop().catch(console.error); }); if (!fs.existsSync('tests/push/singlestore')) { fs.mkdirSync('tests/push/singlestore'); } test('db has checks. Push with same names', async () => { const schema1 = { test: singlestoreTable('test', { id: int('id').primaryKey(), values: int('values').default(1), }), }; const schema2 = { test: singlestoreTable('test', { id: int('id').primaryKey(), values: int('values').default(1), }), }; const { statements, sqlStatements } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', ); expect(statements).toStrictEqual([]); expect(sqlStatements).toStrictEqual([]); await client.query(`DROP TABLE \`test\`;`); }); // TODO: Unskip this test when views are implemented /* test.skip.skip('create view', async () => { const table = singlestoreTable('test', { id: int('id').primaryKey(), }); const schema1 = { test: table, }; const schema2 = { test: table, view: singlestoreView('view').as((qb) => qb.select().from(table)), }; const { statements, sqlStatements } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, ); expect(statements).toStrictEqual([ { definition: 'select `id` from `test`', name: 'view', type: 'singlestore_create_view', replace: false, sqlSecurity: 'definer', withCheckOption: undefined, algorithm: 'undefined', }, ]); expect(sqlStatements).toStrictEqual([ `CREATE ALGORITHM = undefined SQL SECURITY definer VIEW \`view\` AS (select \`id\` from \`test\`);`, ]); await client.query(`DROP TABLE \`test\`;`); }); */ // TODO: Unskip this test when views are implemented /* test.skip('drop view', async () => { const table = singlestoreTable('test', { id: int('id').primaryKey(), }); const schema1 = { test: table, view: singlestoreView('view').as((qb) => qb.select().from(table)), }; const schema2 = { test: table, }; const { statements, sqlStatements } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, ); expect(statements).toStrictEqual([ { name: 'view', type: 'drop_view', }, ]); expect(sqlStatements).toStrictEqual(['DROP VIEW `view`;']); await client.query(`DROP TABLE \`test\`;`); await client.query(`DROP VIEW \`view\`;`); }); */ // TODO: Unskip this test when views are implemented /* test.skip('alter view ".as"', async () => { const table = singlestoreTable('test', { id: int('id').primaryKey(), }); const schema1 = { test: table, view: singlestoreView('view').as((qb) => qb .select() .from(table) .where(sql`${table.id} = 1`) ), }; const schema2 = { test: table, view: singlestoreView('view').as((qb) => qb.select().from(table)), }; const { statements, sqlStatements } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, ); expect(statements.length).toBe(0); expect(sqlStatements.length).toBe(0); await client.query(`DROP TABLE \`test\`;`); await client.query(`DROP VIEW \`view\`;`); }); */ // TODO: Unskip this test when views are implemented /* test.skip('alter meta options with distinct in definition', async () => { const table = singlestoreTable('test', { id: int('id').primaryKey(), }); const schema1 = { test: table, view: singlestoreView('view') .withCheckOption('cascaded') .sqlSecurity('definer') .algorithm('merge') .as((qb) => qb .selectDistinct() .from(table) .where(sql`${table.id} = 1`) ), }; const schema2 = { test: table, view: singlestoreView('view') .withCheckOption('cascaded') .sqlSecurity('definer') .algorithm('undefined') .as((qb) => qb.selectDistinct().from(table)), }; await expect( diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, ), ).rejects.toThrowError(); await client.query(`DROP TABLE \`test\`;`); }); */ test('added column not null and without default to table with data', async (t) => { const schema1 = { companies: singlestoreTable('companies', { id: int('id'), name: text('name'), }), }; const schema2 = { companies: singlestoreTable('companies', { id: int('id'), name: text('name'), age: int('age').notNull(), }), }; const table = getTableConfig(schema1.companies); const seedStatements = [ `INSERT INTO \`${table.name}\` (\`${schema1.companies.name.name}\`) VALUES ('drizzle');`, `INSERT INTO \`${table.name}\` (\`${schema1.companies.name.name}\`) VALUES ('turso');`, ]; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, { after: seedStatements, }, ); expect(statements.length).toBe(1); expect(statements[0]).toStrictEqual({ type: 'alter_table_add_column', tableName: 'companies', column: { name: 'age', type: 'int', primaryKey: false, notNull: true, autoincrement: false, }, schema: '', }); expect(sqlStatements.length).toBe(2); expect(sqlStatements[0]).toBe(`truncate table companies;`); expect(sqlStatements[1]).toBe( `ALTER TABLE \`companies\` ADD \`age\` int NOT NULL;`, ); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(1); expect(infoToPrint![0]).toBe( `· You're about to add not-null ${ chalk.underline( 'age', ) } column without default value, which contains 2 items`, ); expect(shouldAskForApprove).toBe(true); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(1); expect(tablesToTruncate![0]).toBe('companies'); await client.query(`DROP TABLE \`companies\`;`); }); test('added column not null and without default to table without data', async (t) => { const schema1 = { companies: singlestoreTable('companies', { id: int('id').primaryKey(), name: text('name').notNull(), }), }; const schema2 = { companies: singlestoreTable('companies', { id: int('id').primaryKey(), name: text('name').notNull(), age: int('age').notNull(), }), }; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, ); expect(statements.length).toBe(1); expect(statements[0]).toStrictEqual({ type: 'alter_table_add_column', tableName: 'companies', column: { name: 'age', type: 'int', primaryKey: false, notNull: true, autoincrement: false, }, schema: '', }); expect(sqlStatements.length).toBe(1); expect(sqlStatements[0]).toBe( `ALTER TABLE \`companies\` ADD \`age\` int NOT NULL;`, ); expect(infoToPrint!.length).toBe(0); expect(columnsToRemove!.length).toBe(0); expect(shouldAskForApprove).toBe(false); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(0); await client.query(`DROP TABLE \`companies\`;`); }); test('drop not null, add not null', async (t) => { const schema1 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name').notNull(), }), posts: singlestoreTable( 'posts', { id: int('id').primaryKey(), name: text('name'), userId: int('user_id'), }, ), }; const schema2 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name'), }), posts: singlestoreTable( 'posts', { id: int('id').primaryKey(), name: text('name').notNull(), userId: int('user_id'), }, ), }; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, ); expect(statements!.length).toBe(2); expect(statements![0]).toStrictEqual({ columns: [ { autoincrement: false, generated: undefined, name: 'id', notNull: true, onUpdate: undefined, primaryKey: false, type: 'int', }, { autoincrement: false, generated: undefined, name: 'name', notNull: true, onUpdate: undefined, primaryKey: false, type: 'text', }, { autoincrement: false, generated: undefined, name: 'user_id', notNull: false, onUpdate: undefined, primaryKey: false, type: 'int', }, ], compositePKs: [ 'posts_id;id', ], tableName: 'posts', type: 'singlestore_recreate_table', uniqueConstraints: [], }); expect(statements![1]).toStrictEqual({ columns: [ { autoincrement: false, generated: undefined, name: 'id', notNull: true, onUpdate: undefined, primaryKey: false, type: 'int', }, { autoincrement: false, generated: undefined, name: 'name', notNull: false, onUpdate: undefined, primaryKey: false, type: 'text', }, ], compositePKs: [ 'users_id;id', ], tableName: 'users', type: 'singlestore_recreate_table', uniqueConstraints: [], }); expect(sqlStatements!.length).toBe(8); expect(sqlStatements![0]).toBe(`CREATE TABLE \`__new_posts\` ( \t\`id\` int NOT NULL, \t\`name\` text NOT NULL, \t\`user_id\` int, \tCONSTRAINT \`posts_id\` PRIMARY KEY(\`id\`) );\n`); expect(sqlStatements![1]).toBe( `INSERT INTO \`__new_posts\`(\`id\`, \`name\`, \`user_id\`) SELECT \`id\`, \`name\`, \`user_id\` FROM \`posts\`;`, ); expect(sqlStatements![2]).toBe(`DROP TABLE \`posts\`;`); expect(sqlStatements![3]).toBe(`ALTER TABLE \`__new_posts\` RENAME TO \`posts\`;`); expect(sqlStatements![4]).toBe(`CREATE TABLE \`__new_users\` ( \t\`id\` int NOT NULL, \t\`name\` text, \tCONSTRAINT \`users_id\` PRIMARY KEY(\`id\`) );\n`); expect(sqlStatements![5]).toBe( `INSERT INTO \`__new_users\`(\`id\`, \`name\`) SELECT \`id\`, \`name\` FROM \`users\`;`, ); expect(sqlStatements![6]).toBe( `DROP TABLE \`users\`;`, ); expect(sqlStatements![7]).toBe(`ALTER TABLE \`__new_users\` RENAME TO \`users\`;`); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(0); expect(shouldAskForApprove).toBe(false); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(0); await client.query(`DROP TABLE \`users\`;`); await client.query(`DROP TABLE \`posts\`;`); }); test('drop table with data', async (t) => { const schema1 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name').notNull(), }), posts: singlestoreTable( 'posts', { id: int('id').primaryKey(), name: text('name'), userId: int('user_id'), }, ), }; const schema2 = { posts: singlestoreTable( 'posts', { id: int('id').primaryKey(), name: text('name'), userId: int('user_id'), }, ), }; const seedStatements = [ `INSERT INTO \`users\` (\`id\`, \`name\`) VALUES (1, 'drizzle')`, ]; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, { after: seedStatements }, ); expect(statements!.length).toBe(1); expect(statements![0]).toStrictEqual({ policies: [], schema: undefined, tableName: 'users', type: 'drop_table', }); expect(sqlStatements!.length).toBe(1); expect(sqlStatements![0]).toBe(`DROP TABLE \`users\`;`); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(1); expect(infoToPrint![0]).toBe(`· You're about to delete ${chalk.underline('users')} table with 1 items`); expect(shouldAskForApprove).toBe(true); expect(tablesToRemove!.length).toBe(1); expect(tablesToRemove![0]).toBe('users'); expect(tablesToTruncate!.length).toBe(0); await client.query(`DROP TABLE \`users\`;`); await client.query(`DROP TABLE \`posts\`;`); }); test('change data type. db has indexes. table does not have values', async (t) => { const schema1 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: int('name').notNull(), }, (table) => [index('index').on(table.name)]), }; const schema2 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name').notNull(), }, (table) => [index('index').on(table.name)]), }; const seedStatements = [`INSERT INTO users VALUES (1, 12)`]; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, ); expect(statements!.length).toBe(2); expect(statements![0]).toStrictEqual({ columns: [ { autoincrement: false, generated: undefined, name: 'id', notNull: true, onUpdate: undefined, primaryKey: false, type: 'int', }, { autoincrement: false, generated: undefined, name: 'name', notNull: true, onUpdate: undefined, primaryKey: false, type: 'text', }, ], compositePKs: [ 'users_id;id', ], tableName: 'users', type: 'singlestore_recreate_table', uniqueConstraints: [], }); expect(statements![1]).toStrictEqual({ data: 'index;name;false;;;', internal: undefined, schema: '', tableName: 'users', type: 'create_index', }); expect(sqlStatements!.length).toBe(5); expect(sqlStatements![0]).toBe(`CREATE TABLE \`__new_users\` ( \t\`id\` int NOT NULL, \t\`name\` text NOT NULL, \tCONSTRAINT \`users_id\` PRIMARY KEY(\`id\`) );\n`); expect(sqlStatements![1]).toBe( `INSERT INTO \`__new_users\`(\`id\`, \`name\`) SELECT \`id\`, \`name\` FROM \`users\`;`, ); expect(sqlStatements![2]).toBe(`DROP TABLE \`users\`;`); expect(sqlStatements![3]).toBe(`ALTER TABLE \`__new_users\` RENAME TO \`users\`;`); expect(sqlStatements![4]).toBe(`CREATE INDEX \`index\` ON \`users\` (\`name\`);`); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(0); expect(shouldAskForApprove).toBe(false); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(0); await client.query(`DROP TABLE \`users\`;`); }); test('change data type. db has indexes. table has values', async (t) => { const schema1 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: int('name'), }, (table) => [index('index').on(table.name)]), }; const schema2 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name'), }, (table) => [index('index').on(table.name)]), }; const seedStatements = [`INSERT INTO users VALUES (1, 12);`, `INSERT INTO users (id) VALUES (2);`]; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, { after: seedStatements }, ); expect(statements!.length).toBe(2); expect(statements![0]).toStrictEqual({ columns: [ { autoincrement: false, generated: undefined, name: 'id', notNull: true, onUpdate: undefined, primaryKey: false, type: 'int', }, { autoincrement: false, generated: undefined, name: 'name', notNull: false, onUpdate: undefined, primaryKey: false, type: 'text', }, ], compositePKs: [ 'users_id;id', ], tableName: 'users', type: 'singlestore_recreate_table', uniqueConstraints: [], }); expect(statements![1]).toStrictEqual({ data: 'index;name;false;;;', internal: undefined, schema: '', tableName: 'users', type: 'create_index', }); expect(sqlStatements!.length).toBe(6); expect(sqlStatements![0]).toBe(`TRUNCATE TABLE \`users\`;`); expect(sqlStatements![1]).toBe(`CREATE TABLE \`__new_users\` ( \t\`id\` int NOT NULL, \t\`name\` text, \tCONSTRAINT \`users_id\` PRIMARY KEY(\`id\`) );\n`); expect(sqlStatements![2]).toBe( `INSERT INTO \`__new_users\`(\`id\`, \`name\`) SELECT \`id\`, \`name\` FROM \`users\`;`, ); expect(sqlStatements![3]).toBe(`DROP TABLE \`users\`;`); expect(sqlStatements![4]).toBe(`ALTER TABLE \`__new_users\` RENAME TO \`users\`;`); expect(sqlStatements![5]).toBe(`CREATE INDEX \`index\` ON \`users\` (\`name\`);`); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(1); expect(infoToPrint![0]).toBe( `· You're about recreate ${chalk.underline('users')} table with data type changing for ${ chalk.underline('name') } column, which contains 1 items`, ); expect(shouldAskForApprove).toBe(true); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(1); expect(tablesToTruncate![0]).toBe(`users`); await client.query(`DROP TABLE \`users\`;`); }); test('add column. add default to column without not null', async (t) => { const schema1 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name'), }), }; const schema2 = { users: singlestoreTable('users', { id: int('id').primaryKey(), name: text('name').default('drizzle'), age: int('age'), }), }; const { statements, sqlStatements, columnsToRemove, infoToPrint, shouldAskForApprove, tablesToRemove, tablesToTruncate, } = await diffTestSchemasPushSingleStore( client, schema1, schema2, [], 'drizzle', false, undefined, ); expect(statements!.length).toBe(2); expect(statements![0]).toStrictEqual({ columnAutoIncrement: false, columnName: 'name', columnNotNull: false, columnOnUpdate: undefined, columnPk: false, newDataType: 'text', newDefaultValue: "'drizzle'", schema: '', tableName: 'users', type: 'alter_table_alter_column_set_default', }); expect(statements![1]).toStrictEqual({ type: 'alter_table_add_column', tableName: 'users', schema: '', column: { notNull: false, primaryKey: false, autoincrement: false, name: 'age', type: 'int', }, }); expect(sqlStatements!.length).toBe(2); expect(sqlStatements![0]).toBe(`ALTER TABLE \`users\` MODIFY COLUMN \`name\` text DEFAULT 'drizzle';`); expect(sqlStatements![1]).toBe(`ALTER TABLE \`users\` ADD \`age\` int;`); expect(columnsToRemove!.length).toBe(0); expect(infoToPrint!.length).toBe(0); expect(shouldAskForApprove).toBe(false); expect(tablesToRemove!.length).toBe(0); expect(tablesToTruncate!.length).toBe(0); await client.query(`DROP TABLE \`users\`;`); });