Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add destroyColumn, renameColumn, and destroyTable migration steps #1799

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5ee9166
add destroyColumn migration step
skam22 May 25, 2024
c6883c4
add renameColumn migration step
skam22 May 25, 2024
0674a7b
add loki rename_column to loki adapter
skam22 May 26, 2024
0cb7980
add destroy table migration step
skam22 May 26, 2024
b5b642a
add missing type
skam22 May 27, 2024
0b0bdf1
add migration step labels to getSyncChanges
skam22 Jun 2, 2024
d91e6d8
remove destroy_column and destroy_table from possible future types test
skam22 Jun 4, 2024
82b8fc2
Merge branch 'master' of github.com:skam22/WatermelonDB into skam22-m…
radex Jun 13, 2024
50d9be3
encodeSchema: consistent sql style, escape column/table names
radex Jun 13, 2024
493907e
encodeSchema: clean up
radex Jun 13, 2024
637737a
encodeSchema: clean up and test table destroy
radex Jun 13, 2024
f7b17b6
migrations: validate column name in renameColumn, add test
radex Jun 13, 2024
b2a5e1a
loki: clean up new migrations
radex Jun 13, 2024
1b8f69b
commonTests: test column rename
radex Jun 13, 2024
5a27ec1
commonTests: migrations: destroyTable
radex Jun 13, 2024
be4349d
commonTests: migrations: destroyColumn
radex Jun 13, 2024
46d9773
commonTests: migrations: clean up
radex Jun 13, 2024
9c029ed
commonTests: migrations: renameColumn
radex Jun 13, 2024
c7c9c2d
commonTests: migrations: add more variants of column rename/delete
radex Jun 14, 2024
e095586
sqlite: simplify new migrations / fix bugs
radex Jun 14, 2024
1cafb68
common: migrations: check table re-creation migration
radex Jun 15, 2024
99af408
commonTests: migrations: add complex rename/destroy column test; fix …
radex Jun 15, 2024
efb9ba3
fix flow
radex Jun 15, 2024
d2c48fa
Merge remote-tracking branch 'upstream/master' into merge
skam22 Jul 13, 2024
826873a
migrations: add unsafeSql option to renameColumn, destroyColumn, dest…
radex Jul 12, 2024
1e84a89
migrations: improve documentation
radex Jul 12, 2024
8bf1793
migrations: fix getSyncChanges for renamed columns, deleted columns/t…
radex Jul 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG-Unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
### New features

- Added `Database#experimentalIsVerbose` option
- New migration steps available:
- `destroyColumn` (see docs for limitations)
- `renameColumn` (see docs for limitations)
- `destroyTable`

### Fixes

