diff --git a/Readme.md b/Readme.md index 5294466..c4c4a1c 100644 --- a/Readme.md +++ b/Readme.md @@ -254,6 +254,63 @@ topic directly as you user command parameter; all you will then need to do is to set the index at some point before you attempt to record any operations: +#### Migrating topics + +> `./lib/modules/modulename/topics/Player.ts` + +```typescript +import { ValidatedTopic, ValidateNested, IsUUID, IsAlpha } from 'mage-validator'; +import { Type } from 'class-transform'; +import PlayerData from '../topics/PlayerData' + +class Index { + @isUUID(5) + playerId: string +} + +export default class { + // Index configuration + public static readonly index = ['playerId'] + public static readonly indexType = Index + + // Vaults configuration (optional) + public static readonly vaults = {} + + // Attribute instances + @IsAlpha() + public name: string + + public age: number + + @ValidateNested() + @Type(() => PlayerData) + public data: PlayerData + + @Migrate(1) + public setDefaultName() { + if (!this.name) { + this.name = 'DefaultName' + Math.floor(Math.random() * 1000) + } + } + + @Migrate(2) + public setDefaultAge() { + if (!this.age) { + this.age = 0 + } + } +} +``` + +All `ValdiatedTopic` have a `_version`, attribute used for data versioning. The default value is 0. + +You can define methods to be used as migration steps. Use the `Migration` +decorator to define the version this step will upgrade the data to. +Only migration steps with a higher step value than the current data will +be executed. + +Migrations will **not** be executed on newly created topic instances. + #### Topics as user command parameters > `./lib/modules/modulename/usercommands/createPlayer.ts` diff --git a/package-lock.json b/package-lock.json index c835f25..933265e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "mage-validator", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b9284ad..958abcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mage-validator", - "version": "0.9.1", + "version": "0.9.2", "description": "Validation utility for MAGE user commands & topics (TypeScript)", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/src/classes/ValidatedTomeTopic.ts b/src/classes/ValidatedTomeTopic.ts index 536695f..444d383 100644 --- a/src/classes/ValidatedTomeTopic.ts +++ b/src/classes/ValidatedTomeTopic.ts @@ -1,5 +1,6 @@ import ValidatedTopic, { IStaticThis, TopicData } from './ValidatedTopic' import { defaultMetadataStorage } from 'class-transformer/storage' +import * as classTransformer from 'class-transformer' import { archivist } from 'mage' import * as mage from 'mage' @@ -259,18 +260,13 @@ export default class ValidatedTomeTopic extends ValidatedTopic { data?: TopicData ): Promise { - const tome: any = Tome.isTome(data) ? data : Tome.conjure(data || {}) const className = this.getClassName() - const instance = new this() const typeMap: any = {} - // Create default values - if (!data) { - Object.keys(instance).forEach((key) => { - tome.set(key, ( instance)[key]) - delete ( instance)[key] - }) - } + const isTome = Tome.isTome(data) + const rawData: any = isTome ? Tome.unTome(data) : data + const instance = classTransformer.plainToClass(this, rawData) || new this() + const tome: any = isTome ? data : Tome.conjure(instance) instance.setTopic(className) instance.setState(state) @@ -316,7 +312,6 @@ export default class ValidatedTomeTopic extends ValidatedTopic { return target[key] } - return undefined } diff --git a/src/classes/ValidatedTopic.ts b/src/classes/ValidatedTopic.ts index 1b8558b..f17e4e4 100644 --- a/src/classes/ValidatedTopic.ts +++ b/src/classes/ValidatedTopic.ts @@ -34,6 +34,7 @@ export type PartialIndex = { * that return a properly typed output. */ export interface IStaticThis { + version: number, // Todo: any should be I! indexType: { new(): any }, @@ -98,8 +99,13 @@ export default class ValidatedTopic { public static readonly indexType: any public static readonly vaults = {} + public static version = 0 + public static migrations = new Map() + private static _className: string + public _version: number = 0 + /** * Return the current class name * @@ -161,6 +167,11 @@ export default class ValidatedTopic { instance.setState(state) await instance.setIndex(index) + /* istanbul ignore next */ + if (!instance._version) { + instance._version = this.version + } + return instance } @@ -228,7 +239,10 @@ export default class ValidatedTopic { return undefined } - return this.create(state, index, data) + const instance = await this.create(state, index, data) + await instance.migrate() + + return instance }) } @@ -298,6 +312,8 @@ export default class ValidatedTopic { const index = queries[i].index const instance = await this.create(state, index as I, data) + await instance.migrate() + instances.push(instance) } @@ -541,6 +557,38 @@ export default class ValidatedTopic { } } + /** + * Migrate data using predefined migration methods + */ + public async migrate() { + const type = this.constructor as typeof ValidatedTopic + const { migrations } = type + + let migrated = false + + // tslint:disable-next-line:strict-type-predicates + if (this._version === undefined) { + this._version = 0 + } + + if (this._version === type.version) { + return + } + + for (const [version, methodName] of migrations.entries()) { + if (version > this._version) { + const method = (this as any)[methodName] + await method.call(this) + this._version = version + migrated = true + } + } + + if (migrated) { + await this.set() + } + } + /** * Throw a ValidateError including relevant details */ diff --git a/src/decorators.ts b/src/decorators.ts index 9fe14a2..631d622 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -65,7 +65,7 @@ function wrapCreate(target: any, key: string, childrenType: any, validateFunctio } for (const [subkey, value] of Object.entries(this[key])) { - errors = errors.concat(await classValidator.validate(value)) + errors = errors.concat(await classValidator.validate(value as any)) if (!validateFunction) { continue @@ -245,3 +245,18 @@ export function Acl(...acl: string[]) { } } } + +export function Migrate(version: number) { + return function (topic: ValidatedTopic, method: string) { + const type = topic.constructor as typeof ValidatedTopic + const { migrations } = type + migrations.set(version, method) + + const migrationsArray = [...migrations.entries()] + const sortedMigrationArrays = migrationsArray.sort(([a], [b]) => a - b) + const sortedMigrations = new Map(sortedMigrationArrays) + + type.migrations = sortedMigrations + type.version = Array.from(sortedMigrations.keys()).pop() as number + } +} diff --git a/test/tomeTopic/create.ts b/test/tomeTopic/create.ts index a708521..308119a 100644 --- a/test/tomeTopic/create.ts +++ b/test/tomeTopic/create.ts @@ -51,7 +51,7 @@ class TestTomeTopic extends ValidatedTomeTopic { @Type(() => TestTome) @ValidateNested() - public children: TestTome[] + public children: TestTome[] = [] public whatever: any } @@ -80,6 +80,16 @@ describe('create', function () { assert.strictEqual(tTest.name, 'ohai') }) + it('tome nested values are properly set and accessible', async () => { + const test = new TestTome() + test.childId = '1' + + const tome = Tome.conjure({ name: 'ohai', children: [test] }) + const tTest = await TestTomeTopic.create(state, { id: '1' }, tome) + assert.strictEqual(tTest.children.length, 1) + assert.strictEqual(tTest.children[0].childId, '1') + }) + it('getData returns the tome instance', async () => { const tTest = await TestTomeTopic.create(state, { id: '1' }) const data = tTest.getData() diff --git a/test/tomeTopic/iterate.ts b/test/tomeTopic/iterate.ts index 9c007ef..6062f12 100644 --- a/test/tomeTopic/iterate.ts +++ b/test/tomeTopic/iterate.ts @@ -47,8 +47,8 @@ describe('iterate', function () { const vals = Object.values(tTest) - assert.strictEqual(vals[0], 'hello') - assert.strictEqual(vals[1][0], '1') + assert.strictEqual(vals[1], 'hello') + assert.strictEqual(vals[2][0], '1') // assert.deepStrictEqual(vals, ['hello', ['1']]) }) @@ -72,7 +72,7 @@ describe('iterate', function () { tTest.list = [] tTest.children = [] - assert.deepStrictEqual(Object.keys(tTest), ['list', 'children']) + assert.deepStrictEqual(Object.keys(tTest), ['_version', 'list', 'children']) }) it('lists nested tome keys', async () => { diff --git a/test/tomeTopic/log.ts b/test/tomeTopic/log.ts index d117fb7..1902491 100644 --- a/test/tomeTopic/log.ts +++ b/test/tomeTopic/log.ts @@ -52,7 +52,7 @@ describe('log, inspect, etc', function () { tTest.list = ['b', 'c'] tTest.num = 1 - const res = '{"name":"my name","list":["b","c"],"num":1}' + const res = '{"_version":0,"name":"my name","list":["b","c"],"num":1}' assert.strictEqual(tTest.toString(), res) assert.strictEqual(( tTest)[Symbol.toStringTag](), res) assert.strictEqual(tTest.list.toString(), '["b","c"]') @@ -63,9 +63,9 @@ describe('log, inspect, etc', function () { tTest.name = 'my name' tTest.list = ['b', 'c'] - const res = 'TestTopic -> { name: \'my name\', list: [ \'b\', \'c\' ] }' + const res = 'TestTopic -> { _version: 0, name: \'my name\', list: [ \'b\', \'c\' ] }' - assert.strictEqual(( tTest).inspect(0, {}), 'TestTopic -> { name: \'my name\', list: [Array] }') + assert.strictEqual(( tTest).inspect(0, {}), 'TestTopic -> { _version: 0, name: \'my name\', list: [Array] }') assert.strictEqual(( tTest).inspect(1, {}), res) assert.strictEqual(( tTest)[inspect.custom](), res) }) diff --git a/test/topic/index.ts b/test/topic/index.ts index 4f5aef6..7b6a467 100644 --- a/test/topic/index.ts +++ b/test/topic/index.ts @@ -58,4 +58,5 @@ describe('Validated Topics', function () { require('./add-set-touch') require('./del') require('./type-decorator') + require('./migrate') })