From 0736de8c3204eb0b1fca1d55180f4f8ebac452da Mon Sep 17 00:00:00 2001 From: Wesley Clements Date: Fri, 18 Mar 2022 01:56:03 -0400 Subject: [PATCH 1/2] Added ablility to pass filters into define types --- src/annotations.ts | 63 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/annotations.ts b/src/annotations.ts index d8f1b39e..c1808765 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -24,7 +24,7 @@ export type PrimitiveType = typeof Schema; export type DefinitionType = PrimitiveType - | PrimitiveType[] + | [PrimitiveType] | { array: PrimitiveType } | { map: PrimitiveType } | { collection: PrimitiveType } @@ -345,13 +345,66 @@ export function deprecated(throws: boolean = true, context: Context = globalCont } } -export function defineTypes( +type DefinitionTypeOptionsBase = { + type: T extends Map ? { + [prop in keyof typeof Map]: typeof Map[prop]; + } : + T extends Set ? { + [prop in keyof typeof Set]: typeof Set[prop]; + } : + T; + of?: PrimitiveType; + filter?: FilterCallback; + filterChildren?: FilterChildrenCallback; +} + +export type DefinitionTypeOptions = DefinitionTypeOptionsBase + | DefinitionTypeOptionsBase<[PrimitiveType], S, R, P, K, V> + | DefinitionTypeOptionsBase + | DefinitionTypeOptionsBase + +type PropertyDefinitionOptions = { + type: DefinitionType; + filter?: FilterCallback; + filterChildren?: FilterChildrenCallback; +} + +function isPrimitive(prop: any) { + return (typeof prop === "string" || Schema.isPrototypeOf(prop)); +} + +export function defineTypes( target: typeof Schema, - fields: { [property: string]: DefinitionType }, + fields: { + [property: string]: DefinitionType | DefinitionTypeOptions + }, context: Context = target._context || globalContext ) { - for (let field in fields) { - type(fields[field], context)(target.prototype, field); + for (let propertyName in fields) { + const propertyOptions: PropertyDefinitionOptions = (() => { + const prop = fields[propertyName]; + if (isPrimitive(prop) || Array.isArray(prop) || prop["type"] === null) return { + type: prop as DefinitionType + }; + + const options = prop as DefinitionTypeOptions; + const result = { + type: undefined, + filter: options.filter, + filterChildren: options.filterChildren + } + if (isPrimitive(options["type"]) || Array.isArray(options["type"])) { + result.type = options["type"]; + } else if (Map.isPrototypeOf(options["type"])) { + result.type = { map: options["of"] }; + } else if (Set.isPrototypeOf(options["type"])) { + result.type = { set: options["of"] }; + } + return result; + })(); + type(propertyOptions.type, context)(target.prototype, propertyName); + if (propertyOptions.filter) filter(propertyOptions.filter)(target.prototype, propertyName) + if (propertyOptions.filterChildren) filterChildren(propertyOptions.filter)(target.prototype, propertyName) } return target; } From 8dd3160f6d256d9a7dce628e92d820aeafc9089a Mon Sep 17 00:00:00 2001 From: Wesley Clements Date: Thu, 31 Mar 2022 18:30:50 -0400 Subject: [PATCH 2/2] Squashed commit of the following: commit 156135e543fbb978c7f358aa50c445e88227e226 Author: Wesley Clements Date: Thu Mar 31 18:02:06 2022 -0400 reverted change commit 5cb4f848a25acb920878edd1ae1e202fd4c31ea0 Author: Wesley Clements Date: Thu Mar 31 18:01:30 2022 -0400 removed auxiliary changes commit fc129cef26be4a54ffe8964384313aa907a5a800 Author: Wesley Clements Date: Thu Mar 31 16:59:30 2022 -0400 updated to include match MapSchema commit 24cf71755bb61c4243729857e6659f16b3d2fb27 Author: Wesley Clements Date: Thu Mar 31 16:58:19 2022 -0400 simplified exports commit ea383d05aeaf4d2981596036cf48e543ea28308b Author: Wesley Clements Date: Thu Mar 31 16:56:35 2022 -0400 added new line at end of file commit e23ceaeb48a4189605c3261d89556f872bd0cc9e Author: Wesley Clements Date: Thu Mar 31 16:56:11 2022 -0400 reverted change commit 93d99af66c0ab7d045153c294330b4a8c6c422d5 Author: Wesley Clements Date: Thu Mar 31 16:54:25 2022 -0400 updated exports to fix tests commit 58a7aa11d625ce72cf547870e800f1050a96118c Author: Wesley Clements Date: Thu Mar 31 16:51:29 2022 -0400 reverted changes commit 6333d602f9a41cf79a911a36a05a4dd0250884fe Author: Wesley Clements Date: Thu Mar 31 16:50:19 2022 -0400 removed unused type commit b11f201e58925f443432dd25e42cf4308a581a48 Author: Wesley Clements Date: Thu Mar 31 16:49:59 2022 -0400 fixed typing conflicting with function commit c9d6e1ae1694951c28e24606562437eaa7f275fe Author: Wesley Clements Date: Thu Mar 31 16:48:57 2022 -0400 reverted changes commit fc84e377980d3568902910ef4378d0c5a1b74790 Author: Wesley Clements Date: Thu Mar 31 16:48:27 2022 -0400 moved types around to better reflect domains commit 7c0fc9f72cc74569a01e6b00528beb30c4bb9f4c Author: Wesley Clements Date: Thu Mar 31 16:40:00 2022 -0400 refactored to reduce file bloat and impact on other files commit ac43f0f81df80a67c7232e5efdc3aaed68692925 Author: Wesley Clements Date: Thu Mar 31 15:21:49 2022 -0400 refactored to use optional chaining commit e0df843471d5b402f5a111162a3168c59e751480 Author: Wesley Clements Date: Thu Mar 31 14:51:53 2022 -0400 split annotations file into separate files by type commit e3e69c0455fdb3ac7a88611ae77aa4cc241275c5 Author: Wesley Clements Date: Thu Mar 31 14:21:35 2022 -0400 simplified types and implementation commit 8dfe1f09b19c10454e257e454b2b5d4058443e9f Author: Wesley Clements Date: Tue Mar 29 20:40:55 2022 -0400 changed to use Schema.is commit c7f0300a49e354b5b01b9511f7bfa877b6597997 Author: Wesley Clements Date: Tue Mar 29 20:39:38 2022 -0400 reverted "fix" commit 4747b7315fe77aa46f7ff9f99f054955f5fb1cb9 Author: Wesley Clements Date: Tue Mar 29 20:34:52 2022 -0400 fixed isPrototypeOf commit d723bbc9e752215ace88206bd65a4272fcd1c3e6 Author: Wesley Clements Date: Tue Mar 29 20:15:29 2022 -0400 simplified implementation commit cd7847bde2b3ad0451d50117105a7371e3742e1b Author: Wesley Clements Date: Fri Mar 18 03:00:30 2022 -0400 added defaults for generics commit 8c0c52ae99930d044d44aed1dd56edf66c4e38f2 Author: Wesley Clements Date: Fri Mar 18 02:53:15 2022 -0400 updated exports commit 9f06c015b57b35f4cb04ba69c37f0f87ec7331ed Author: Wesley Clements Date: Fri Mar 18 02:53:00 2022 -0400 refactored to address file length issue commit 706fb8e3819267108fca484af071e4037e92e82e Author: Wesley Clements Date: Fri Mar 18 02:00:31 2022 -0400 fixed strict equality for null --- src/Schema.ts | 2 +- src/annotations.ts | 410 ---------------------------- src/annotations/Context.ts | 37 +++ src/annotations/SchemaDefinition.ts | 105 +++++++ src/annotations/defineTypes.ts | 33 +++ src/annotations/index.ts | 217 +++++++++++++++ src/index.ts | 4 +- 7 files changed, 396 insertions(+), 412 deletions(-) delete mode 100644 src/annotations.ts create mode 100644 src/annotations/Context.ts create mode 100644 src/annotations/SchemaDefinition.ts create mode 100644 src/annotations/defineTypes.ts create mode 100644 src/annotations/index.ts diff --git a/src/Schema.ts b/src/Schema.ts index 3644f16a..7ba76129 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -122,7 +122,7 @@ export abstract class Schema { console.error(e); } - static is(type: DefinitionType) { + static is(type: DefinitionType): type is typeof Schema { return ( type['_definition'] && type['_definition'].schema !== undefined diff --git a/src/annotations.ts b/src/annotations.ts deleted file mode 100644 index c1808765..00000000 --- a/src/annotations.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { ChangeTree } from './changes/ChangeTree'; -import { Schema } from './Schema'; -import { ArraySchema, getArrayProxy } from './types/ArraySchema'; -import { MapSchema, getMapProxy } from './types/MapSchema'; -import { getType } from './types'; - -/** - * Data types - */ -export type PrimitiveType = - "string" | - "number" | - "boolean" | - "int8" | - "uint8" | - "int16" | - "uint16" | - "int32" | - "uint32" | - "int64" | - "uint64" | - "float32" | - "float64" | - typeof Schema; - -export type DefinitionType = PrimitiveType - | [PrimitiveType] - | { array: PrimitiveType } - | { map: PrimitiveType } - | { collection: PrimitiveType } - | { set: PrimitiveType }; - -export type Definition = { [field: string]: DefinitionType }; -export type FilterCallback< - T extends Schema = any, - V = any, - R extends Schema = any -> = ( - ((this: T, client: ClientWithSessionId, value: V) => boolean) | - ((this: T, client: ClientWithSessionId, value: V, root: R) => boolean) -); - -export type FilterChildrenCallback< - T extends Schema = any, - K = any, - V = any, - R extends Schema = any -> = ( - ((this: T, client: ClientWithSessionId, key: K, value: V) => boolean) | - ((this: T, client: ClientWithSessionId, key: K, value: V, root: R) => boolean) -) - -export class SchemaDefinition { - schema: Definition; - - // - // TODO: use a "field" structure combining all these properties per-field. - // - - indexes: { [field: string]: number } = {}; - fieldsByIndex: { [index: number]: string } = {}; - - filters: { [field: string]: FilterCallback }; - indexesWithFilters: number[]; - childFilters: { [field: string]: FilterChildrenCallback }; // childFilters are used on Map, Array, Set items. - - deprecated: { [field: string]: boolean } = {}; - descriptors: PropertyDescriptorMap & ThisType = {}; - - static create(parent?: SchemaDefinition) { - const definition = new SchemaDefinition(); - - // support inheritance - definition.schema = Object.assign({}, parent && parent.schema || {}); - definition.indexes = Object.assign({}, parent && parent.indexes || {}); - definition.fieldsByIndex = Object.assign({}, parent && parent.fieldsByIndex || {}); - definition.descriptors = Object.assign({}, parent && parent.descriptors || {}); - definition.deprecated = Object.assign({}, parent && parent.deprecated || {}); - - return definition; - } - - addField(field: string, type: DefinitionType) { - const index = this.getNextFieldIndex(); - this.fieldsByIndex[index] = field; - this.indexes[field] = index; - this.schema[field] = (Array.isArray(type)) - ? { array: type[0] } - : type; - } - - addFilter(field: string, cb: FilterCallback) { - if (!this.filters) { - this.filters = {}; - this.indexesWithFilters = []; - } - this.filters[this.indexes[field]] = cb; - this.indexesWithFilters.push(this.indexes[field]); - return true; - } - - addChildrenFilter(field: string, cb: FilterChildrenCallback) { - const index = this.indexes[field]; - const type = this.schema[field]; - - if (getType(Object.keys(type)[0])) { - if (!this.childFilters) { this.childFilters = {}; } - - this.childFilters[index] = cb; - return true; - - } else { - console.warn(`@filterChildren: field '${field}' can't have children. Ignoring filter.`); - } - } - - getChildrenFilter(field: string) { - return this.childFilters && this.childFilters[this.indexes[field]]; - } - - getNextFieldIndex() { - return Object.keys(this.schema || {}).length; - } -} - -export function hasFilter(klass: typeof Schema) { - return klass._context && klass._context.useFilters; -} - -// Colyseus integration -export type ClientWithSessionId = { sessionId: string } & any; - -export class Context { - types: {[id: number]: typeof Schema} = {}; - schemas = new Map(); - useFilters = false; - - has(schema: typeof Schema) { - return this.schemas.has(schema); - } - - get(typeid: number) { - return this.types[typeid]; - } - - add(schema: typeof Schema, typeid: number = this.schemas.size) { - // FIXME: move this to somewhere else? - // support inheritance - schema._definition = SchemaDefinition.create(schema._definition); - - schema._typeid = typeid; - this.types[typeid] = schema; - this.schemas.set(schema, typeid); - } - - static create(context: Context = new Context) { - return function (definition: DefinitionType) { - return type(definition, context); - } - } -} - -export const globalContext = new Context(); - -/** - * `@type()` decorator for proxies - */ -export function type (type: DefinitionType, context: Context = globalContext): PropertyDecorator { - return function (target: typeof Schema, field: string) { - if (!type) { - throw new Error("Type not found. Ensure your `@type` annotations are correct and that you don't have any circular dependencies."); - } - - const constructor = target.constructor as typeof Schema; - constructor._context = context; - - /* - * static schema - */ - if (!context.has(constructor)) { - context.add(constructor); - } - - const definition = constructor._definition; - definition.addField(field, type); - - /** - * skip if descriptor already exists for this field (`@deprecated()`) - */ - if (definition.descriptors[field]) { - if (definition.deprecated[field]) { - // do not create accessors for deprecated properties. - return; - - } else { - // trying to define same property multiple times across inheritance. - // https://github.com/colyseus/colyseus-unity3d/issues/131#issuecomment-814308572 - try { - throw new Error(`@colyseus/schema: Duplicate '${field}' definition on '${constructor.name}'.\nCheck @type() annotation`); - - } catch (e) { - const definitionAtLine = e.stack.split("\n")[4].trim(); - throw new Error(`${e.message} ${definitionAtLine}`); - } - } - } - - const isArray = ArraySchema.is(type); - const isMap = !isArray && MapSchema.is(type); - - // TODO: refactor me. - // Allow abstract intermediary classes with no fields to be serialized - // (See "should support an inheritance with a Schema type without fields" test) - if (typeof (type) !== "string" && !Schema.is(type)) { - const childType = Object.values(type)[0]; - if (typeof (childType) !== "string" && !context.has(childType)) { - context.add(childType); - } - } - - const fieldCached = `_${field}`; - - definition.descriptors[fieldCached] = { - enumerable: false, - configurable: false, - writable: true, - }; - - definition.descriptors[field] = { - get: function () { - return this[fieldCached]; - }, - - set: function (this: Schema, value: any) { - /** - * Create Proxy for array or map items - */ - - // skip if value is the same as cached. - if (value === this[fieldCached]) { - return; - } - - if ( - value !== undefined && - value !== null - ) { - // automaticallty transform Array into ArraySchema - if (isArray && !(value instanceof ArraySchema)) { - value = new ArraySchema(...value); - } - - // automaticallty transform Map into MapSchema - if (isMap && !(value instanceof MapSchema)) { - value = new MapSchema(value); - } - - // try to turn provided structure into a Proxy - if (value['$proxy'] === undefined) { - if (isMap) { - value = getMapProxy(value); - - } else if (isArray) { - value = getArrayProxy(value); - } - } - - // flag the change for encoding. - this.$changes.change(field); - - // - // call setParent() recursively for this and its child - // structures. - // - if (value['$changes']) { - (value['$changes'] as ChangeTree).setParent( - this, - this.$changes.root, - this._definition.indexes[field], - ); - } - - } else { - // - // Setting a field to `null` or `undefined` will delete it. - // - this.$changes.delete(field); - } - - this[fieldCached] = value; - }, - - enumerable: true, - configurable: true - }; - } -} - -/** - * `@filter()` decorator for defining data filters per client - */ - -export function filter(cb: FilterCallback): PropertyDecorator { - return function (target: any, field: string) { - const constructor = target.constructor as typeof Schema; - const definition = constructor._definition; - - if (definition.addFilter(field, cb)) { - constructor._context.useFilters = true; - } - } -} - -export function filterChildren(cb: FilterChildrenCallback): PropertyDecorator { - return function (target: any, field: string) { - const constructor = target.constructor as typeof Schema; - const definition = constructor._definition; - if (definition.addChildrenFilter(field, cb)) { - constructor._context.useFilters = true; - } - } -} - - -/** - * `@deprecated()` flag a field as deprecated. - * The previous `@type()` annotation should remain along with this one. - */ - -export function deprecated(throws: boolean = true, context: Context = globalContext): PropertyDecorator { - return function (target: typeof Schema, field: string) { - const constructor = target.constructor as typeof Schema; - const definition = constructor._definition; - - definition.deprecated[field] = true; - - if (throws) { - definition.descriptors[field] = { - get: function () { throw new Error(`${field} is deprecated.`); }, - set: function (this: Schema, value: any) { /* throw new Error(`${field} is deprecated.`); */ }, - enumerable: false, - configurable: true - }; - } - } -} - -type DefinitionTypeOptionsBase = { - type: T extends Map ? { - [prop in keyof typeof Map]: typeof Map[prop]; - } : - T extends Set ? { - [prop in keyof typeof Set]: typeof Set[prop]; - } : - T; - of?: PrimitiveType; - filter?: FilterCallback; - filterChildren?: FilterChildrenCallback; -} - -export type DefinitionTypeOptions = DefinitionTypeOptionsBase - | DefinitionTypeOptionsBase<[PrimitiveType], S, R, P, K, V> - | DefinitionTypeOptionsBase - | DefinitionTypeOptionsBase - -type PropertyDefinitionOptions = { - type: DefinitionType; - filter?: FilterCallback; - filterChildren?: FilterChildrenCallback; -} - -function isPrimitive(prop: any) { - return (typeof prop === "string" || Schema.isPrototypeOf(prop)); -} - -export function defineTypes( - target: typeof Schema, - fields: { - [property: string]: DefinitionType | DefinitionTypeOptions - }, - context: Context = target._context || globalContext -) { - for (let propertyName in fields) { - const propertyOptions: PropertyDefinitionOptions = (() => { - const prop = fields[propertyName]; - if (isPrimitive(prop) || Array.isArray(prop) || prop["type"] === null) return { - type: prop as DefinitionType - }; - - const options = prop as DefinitionTypeOptions; - const result = { - type: undefined, - filter: options.filter, - filterChildren: options.filterChildren - } - if (isPrimitive(options["type"]) || Array.isArray(options["type"])) { - result.type = options["type"]; - } else if (Map.isPrototypeOf(options["type"])) { - result.type = { map: options["of"] }; - } else if (Set.isPrototypeOf(options["type"])) { - result.type = { set: options["of"] }; - } - return result; - })(); - type(propertyOptions.type, context)(target.prototype, propertyName); - if (propertyOptions.filter) filter(propertyOptions.filter)(target.prototype, propertyName) - if (propertyOptions.filterChildren) filterChildren(propertyOptions.filter)(target.prototype, propertyName) - } - return target; -} diff --git a/src/annotations/Context.ts b/src/annotations/Context.ts new file mode 100644 index 00000000..12d9bc1d --- /dev/null +++ b/src/annotations/Context.ts @@ -0,0 +1,37 @@ +import { DefinitionType, SchemaDefinition, type } from '.'; +import { Schema } from '../Schema'; + +// Colyseus integration +export type ClientWithSessionId = { sessionId: string } & any; + +export class Context { + types: {[id: number]: typeof Schema} = {}; + schemas = new Map(); + useFilters = false; + + has(schema: typeof Schema) { + return this.schemas.has(schema); + } + + get(typeid: number) { + return this.types[typeid]; + } + + add(schema: typeof Schema, typeid: number = this.schemas.size) { + // FIXME: move this to somewhere else? + // support inheritance + schema._definition = SchemaDefinition.create(schema._definition); + + schema._typeid = typeid; + this.types[typeid] = schema; + this.schemas.set(schema, typeid); + } + + static create(context: Context = new Context) { + return function (definition: DefinitionType) { + return type(definition, context); + } + } +} + +export const globalContext = new Context(); \ No newline at end of file diff --git a/src/annotations/SchemaDefinition.ts b/src/annotations/SchemaDefinition.ts new file mode 100644 index 00000000..6ef260fe --- /dev/null +++ b/src/annotations/SchemaDefinition.ts @@ -0,0 +1,105 @@ +import { FilterCallback, FilterChildrenCallback } from '.'; +import { getType } from '../types'; +import { Schema } from '../Schema'; + +/** + * Data Types + */ + +export type PrimitiveType = + "string" | + "number" | + "boolean" | + "int8" | + "uint8" | + "int16" | + "uint16" | + "int32" | + "uint32" | + "int64" | + "uint64" | + "float32" | + "float64" | + typeof Schema; + +export type DefinitionType = PrimitiveType + | [PrimitiveType] + | { array: PrimitiveType } + | { map: PrimitiveType } + | { collection: PrimitiveType } + | { set: PrimitiveType }; + +export type Definition = { [field: string]: DefinitionType }; + +export class SchemaDefinition { + schema: Definition; + + // + // TODO: use a "field" structure combining all these properties per-field. + // + + indexes: { [field: string]: number } = {}; + fieldsByIndex: { [index: number]: string } = {}; + + filters: { [field: string]: FilterCallback }; + indexesWithFilters: number[]; + childFilters: { [field: string]: FilterChildrenCallback }; // childFilters are used on Map, Array, Set items. + + deprecated: { [field: string]: boolean } = {}; + descriptors: PropertyDescriptorMap & ThisType = {}; + + static create(parent?: SchemaDefinition) { + const definition = new SchemaDefinition(); + + // support inheritance + definition.schema = Object.assign({}, parent && parent.schema || {}); + definition.indexes = Object.assign({}, parent && parent.indexes || {}); + definition.fieldsByIndex = Object.assign({}, parent && parent.fieldsByIndex || {}); + definition.descriptors = Object.assign({}, parent && parent.descriptors || {}); + definition.deprecated = Object.assign({}, parent && parent.deprecated || {}); + + return definition; + } + + addField(field: string, type: DefinitionType) { + const index = this.getNextFieldIndex(); + this.fieldsByIndex[index] = field; + this.indexes[field] = index; + this.schema[field] = (Array.isArray(type)) + ? { array: type[0] } + : type; + } + + addFilter(field: string, cb: FilterCallback) { + if (!this.filters) { + this.filters = {}; + this.indexesWithFilters = []; + } + this.filters[this.indexes[field]] = cb; + this.indexesWithFilters.push(this.indexes[field]); + return true; + } + + addChildrenFilter(field: string, cb: FilterChildrenCallback) { + const index = this.indexes[field]; + const type = this.schema[field]; + + if (getType(Object.keys(type)[0])) { + if (!this.childFilters) { this.childFilters = {}; } + + this.childFilters[index] = cb; + return true; + + } else { + console.warn(`@filterChildren: field '${field}' can't have children. Ignoring filter.`); + } + } + + getChildrenFilter(field: string) { + return this.childFilters && this.childFilters[this.indexes[field]]; + } + + getNextFieldIndex() { + return Object.keys(this.schema || {}).length; + } +} \ No newline at end of file diff --git a/src/annotations/defineTypes.ts b/src/annotations/defineTypes.ts new file mode 100644 index 00000000..054bcab4 --- /dev/null +++ b/src/annotations/defineTypes.ts @@ -0,0 +1,33 @@ +import { DefinitionType, filter, FilterCallback, filterChildren, FilterChildrenCallback, type } from '.'; +import { Schema } from '../Schema'; +import { ArraySchema } from '../types/ArraySchema'; +import { MapSchema } from '../types/MapSchema'; +import { Context, globalContext } from './Context'; + +export type DefinitionTypeOptions = { + type: DefinitionType; + filter?: FilterCallback; + filterChildren?: FilterChildrenCallback; +} + +const isPrimitiveType = (type: any) => typeof type === "string" || Schema.is(type); +const isDefinitionType = (type: any): type is DefinitionType => isPrimitiveType(type) || ArraySchema.is(type) || MapSchema.is(type) || type["type"] == null; +export function defineTypes( + target: typeof Schema, + fields: { + [property: string]: DefinitionType | DefinitionTypeOptions + }, + context: Context = target._context || globalContext +) { + for (let fieldName in fields) { + const field = fields[fieldName]; + if (isDefinitionType(field)) { + type(field, context)(target.prototype, fieldName); + continue; + } + type(field.type, context)(target.prototype, fieldName); + if (field.filter) filter(field.filter)(target.prototype, fieldName) + if (field.filterChildren) filterChildren(field.filter)(target.prototype, fieldName) + } + return target; +} \ No newline at end of file diff --git a/src/annotations/index.ts b/src/annotations/index.ts new file mode 100644 index 00000000..ca41e011 --- /dev/null +++ b/src/annotations/index.ts @@ -0,0 +1,217 @@ +import { ClientWithSessionId, Context, globalContext } from './Context'; +import { Schema } from '../Schema'; +import { DefinitionType, SchemaDefinition } from './SchemaDefinition'; +import { ArraySchema, getArrayProxy } from '../types/ArraySchema'; +import { getMapProxy, MapSchema } from '../types/MapSchema'; +import { ChangeTree } from '../changes/ChangeTree'; + + +export type FilterCallback< + T extends Schema = any, + V = any, + R extends Schema = any +> = ( + ((this: T, client: ClientWithSessionId, value: V) => boolean) | + ((this: T, client: ClientWithSessionId, value: V, root: R) => boolean) +); + +export type FilterChildrenCallback< + T extends Schema = any, + K = any, + V = any, + R extends Schema = any +> = ( + ((this: T, client: ClientWithSessionId, key: K, value: V) => boolean) | + ((this: T, client: ClientWithSessionId, key: K, value: V, root: R) => boolean) +); + +/** + * `@type()` decorator for proxies + */ +export function type (type: DefinitionType, context: Context = globalContext): PropertyDecorator { + return function (target: typeof Schema, field: string) { + if (!type) { + throw new Error("Type not found. Ensure your `@type` annotations are correct and that you don't have any circular dependencies."); + } + + const constructor = target.constructor as typeof Schema; + constructor._context = context; + + /* + * static schema + */ + if (!context.has(constructor)) { + context.add(constructor); + } + + const definition = constructor._definition; + definition.addField(field, type); + + /** + * skip if descriptor already exists for this field (`@deprecated()`) + */ + if (definition.descriptors[field]) { + if (definition.deprecated[field]) { + // do not create accessors for deprecated properties. + return; + + } else { + // trying to define same property multiple times across inheritance. + // https://github.com/colyseus/colyseus-unity3d/issues/131#issuecomment-814308572 + try { + throw new Error(`@colyseus/schema: Duplicate '${field}' definition on '${constructor.name}'.\nCheck @type() annotation`); + + } catch (e) { + const definitionAtLine = e.stack.split("\n")[4].trim(); + throw new Error(`${e.message} ${definitionAtLine}`); + } + } + } + + const isArray = ArraySchema.is(type); + const isMap = !isArray && MapSchema.is(type); + + // TODO: refactor me. + // Allow abstract intermediary classes with no fields to be serialized + // (See "should support an inheritance with a Schema type without fields" test) + if (typeof (type) !== "string" && !Schema.is(type)) { + const childType = Object.values(type)[0]; + if (typeof (childType) !== "string" && !context.has(childType)) { + context.add(childType); + } + } + + const fieldCached = `_${field}`; + + definition.descriptors[fieldCached] = { + enumerable: false, + configurable: false, + writable: true, + }; + + definition.descriptors[field] = { + get: function () { + return this[fieldCached]; + }, + + set: function (this: Schema, value: any) { + /** + * Create Proxy for array or map items + */ + + // skip if value is the same as cached. + if (value === this[fieldCached]) { + return; + } + + if ( + value !== undefined && + value !== null + ) { + // automaticallty transform Array into ArraySchema + if (isArray && !(value instanceof ArraySchema)) { + value = new ArraySchema(...value); + } + + // automaticallty transform Map into MapSchema + if (isMap && !(value instanceof MapSchema)) { + value = new MapSchema(value); + } + + // try to turn provided structure into a Proxy + if (value['$proxy'] === undefined) { + if (isMap) { + value = getMapProxy(value); + + } else if (isArray) { + value = getArrayProxy(value); + } + } + + // flag the change for encoding. + this.$changes.change(field); + + // + // call setParent() recursively for this and its child + // structures. + // + if (value['$changes']) { + (value['$changes'] as ChangeTree).setParent( + this, + this.$changes.root, + this._definition.indexes[field], + ); + } + + } else { + // + // Setting a field to `null` or `undefined` will delete it. + // + this.$changes.delete(field); + } + + this[fieldCached] = value; + }, + + enumerable: true, + configurable: true + }; + } +} + +/** + * `@deprecated()` flag a field as deprecated. + * The previous `@type()` annotation should remain along with this one. + */ + +export function deprecated(throws: boolean = true, context: Context = globalContext): PropertyDecorator { + return function (target: typeof Schema, field: string) { + const constructor = target.constructor as typeof Schema; + const definition = constructor._definition; + + definition.deprecated[field] = true; + + if (throws) { + definition.descriptors[field] = { + get: function () { throw new Error(`${field} is deprecated.`); }, + set: function (this: Schema, value: any) { /* throw new Error(`${field} is deprecated.`); */ }, + enumerable: false, + configurable: true + }; + } + } +} + +export function hasFilter(klass: typeof Schema) { + return klass._context && klass._context.useFilters; +} + +function applyFilter(addFilter:(definition: SchemaDefinition, field: string) => boolean) { + return function (target: typeof Schema, field: string) { + const constructor = target.constructor as typeof Schema; + const definition = constructor._definition; + if (addFilter(definition, field)) { + constructor._context.useFilters = true; + } + } +} + +/** + * `@filter()` decorator for defining data filters per client + */ + +export function filter(cb: FilterCallback): PropertyDecorator { + return applyFilter((definition, field) => definition.addFilter(field, cb)) +} + +/** + * `@filterChildren()` decorator for defining data filters per client + */ + +export function filterChildren(cb: FilterChildrenCallback): PropertyDecorator { + return applyFilter((definition, field) => definition.addChildrenFilter(field, cb)) +} + +export { ClientWithSessionId, Context, globalContext } from "./Context"; +export { SchemaDefinition, Definition, DefinitionType, PrimitiveType } from "./SchemaDefinition"; +export { DefinitionTypeOptions, defineTypes } from "./defineTypes"; diff --git a/src/index.ts b/src/index.ts index 60b207d8..869b23a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,14 +46,16 @@ export { hasFilter, // Internals - SchemaDefinition, + SchemaDefinition, // Types Context, PrimitiveType, Definition, DefinitionType, + DefinitionTypeOptions, FilterCallback, + FilterChildrenCallback, } from "./annotations"; export { OPERATION } from "./spec"; \ No newline at end of file