Skip to content

Commit

Permalink
Database diff for entities & attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
loicknuchel committed Oct 2, 2024
1 parent 7f39fb4 commit c799d85
Show file tree
Hide file tree
Showing 4 changed files with 325 additions and 101 deletions.
126 changes: 86 additions & 40 deletions libs/models/src/databaseDiff.test.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,129 @@
import {describe, expect, test} from "@jest/globals";
import {databaseDiff} from "./databaseDiff";
import {Database} from "./database";
import {attributesDiff, databaseDiff} from "./databaseDiff";
import {Attribute, Database} from "./database";

describe('databaseDiff', () => {
test('empty', () => {
expect(databaseDiff({}, {})).toEqual({})
})
describe('entities', () => {
test('create', () => {
expect(databaseDiff({}, {entities: [{name: 'users'}]})).toEqual({entities: {created: [{i: 0, name: 'users'}]}})
})
test('delete', () => {
expect(databaseDiff({entities: [{name: 'users'}]}, {})).toEqual({entities: {deleted: [{i: 0, name: 'users'}]}})
})
test('same', () => {
const before = {entities: [{name: 'users'}]}
const after = {entities: [{name: 'users'}]}
const diff = {entities: {unchanged: [{i: 0, name: 'users'}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('update def', () => {
const before = {entities: [{name: 'users', def: 'SELECT * FROM users'}]}
const after = {entities: [{name: 'users', def: 'SELECT id, name FROM users'}]}
const diff = {entities: {updated: [{name: 'users', def: {before: 'SELECT * FROM users', after: 'SELECT id, name FROM users'}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('update doc', () => {
const before = {entities: [{name: 'users', doc: 'store users'}]}
const after = {entities: [{name: 'users', doc: 'list users'}]}
const diff = {entities: {updated: [{name: 'users', doc: {before: 'store users', after: 'list users'}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('update extra', () => {
const before = {entities: [{name: 'users', extra: {}}]}
const after = {entities: [{name: 'users', extra: {deprecated: null}}]}
const diff = {entities: {updated: [{name: 'users', extra: {deprecated: {before: undefined, after: null}}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
})
describe('types', () => {
test('create type', () => {
test('create', () => {
expect(databaseDiff({}, {types: [{name: 'position'}]})).toEqual({types: {created: [{i: 0, name: 'position'}]}})
})
test('delete type', () => {
test('delete', () => {
expect(databaseDiff({types: [{name: 'position'}]}, {})).toEqual({types: {deleted: [{i: 0, name: 'position'}]}})
})
test('same type', () => {
test('same', () => {
const before = {types: [{name: 'position'}]}
const after = {types: [{name: 'position'}]}
const diff = {types: {unchanged: [{i: 0, name: 'position'}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change alias', () => {
test('update alias', () => {
const before = {types: [{name: 'position', alias: 'p'}]}
const after = {types: [{name: 'position', alias: 'pos'}]}
const diff = {types: {updated: [{name: 'position', alias: {before: 'p', after: 'pos'}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change values', () => {
test('update values', () => {
const before: Database = {types: [{name: 'status', values: ['draft', 'public']}]}
const after: Database = {types: [{name: 'status', values: ['draft', 'private']}]}
const diff = {types: {updated: [{name: 'status', values: {before: ['draft', 'public'], after: ['draft', 'private']}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type add attribute', () => {
const before: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]}]}
const after: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}, {name: 'z', type: 'int'}]}]}
const diff = {types: {updated: [{name: 'position', attrs: {unchanged: [{i: 0, name: 'x', type: 'int'}, {i: 1, name: 'y', type: 'int'}], created: [{i: 2, name: 'z', type: 'int'}]}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type drop attribute', () => {
const before: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]}]}
const after: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}]}]}
const diff = {types: {updated: [{name: 'position', attrs: {unchanged: [{i: 0, name: 'x', type: 'int'}], deleted: [{i: 1, name: 'y', type: 'int'}]}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change attribute type', () => {
const before: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]}]}
const after: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'bigint'}]}]}
const diff = {types: {updated: [{name: 'position', attrs: {unchanged: [{i: 0, name: 'x', type: 'int'}], updated: [{name: 'y', type: {before: 'int', after: 'bigint'}}]}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type rename attribute', () => {
const before: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]}]}
const after: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'z', type: 'int'}]}]}
const diff = {types: {updated: [{name: 'position', attrs: {unchanged: [{i: 0, name: 'x', type: 'int'}], created: [{i: 1, name: 'z', type: 'int'}], deleted: [{i: 1, name: 'y', type: 'int'}]}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change attribute position', () => {
const before: Database = {types: [{name: 'position', attrs: [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]}]}
const after: Database = {types: [{name: 'position', attrs: [{name: 'y', type: 'int'}, {name: 'x', type: 'int'}]}]}
const diff = {types: {updated: [{name: 'position', attrs: {updated: [{i: {before: 0, after: 1}, name: 'x'}, {i: {before: 1, after: 0}, name: 'y'}]}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change definition', () => {
test('update definition', () => {
const before = {types: [{name: 'position', definition: 'range(0..10)'}]}
const after = {types: [{name: 'position', definition: 'range(0..100)'}]}
const diff = {types: {updated: [{name: 'position', definition: {before: 'range(0..10)', after: 'range(0..100)'}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change doc', () => {
test('update doc', () => {
const before = {types: [{name: 'position', doc: 'store position'}]}
const after = {types: [{name: 'position', doc: 'save position'}]}
const diff = {types: {updated: [{name: 'position', doc: {before: 'store position', after: 'save position'}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
test('type change extra', () => {
test('update extra', () => {
const before = {types: [{name: 'position', extra: {}}]}
const after = {types: [{name: 'position', extra: {deprecated: null}}]}
const diff = {types: {updated: [{name: 'position', extra: {deprecated: {before: undefined, after: null}}}]}}
expect(databaseDiff(before, after)).toEqual(diff)
})
})
describe('attributes', () => {
test('create', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}, {name: 'z', type: 'int'}]
const diff = {unchanged: [{i: 0, name: 'x', type: 'int'}, {i: 1, name: 'y', type: 'int'}], created: [{i: 2, name: 'z', type: 'int'}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('delete', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'x', type: 'int'}]
const diff = {unchanged: [{i: 0, name: 'x', type: 'int'}], deleted: [{i: 1, name: 'y', type: 'int'}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('rename', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'x', type: 'int'}, {name: 'z', type: 'int'}]
const diff = {unchanged: [{i: 0, name: 'x', type: 'int'}], created: [{i: 1, name: 'z', type: 'int'}], deleted: [{i: 1, name: 'y', type: 'int'}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('update position', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'y', type: 'int'}, {name: 'x', type: 'int'}]
const diff = {updated: [{i: {before: 0, after: 1}, name: 'x'}, {i: {before: 1, after: 0}, name: 'y'}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('update type', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'bigint'}]
const diff = {unchanged: [{i: 0, name: 'x', type: 'int'}], updated: [{name: 'y', type: {before: 'int', after: 'bigint'}}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('update null', () => {
const before: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int', null: false}]
const after: Attribute[] = [{name: 'x', type: 'int', null: true}, {name: 'y', type: 'int'}]
const diff = {updated: [{name: 'x', null: {before: undefined, after: true}}, {name: 'y', null: {before: false, after: undefined}}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
test('update default', () => {
const before: Attribute[] = [{name: 'x', type: 'int', default: 0}, {name: 'y', type: 'int'}]
const after: Attribute[] = [{name: 'x', type: 'int'}, {name: 'y', type: 'int', default: '5'}]
const diff = {updated: [{name: 'x', default: {before: 0, after: undefined}}, {name: 'y', default: {before: undefined, after: '5'}}]}
expect(attributesDiff(before, after)).toEqual(diff)
})
})
})
79 changes: 62 additions & 17 deletions libs/models/src/databaseDiff.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import {z} from "zod";
import {anySame, arraySame, diffBy, removeEmpty, removeUndefined} from "@azimutt/utils";
import {Attribute, AttributeName, AttributeType, Database, Extra, Namespace, Type, TypeName} from "./database";
import {typeToId, typeToNamespace} from "./databaseUtils";
import {
Attribute,
AttributeName,
AttributeType,
AttributeValue,
Database,
Entity,
EntityKind,
EntityName,
Extra,
Namespace,
Type,
TypeName
} from "./database";
import {entityToId, typeToId, typeToNamespace} from "./databaseUtils";

// FIXME: Work In Progress
// cf https://github.com/andreyvit/json-diff
Expand All @@ -16,16 +29,18 @@ export type ArrayDiff<T, U> = {unchanged: (T & {i: number})[], updated: U[], cre

export const ValueDiff = <T>(schema: z.ZodType<T>) => z.object({before: schema, after: schema}).strict()
export type ValueDiff<T> = {before: T, after: T}
export const OptValueDiff = <T>(schema: z.ZodType<T>) => z.object({before: schema.optional(), after: schema.optional()}).strict()
export type OptValueDiff<T> = {before?: T | undefined, after?: T | undefined}

export const ExtraDiff = z.record(z.object({before: z.any(), after: z.any()}))
export type ExtraDiff = z.infer<typeof ExtraDiff>
export const AttributeDiff = z.object({
name: AttributeName,
i: ValueDiff(z.number()).optional(),
type: ValueDiff(AttributeType).optional(),
// null: z.boolean().optional(), // false when not specified
null: OptValueDiff(z.boolean()).optional(),
// gen: z.boolean().optional(), // false when not specified
// default: AttributeValue.optional(),
default: OptValueDiff(AttributeValue).optional(),
// attrs: z.lazy(() => Attribute.array().optional()),
// doc: z.string().optional(),
// stats: AttributeStats.optional(),
Expand All @@ -35,52 +50,82 @@ export type AttributeDiff = { // define type explicitly because it's lazy (https
name: AttributeName
i?: ValueDiff<number>,
type?: ValueDiff<AttributeType>,
// null?: boolean | undefined
null?: OptValueDiff<boolean>
// gen?: boolean | undefined
// default?: AttributeValue | undefined
default?: OptValueDiff<AttributeValue>
// attrs?: Attribute[] | undefined
// doc?: string | undefined
// stats?: AttributeStats | undefined
// extra?: AttributeExtra | undefined
}
export const EntityDiff = Namespace.extend({
name: EntityName,
kind: OptValueDiff(EntityKind),
def: OptValueDiff(z.string()).optional(),
attrs: ArrayDiff(Attribute, AttributeDiff).optional(),
// pk: PrimaryKey.optional(),
// indexes: Index.array().optional(),
// checks: Check.array().optional(),
doc: OptValueDiff(z.string()).optional(),
// stats: EntityStats.optional(),
extra: ExtraDiff.optional(),
}).strict()
export type EntityDiff = z.infer<typeof EntityDiff>
export const TypeDiff = Namespace.extend({
name: TypeName,
alias: ValueDiff(z.string().optional()).optional(),
values: ValueDiff(z.string().array().optional()).optional(),
alias: OptValueDiff(z.string()).optional(),
values: OptValueDiff(z.string().array()).optional(),
attrs: ArrayDiff(Attribute, AttributeDiff).optional(),
definition: ValueDiff(z.string().optional()).optional(),
doc: ValueDiff(z.string().optional()).optional(),
definition: OptValueDiff(z.string()).optional(),
doc: OptValueDiff(z.string()).optional(),
extra: ExtraDiff.optional(),
}).strict().describe('TypeDiff')
export type TypeDiff = z.infer<typeof TypeDiff>
export const DatabaseDiff = z.object({
entities: ArrayDiff(Entity, EntityDiff).optional(),
types: ArrayDiff(Type, TypeDiff).optional(),
}).strict().describe('DatabaseDiff')
export type DatabaseDiff = z.infer<typeof DatabaseDiff>


export function databaseDiff(before: Database, after: Database): DatabaseDiff {
const entities = arrayDiffBy(before.entities || [], after.entities || [], entityToId, entityDiff)
const types = arrayDiffBy(before.types || [], after.types || [], typeToId, typeDiff)
return removeEmpty({types})
return removeEmpty({entities, types})
}

function entityDiff(before: Entity & {i: number}, after: Entity & {i: number}): EntityDiff | undefined {
const kind = valueDiff(before.kind, after.kind)
const def = before.def === after.def ? undefined : valueDiff(before.def, after.def)
const attrs = attributesDiff(before.attrs || [], after.attrs || [])
const doc = before.doc === after.doc ? undefined : valueDiff(before.doc, after.doc)
const extra = extraDiff(before.extra || {}, after.extra || {})
if ([def, attrs, doc, extra].every(v => v === undefined)) return undefined
return removeUndefined({...typeToNamespace(before), name: before.name, kind, def, attrs, doc, extra})
}

function typeDiff(before: Type & {i: number}, after: Type & {i: number}): TypeDiff | undefined {
const i = before.i === after.i ? undefined : valueDiff(before.i, after.i)
const alias = before.alias === after.alias ? undefined : valueDiff(before.alias, after.alias)
const values = arraySame(before.values || [], after.values || [], (a, b) => a === b) ? undefined : valueDiff(before.values, after.values)
const attrs = arrayDiffBy(before.attrs || [], after.attrs || [], a => a.name, attributeDiff)
const attrs = attributesDiff(before.attrs || [], after.attrs || [])
const definition = before.definition === after.definition ? undefined : valueDiff(before.definition, after.definition)
const doc = before.doc === after.doc ? undefined : valueDiff(before.doc, after.doc)
const extra = extraDiff(before.extra || {}, after.extra || {})
if ([i, alias, values, attrs, definition, doc, extra].every(v => v === undefined)) return undefined
return removeUndefined({i, ...typeToNamespace(before), name: before.name, alias, values, attrs, definition, doc, extra})
if ([alias, values, attrs, definition, doc, extra].every(v => v === undefined)) return undefined
return removeUndefined({...typeToNamespace(before), name: before.name, alias, values, attrs, definition, doc, extra})
}

export function attributesDiff(before: Attribute[], after: Attribute[]): ArrayDiff<Attribute, AttributeDiff> | undefined {
return arrayDiffBy(before, after, a => a.name, attributeDiff)
}

function attributeDiff(before: Attribute & {i: number}, after: Attribute & {i: number}): AttributeDiff | undefined {
const i = before.i === after.i ? undefined : valueDiff(before.i, after.i)
const type = before.type === after.type ? undefined : valueDiff(before.type, after.type)
if ([i, type].every(v => v === undefined)) return undefined
return removeUndefined({...typeToNamespace(before), name: before.name, i, type})
const nullable = before.null === after.null ? undefined : valueDiff(before.null, after.null)
const defaultValue = before.default === after.default ? undefined : valueDiff(before.default, after.default)
if ([i, type, nullable, defaultValue].every(v => v === undefined)) return undefined
return removeUndefined({...typeToNamespace(before), name: before.name, i, type, null: nullable, default: defaultValue})
}

function extraDiff(before: Extra, after: Extra): ExtraDiff | undefined {
Expand Down
Loading

0 comments on commit c799d85

Please sign in to comment.