diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index c00677656..6ce1b918c 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -7,6 +7,7 @@ ### New features - Added `Database#experimentalIsVerbose` option +- Added destroyColumn migration step. ### Fixes diff --git a/src/RawRecord/index.d.ts b/src/RawRecord/index.d.ts index 54ce69bad..83b15bee0 100644 --- a/src/RawRecord/index.d.ts +++ b/src/RawRecord/index.d.ts @@ -1,7 +1,6 @@ import type { ColumnName, ColumnSchema, TableSchema } from '../Schema' import type { RecordId, SyncStatus } from '../Model' - // Raw object representing a model record, coming from an untrusted source // (disk, sync, user data). Before it can be used to create a Model instance // it must be sanitized (with `sanitizedRaw`) into a RawRecord @@ -15,7 +14,7 @@ type _RawRecord = { } // Raw object representing a model record. A RawRecord is guaranteed by the type system -// to be safe to use (sanitied with `sanitizedRaw`): +// to be safe to use (sanitized with `sanitizedRaw`): // - it has exactly the fields described by TableSchema (+ standard fields) // - every field is exactly the type described by ColumnSchema (string, number, or boolean) // - … and the same optionality (will not be null unless isOptional: true) diff --git a/src/Schema/migrations/index.d.ts b/src/Schema/migrations/index.d.ts index 35f27f669..9744e2a89 100644 --- a/src/Schema/migrations/index.d.ts +++ b/src/Schema/migrations/index.d.ts @@ -1,5 +1,12 @@ import type { $RE, $Exact } from '../../types' -import type { ColumnSchema, TableName, TableSchema, TableSchemaSpec, SchemaVersion } from '../index' +import type { + ColumnName, + ColumnSchema, + TableName, + TableSchema, + TableSchemaSpec, + SchemaVersion, +} from '../index' export type CreateTableMigrationStep = $RE<{ type: 'create_table' @@ -13,12 +20,22 @@ export type AddColumnsMigrationStep = $RE<{ unsafeSql?: (_: string) => string }> +export type DestroyColumnMigrationStep = $RE<{ + type: 'destroy_column' + table: TableName + column: ColumnName +}> + export type SqlMigrationStep = $RE<{ type: 'sql' sql: string }> -export type MigrationStep = CreateTableMigrationStep | AddColumnsMigrationStep | SqlMigrationStep +export type MigrationStep = + | CreateTableMigrationStep + | AddColumnsMigrationStep + | DestroyColumnMigrationStep + | SqlMigrationStep type Migration = $RE<{ toVersion: SchemaVersion @@ -50,4 +67,12 @@ export function addColumns({ unsafeSql?: (_: string) => string }>): AddColumnsMigrationStep +export function destroyColumn({ + table, + column, +}: $Exact<{ + table: TableName + column: ColumnName +}>): DestroyColumnMigrationStep + export function unsafeExecuteSql(sql: string): SqlMigrationStep diff --git a/src/Schema/migrations/index.js b/src/Schema/migrations/index.js index d6b8cd8ac..6ece9db6f 100644 --- a/src/Schema/migrations/index.js +++ b/src/Schema/migrations/index.js @@ -6,7 +6,14 @@ import invariant from '../../utils/common/invariant' import isObj from '../../utils/fp/isObj' import type { $RE } from '../../types' -import type { ColumnSchema, TableName, TableSchema, TableSchemaSpec, SchemaVersion } from '../index' +import type { + ColumnName, + ColumnSchema, + TableName, + TableSchema, + TableSchemaSpec, + SchemaVersion, +} from '../index' import { tableSchema, validateColumnSchema } from '../index' export type CreateTableMigrationStep = $RE<{ @@ -21,12 +28,22 @@ export type AddColumnsMigrationStep = $RE<{ unsafeSql?: (string) => string, }> +export type DestroyColumnMigrationStep = $RE<{ + type: 'destroy_column', + table: TableName, + column: ColumnName, +}> + export type SqlMigrationStep = $RE<{ type: 'sql', sql: string, }> -export type MigrationStep = CreateTableMigrationStep | AddColumnsMigrationStep | SqlMigrationStep +export type MigrationStep = + | CreateTableMigrationStep + | AddColumnsMigrationStep + | DestroyColumnMigrationStep + | SqlMigrationStep type Migration = $RE<{ toVersion: SchemaVersion, @@ -159,6 +176,21 @@ export function addColumns({ return { type: 'add_columns', table, columns, unsafeSql } } +export function destroyColumn({ + table, + column, +}: $Exact<{ + table: TableName, + column: ColumnName, +}>): DestroyColumnMigrationStep { + if (process.env.NODE_ENV !== 'production') { + invariant(table, `Missing table name in destroyColumn()`) + invariant(column, `Missing column in destroyColumn()`) + } + + return { type: 'destroy_column', table, column } +} + export function unsafeExecuteSql(sql: string): SqlMigrationStep { if (process.env.NODE_ENV !== 'production') { invariant(typeof sql === 'string', `SQL passed to unsafeExecuteSql is not a string`) @@ -176,7 +208,6 @@ renameTable({ from: 'old_table_name', to: 'new_table_name' }) // column operations renameColumn({ table: 'table_name', from: 'old_column_name', to: 'new_column_name' }) -destroyColumn({ table: 'table_name', column: 'column_name' }) // indexing addColumnIndex({ table: 'table_name', column: 'column_name' }) diff --git a/src/Schema/migrations/test.js b/src/Schema/migrations/test.js index b47d24b65..924eb88a6 100644 --- a/src/Schema/migrations/test.js +++ b/src/Schema/migrations/test.js @@ -1,4 +1,4 @@ -import { createTable, addColumns, schemaMigrations } from './index' +import { createTable, addColumns, destroyColumn, schemaMigrations } from './index' import { stepsForMigration } from './stepsForMigration' describe('schemaMigrations()', () => { @@ -30,7 +30,19 @@ describe('schemaMigrations()', () => { it('returns a complex schema migrations spec', () => { const migrations = schemaMigrations({ migrations: [ - { toVersion: 4, steps: [] }, + { + toVersion: 4, + steps: [ + addColumns({ + table: 'comments', + columns: [{ name: 'text', type: 'string' }], + }), + destroyColumn({ + table: 'comments', + column: 'body', + }), + ], + }, { toVersion: 3, steps: [ @@ -103,7 +115,21 @@ describe('schemaMigrations()', () => { }, ], }, - { toVersion: 4, steps: [] }, + { + toVersion: 4, + steps: [ + { + type: 'add_columns', + table: 'comments', + columns: [{ name: 'text', type: 'string' }], + }, + { + type: 'destroy_column', + table: 'comments', + column: 'body', + }, + ], + }, ], }) }) @@ -181,6 +207,10 @@ describe('migration step functions', () => { 'type', ) }) + it('throws if destroyColumn() is malformed', () => { + expect(() => destroyColumn({ column: 'foo' })).toThrow('table') + expect(() => destroyColumn({ table: 'foo' })).toThrow('column') + }) }) describe('stepsForMigration', () => { diff --git a/src/adapters/lokijs/worker/DatabaseDriver.js b/src/adapters/lokijs/worker/DatabaseDriver.js index f9731e575..d1017cf91 100644 --- a/src/adapters/lokijs/worker/DatabaseDriver.js +++ b/src/adapters/lokijs/worker/DatabaseDriver.js @@ -15,6 +15,7 @@ import type { SchemaMigrations, CreateTableMigrationStep, AddColumnsMigrationStep, + DestroyColumnMigrationStep, MigrationStep, } from '../../../Schema/migrations' import type { SerializedQuery } from '../../../Query' @@ -390,6 +391,8 @@ export default class DatabaseDriver { this._executeCreateTableMigration(step) } else if (step.type === 'add_columns') { this._executeAddColumnsMigration(step) + } else if (step.type === 'destroy_column') { + this._executeDestroyColumnMigration(step) } else if (step.type === 'sql') { // ignore } else { @@ -425,6 +428,15 @@ export default class DatabaseDriver { }) } + _executeDestroyColumnMigration({ table, column }: DestroyColumnMigrationStep): void { + const collection = this.loki.getCollection(table) + + // update ALL records in the collection, removing a field + collection.findAndUpdate({}, (record) => { + delete record[column] + }) + } + // Maps records to their IDs if the record is already cached on JS side _compactQueryResults(records: DirtyRaw[], table: TableName): CachedQueryResult { const cache = this.getCache(table) diff --git a/src/adapters/sqlite/encodeSchema/index.js b/src/adapters/sqlite/encodeSchema/index.js index dec177712..9dbfd65a8 100644 --- a/src/adapters/sqlite/encodeSchema/index.js +++ b/src/adapters/sqlite/encodeSchema/index.js @@ -2,7 +2,11 @@ import type { TableSchema, AppSchema, ColumnSchema, TableName } from '../../../Schema' import { nullValue } from '../../../RawRecord' -import type { MigrationStep, AddColumnsMigrationStep } from '../../../Schema/migrations' +import type { + MigrationStep, + AddColumnsMigrationStep, + DestroyColumnMigrationStep, +} from '../../../Schema/migrations' import type { SQL } from '../index' import encodeValue from '../encodeValue' @@ -85,13 +89,36 @@ const encodeAddColumnsMigrationStep: (AddColumnsMigrationStep) => SQL = ({ }) .join('') -export const encodeMigrationSteps: (MigrationStep[]) => SQL = (steps) => +const encodeDestroyColumnMigrationStep: (DestroyColumnMigrationStep, TableSchema) => SQL = ( + { table, column }, + tableSchema, +) => { + const newTempTable = { ...tableSchema, name: `${table}Temp` } + const newColumns = [ + ...standardColumns, + ...Object.keys(tableSchema.columns) + .filter((c) => c !== column) + .map((c) => `"${c}`), + ] + return ` + ${encodeTable(newTempTable)} + INSERT INTO ${table}Temp(${newColumns.join(',')}) SELECT ${newColumns.join( + ',', + )} FROM ${table}; + DROP TABLE ${table}; + ALTER TABLE ${table}Temp RENAME TO ${table}; + ` +} + +export const encodeMigrationSteps: (MigrationStep[], AppSchema) => SQL = (steps, schema) => steps .map((step) => { if (step.type === 'create_table') { return encodeTable(step.schema) } else if (step.type === 'add_columns') { return encodeAddColumnsMigrationStep(step) + } else if (step.type === 'destroy_column') { + return encodeDestroyColumnMigrationStep(step, schema.tables[step.table]) } else if (step.type === 'sql') { return step.sql } diff --git a/src/adapters/sqlite/index.js b/src/adapters/sqlite/index.js index 72a5af9f5..d28da976e 100644 --- a/src/adapters/sqlite/index.js +++ b/src/adapters/sqlite/index.js @@ -169,7 +169,7 @@ export default class SQLiteAdapter implements DatabaseAdapter { 'setUpWithMigrations', [ this.dbName, - require('./encodeSchema').encodeMigrationSteps(migrationSteps), + require('./encodeSchema').encodeMigrationSteps(migrationSteps, this.schema), databaseVersion, this.schema.version, ],