Expand Down
7 changes: 6 additions & 1 deletion docs-website/docs/docs/Advanced/Migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,12 @@ schemaMigrations({

- `createTable({ name: 'table_name', columns: [ ... ] })` - same API as `tableSchema()`
- `addColumns({ table: 'table_name', columns: [ ... ] })` - you can add one or multiple columns to an existing table. The columns table has the same format as in schema definitions
- Other types of migrations (e.g. deleting or renaming tables and columns) are not yet implemented. See [`migrations/index.js`](https://github.com/Nozbe/WatermelonDB/blob/master/src/Schema/migrations/index.js). Please contribute!
- `destroyColumn({ table: 'table_name', column: 'column_name' })` - removes column from table
- ⚠️ Important! This migration requires sqlite 3.35.0, available since iOS 15 and Android 14 (in non-JSI mode). `lokijs` adapter, Android in JSI mode, and sqlite-node are not affected by this limitation.
- `renameColumn({ table: 'table_name', from: 'old_column_name', to: 'new_column_name' })` - renames column in table
- ⚠️ Important! This migration requires sqlite 3.25.0, available since iOS 13 and Android 11 (in non-JSI mode). `lokijs` adapter, Android in JSI mode, and sqlite-node are not affected by this limitation.
- `destroyTable({ table: 'table_name' })` - removes table
- Other types of migrations (e.g. renaming tables, changing indices) are not yet implemented. See [`migrations/index.js`](https://github.com/Nozbe/WatermelonDB/blob/master/src/Schema/migrations/index.js). Please contribute!

## Database reseting and other edge cases

Expand Down
2 changes: 1 addition & 1 deletion src/RawRecord/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,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)
Expand Down
2 changes: 1 addition & 1 deletion src/RawRecord/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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)
Expand Down
2 changes: 1 addition & 1 deletion src/Schema/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function appSchema({ version, tables: tableList, unsafeSql }: AppSchemaSp
return { version, tables, unsafeSql }
}

const validateName = (name: string) => {
export const validateName = (name: string) => {
if (process.env.NODE_ENV !== 'production') {
invariant(
!['id', '_changed', '_status', 'local_storage'].includes(name.toLowerCase()),
Expand Down
70 changes: 43 additions & 27 deletions src/Schema/migrations/getSyncChanges/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// @flow

import { unique, groupBy, toPairs, pipe, unnest } from '../../../utils/fp'
import type { CreateTableMigrationStep, AddColumnsMigrationStep, SchemaMigrations } from '../index'
import type { SchemaMigrations } from '../index'
import type { TableName, ColumnName, SchemaVersion } from '../../index'
import { tableName } from '../../index'
import { stepsForMigration } from '../stepsForMigration'
Expand Down Expand Up @@ -32,40 +30,58 @@ export default function getSyncChanges(
return null
}

const createdTables: Set<TableName<any>> = new Set()
const createdColumns: Map<string, Set<ColumnName>> = new Map()

steps.forEach((step) => {
invariant(
['create_table', 'add_columns', 'sql'].includes(step.type),
[
'create_table',
'add_columns',
'destroy_column',
'rename_column',
'destroy_table',
'sql',
].includes(step.type),
`Unknown migration step type ${step.type}. Can not perform migration sync. This most likely means your migrations are defined incorrectly. It could also be a WatermelonDB bug.`,
)
})

// $FlowFixMe
const createTableSteps: CreateTableMigrationStep[] = steps.filter(
(step) => step.type === 'create_table',
)
const createdTables = createTableSteps.map((step) => step.schema.name)

// $FlowFixMe
const addColumnSteps: AddColumnsMigrationStep[] = steps.filter(
(step) => step.type === 'add_columns',
)
const allAddedColumns = addColumnSteps
.filter((step) => !createdTables.includes(step.table))
.map(({ table, columns }) => columns.map(({ name }) => ({ table, name })))
if (step.type === 'create_table') {
createdTables.add(step.schema.name)
} else if (step.type === 'add_columns') {
if (createdTables.has(step.table)) {
return
}
const columns = createdColumns.get(step.table) || new Set()
step.columns.forEach((column) => {
columns.add(column.name)
})
createdColumns.set(step.table, columns)
} else if (step.type === 'destroy_table') {
createdTables.delete(step.table)
createdColumns.delete(step.table)
} else if (step.type === 'destroy_column') {
const columns = createdColumns.get(step.table)
if (columns) {
columns.delete(step.column)
}
} else if (step.type === 'rename_column') {
const columns = createdColumns.get(step.table)
if (columns && columns.has(step.from)) {
columns.delete(step.from)
columns.add(step.to)
}
}
})

const columnsByTable = pipe(
unnest,
groupBy(({ table }) => table),
toPairs,
)(allAddedColumns)
const addedColumns = columnsByTable.map(([table, columnDefs]) => ({
const columnsByTable = Array.from(createdColumns.entries()).map(([table, columns]) => ({
table: tableName(table),
columns: unique(columnDefs.map(({ name }) => name)),
columns: Array.from(columns),
}))

return {
from: fromVersion,
tables: unique(createdTables),
columns: addedColumns,
tables: Array.from(createdTables),
columns: columnsByTable,
}
}
111 changes: 94 additions & 17 deletions src/Schema/migrations/getSyncChanges/test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import getSyncChanges from './index'
import { schemaMigrations, createTable, addColumns, unsafeExecuteSql } from '../index'
import {
schemaMigrations,
createTable,
addColumns,
unsafeExecuteSql,
renameColumn,
destroyColumn,
destroyTable,
} from '../index'

const createCommentsTable = createTable({
name: 'comments',
Expand All @@ -8,6 +16,13 @@ const createCommentsTable = createTable({
{ name: 'body', type: 'string' },
],
})
const addColumnsToPostsTable = addColumns({
table: 'posts',
columns: [
{ name: 'subtitle', type: 'string', isOptional: true },
{ name: 'is_pinned', type: 'boolean' },
],
})

const test = (migrations, from, to) => getSyncChanges(schemaMigrations({ migrations }), from, to)
const testSteps = (steps) =>
Expand All @@ -21,20 +36,14 @@ describe('getSyncChanges', () => {
expect(testSteps([])).toEqual({ from: 1, tables: [], columns: [] })
})
it('returns created tables', () => {
expect(testSteps([createCommentsTable])).toEqual({ from: 1, tables: ['comments'], columns: [] })
expect(testSteps([createCommentsTable])).toEqual({
from: 1,
tables: ['comments'],
columns: [],
})
})
it('returns added columns', () => {
expect(
testSteps([
addColumns({
table: 'posts',
columns: [
{ name: 'subtitle', type: 'string', isOptional: true },
{ name: 'is_pinned', type: 'boolean' },
],
}),
]),
).toEqual({
expect(testSteps([addColumnsToPostsTable])).toEqual({
from: 1,
tables: [],
columns: [{ table: 'posts', columns: ['subtitle', 'is_pinned'] }],
Expand Down Expand Up @@ -77,6 +86,73 @@ describe('getSyncChanges', () => {
columns: [],
})
})
it('skips added tables if subsequently destroyed', () => {
expect(
testSteps([
createCommentsTable,
destroyTable({ table: 'comments' }),
destroyTable({ table: 'unrelated' }),
]),
).toEqual({
from: 1,
tables: [],
columns: [],
})
})
it('skips added columns if subsequently destroyed', () => {
expect(
testSteps([
addColumnsToPostsTable,
destroyColumn({ table: 'posts', column: 'is_pinned' }),
destroyColumn({ table: 'posts', column: 'unrelated' }),
destroyColumn({ table: 'unrelated', column: 'is_pinned' }),
]),
).toEqual({
from: 1,
tables: [],
columns: [{ table: 'posts', columns: ['subtitle'] }],
})
})
it('skips added columns if parent table subsequently destroyed', () => {
expect(
testSteps([
addColumnsToPostsTable,
destroyTable({ table: 'posts' }),
destroyTable({ table: 'unrelated' }),
]),
).toEqual({
from: 1,
tables: [],
columns: [],
})
})
it('renames added columns if needed', () => {
expect(
testSteps([
addColumnsToPostsTable,
renameColumn({ table: 'posts', from: 'is_pinned', to: 'is_starred' }),
// not needed
renameColumn({ table: 'posts', from: 'foo', to: 'bar' }),
]),
).toEqual({
from: 1,
tables: [],
columns: [{ table: 'posts', columns: ['subtitle', 'is_starred'] }],
})
})
it('skips added columns if subsequently destroyed, after renaming', () => {
expect(
testSteps([
addColumnsToPostsTable,
renameColumn({ table: 'posts', from: 'is_pinned', to: 'is_starred' }),
destroyColumn({ table: 'posts', column: 'is_starred' }),
]),
).toEqual({
from: 1,
tables: [],
columns: [{ table: 'posts', columns: ['subtitle'] }],
})
})
it('skips duplicates', () => {
// technically, a duplicate createTable or addColumn would crash
// but this is in case future migration types could do something like it
Expand Down Expand Up @@ -214,7 +290,11 @@ describe('getSyncChanges', () => {
tables: [],
columns: [{ table: 'attachment_versions', columns: ['reactions'] }],
})
expect(test(bigMigrations, 9, 10)).toEqual({ from: 9, tables: [], columns: [] })
expect(test(bigMigrations, 9, 10)).toEqual({
from: 9,
tables: [],
columns: [],
})
expect(test(bigMigrations, 10, 10)).toEqual(null)
})
it(`fails on incorrect migrations`, () => {
Expand All @@ -225,12 +305,9 @@ describe('getSyncChanges', () => {
const possibleFutureTypes = [
'broken',
'rename_table',
'rename_column',
'add_column_index',
'make_column_optional',
'make_column_required',
'destroy_table',
'destroy_column',
]
possibleFutureTypes.forEach((type) => {
expect(() => testSteps([{ type }])).toThrow('Unknown migration step type')
Expand Down
70 changes: 68 additions & 2 deletions src/Schema/migrations/index.d.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,12 +20,39 @@ export type AddColumnsMigrationStep = $RE<{
unsafeSql?: (_: string) => string
}>

export type DestroyColumnMigrationStep = $RE<{
type: 'destroy_column'
table: TableName<any>
column: ColumnName
unsafeSql?: (_: string) => string
}>

export type RenameColumnMigrationStep = $RE<{
type: 'rename_column'
table: TableName<any>
from: ColumnName
to: ColumnName
unsafeSql?: (_: string) => string
}>

export type DestroyTableMigrationStep = $RE<{
type: 'destroy_table'
table: TableName<any>
unsafeSql?: (_: string) => string
}>

export type SqlMigrationStep = $RE<{
type: 'sql'
sql: string
}>

export type MigrationStep = CreateTableMigrationStep | AddColumnsMigrationStep | SqlMigrationStep
export type MigrationStep =
| CreateTableMigrationStep
| AddColumnsMigrationStep
| SqlMigrationStep
| DestroyColumnMigrationStep
| RenameColumnMigrationStep
| DestroyTableMigrationStep

type Migration = $RE<{
toVersion: SchemaVersion
Expand Down Expand Up @@ -50,4 +84,36 @@ export function addColumns({
unsafeSql?: (_: string) => string
}>): AddColumnsMigrationStep

/** Requires sqlite 3.35.0 (iOS 15 / Android 14) */
export function destroyColumn({
table,
column,
unsafeSql,
}: $Exact<{
table: TableName<any>
column: ColumnName
unsafeSql?: (_: string) => string
}>): DestroyColumnMigrationStep

/** Requires sqlite 3.25.0 (iOS 13 / Android 11) */
export function renameColumn({
table,
from,
to,
unsafeSql,
}: $Exact<{
table: TableName<any>
from: string
to: string
unsafeSql?: (_: string) => string
}>): RenameColumnMigrationStep

export function destroyTable({
table,
unsafeSql,
}: $Exact<{
table: TableName<any>
unsafeSql?: (_: string) => string
}>): DestroyTableMigrationStep

export function unsafeExecuteSql(sql: string): SqlMigrationStep
Loading
Loading