import type { SDK } from '../sdk.js' import SDKAdapter from './adapter.js' import { SDKCollection } from './collection.js' import { assignDeep, isDeepEquals, mergeDeep } from '../utils/object.js' import { SDKModelSchema } from './schema.js' import { Version } from '../models/versions.js' export type ClassExtending = new (...args: any[]) => T export type SDKModelSubclass = (new (...args: any[]) => SDKModel) & { [K in keyof typeof SDKModel]: (typeof SDKModel)[K] } export class SDKModel = T, TImplicit extends Partial = Partial> { sdk: SDK static schema: SDKModelSchema get adapter(): SDKAdapter { throw new Error('Adapter not defined') } /** * Create instance of a model. If no data is provided, it's just empty instance. Otherwise data needs to contain all * the required fields, and the model will be considered "new". Usually it is a better idea to instantiate models via * collections, as it provides necessary implicit attributes. */ constructor(data?: TMinimal & TImplicit) { this.defineProperties() if (data) { this.new(data) } } new(data: TMinimal & Partial) { if (data != null) { this.setDefaults().set(data) this.isNew = true } return this } /** * To ensure that models look as plain objects all extra properties and method needs to be hidden through the use of * `enumerable: false` descriptors. */ defineProperties() { Object.defineProperty(this, 'observers', { enumerable: false, value: [] }) Object.defineProperty(this, 'snapshotted', { enumerable: false, writable: true }) this.sdk.utils.defineAccessor(this, 'isNew', { enumerable: false }, this.getHiddenProps()) this.getHiddenProps().map((prop) => { Object.defineProperty(this, prop, { enumerable: false, writable: true, configurable: true }) }, this) this.onConstruct() } /** Constructor hook for subclasses */ onConstruct() {} getHiddenProps(): string[] { return [] } getDefaults(sdk: SDK): any { return {} } setDefaults() { const values = this.getDefaults(this.sdk) for (var property in values) { if (this[property as keyof this] === undefined && values[property] !== undefined) { this[property as keyof this] = values[property] } } return this } /** Assign values from given object */ set(data: Partial): this { var changed = false var property: keyof typeof data for (property in data) { /** * @ts-ignore fix me* */ if (this[property as keyof typeof this] !== data[property]) { changed = true } } Object.assign(this, data) if (changed) { this.onChange() } return this } merge(data: Partial): this { return assignDeep(this, data) as this } copyHiddenProps(data: any, overwrite?: boolean) { this.getHiddenProps().map((prop) => { const current = this[prop as keyof this] if ( overwrite || typeof current == 'undefined' || (current && current instanceof SDKCollection) || (current && current instanceof SDKModel) ) this[prop as keyof this] = data[prop] }) if (data.snapshotted) this.snapshotted = data.snapshotted this.isNew = data.isNew return this } /** Assign values and hidden properties from given object */ assign(data: Partial) { this.set(data) if (data && data instanceof this.constructor) { this.copyHiddenProps(data, true) } return this } /** Set previously saved valeus to the model, returns clone */ populate(data: Partial): this { const changed = this.set(data) changed.isNew = false return changed } /** Return clone if any of given values are different */ change(data: Partial) { var property: keyof typeof data for (property in data) { const left = this[property as keyof this] as any const right = data[property] as any if (!this.compareProp(property, left, right)) { return this.clone(data) break } } return this } put(data?: Partial): Promise { this.set(data) return this.adapter.put(this as this & T).then((data) => this.populate(data)) } /* Upsert model based on its isNew flag - either update or create it */ save(data?: Partial): Promise { this.set(data) this.setDefaults() if (this.isNew) { return this.post() } else { return this.put() } } update(data?: Partial): Promise { return this.put(data) } post(data?: Partial): Promise { this.set(data) return this.adapter.post(this as this & T).then((data) => this.populate(data)) } insert(data?: Partial): Promise { return this.post(data) } delete(): Promise { if (!this.isNew) { return this.adapter.delete(this as this & T).then((data) => this.populate(data)) } return Promise.resolve(this) } get(): Promise { return this.adapter.get(this as this & T).then((data) => this.populate(data)) } fetch(options?: any): Promise { return this.adapter.fetch(this.clone(options) as this & T).then((collection) => { return collection.map((data) => this.construct(data)) }) } select(options?: any): Promise { return this.fetch(options) } search(query?: any) { return this.adapter .search(Object.assign(this.export(), query)) .then((collection) => collection.map((data) => this.construct(data))) } /** Create new instance of this class */ construct(data?: Partial) { const instance = Object.create(Object.getPrototypeOf(this)) as this instance.defineProperties() return instance.set(data) } /** Clone instance retaining data & hidden properties */ clone(data?: Partial) { const clone = this.construct() Object.assign(clone, this) if (data) { clone.set(data) } clone.copyHiddenProps(this) return clone } /** Returns plain object without any descriptors or prototype */ export(): T { const object: any = {} for (var property in this) { if (this.hasOwnProperty(property)) object[property] = this[property] } return object as T } serialize() { return this.export() } /** Clone mode, and assign its initial values as `snappshoted` property */ snapshotted?: T snapshot() { this.snapshotted = this.export() return this } /** Default sorting logic */ sortCompare(other: this) { return 0 } /** Get object's id */ id: string idStruct: | { id: string } | { details: { id: string } } | { id: string revision: number status: Version['status'] } getId() { return this.id } /** Returns object that has fresh identity but references same collection */ /** Useful for shallow equality checked deps */ proxy(): this { return new Proxy(this, { get(target: any, prop: string | Symbol) { if (prop == Symbol.toStringTag) { return target[Symbol.toStringTag] + 'Proxy' } return target[prop as keyof typeof target] } }) as this } onChange() { if (this.observers?.length) this.observers?.forEach((observer) => observer.call(this, this), this) } observers: ((...args: any) => any)[] observe(observer: (model: this) => any) { ;(this.observers ||= []).push(observer) } unobserve(observer: (model: this) => any) { const index = this.observers.indexOf(observer) if (index > -1) this.observers.splice(index, 1) } /** Hook to redefine new instance detection */ _isNew: boolean get isNew() { return this._isNew || false } set isNew(value: boolean) { this._isNew = value } /** Compares changes */ isEqual(other: this, ignoreProps: string[] = ['modifiedAt', 'createdAt', 'sampledAt']) { return isDeepEquals(this, other, ignoreProps) } compareProp(property: keyof this, value1: any, value2: any) { return value1 && value1 instanceof Date && value2 && value2 instanceof Date ? Number(value1) == Number(value2) : value1 == value2 || false } static onCollectionChange(collection: any[]) { return collection.sort((a, b) => a.sortCompare(b)) } /** Apply normalization on style */ static transformAttributes(object: any) { return object } } SDKModel.prototype.select = SDKModel.prototype.fetch