Skip to content

Commit

Permalink
refactor(form-models): improve code
Browse files Browse the repository at this point in the history
  • Loading branch information
Lodin committed Jun 15, 2024
1 parent c150f74 commit b64f0f6
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 275 deletions.
388 changes: 346 additions & 42 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions packages/ts/form-models/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"build:esbuild": "tsx ../../../scripts/build.ts",
"build:dts": "tsc --isolatedModules -p tsconfig.build.json",
"build:copy": "cd src_ && copyfiles **/*.d.ts ..",
"lint": "eslint src_ test",
"lint:fix": "eslint src_ test --fix",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"test": "mocha test/**/*.spec.ts --config ../../../.mocharc.cjs",
"test:coverage": "npm run test -- --coverage",
"test:watch": "npm run test -- --watch",
Expand Down Expand Up @@ -60,12 +60,15 @@
"@types/chai-dom": "^1.11.1",
"@types/chai-like": "^1.1.3",
"@types/mocha": "^10.0.2",
"@types/node": "^20.14.2",
"@types/react": "^18.2.23",
"@types/sinon": "^10.0.17",
"@types/sinon-chai": "^3.2.10",
"@types/validator": "^13.11.2",
"chai-as-promised": "^7.1.1",
"chai-dom": "^1.11.0",
"glob": "^10.4.1",
"mocha": "^10.4.0",
"sinon": "^16.0.0",
"sinon-chai": "^3.7.0",
"typescript": "5.3.2"
Expand Down
94 changes: 52 additions & 42 deletions packages/ts/form-models/src/builders.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ObjectModel } from './core';
import {
$defaultValue,
$key,
$meta,
$name,
$owner,
type DefaultValueProvider,
type EmptyRecord,
type ExtendedModel,
type Model,
type ModelMetadata,
} from './model.js';
Expand All @@ -14,24 +15,25 @@ export type ModelBuilderPropertyOptions = Readonly<{
meta?: ModelMetadata;
}>;

const $base = Symbol();
const $properties = Symbol();
const $model = Symbol();

export class CoreModelBuilder<T, C extends object = EmptyRecord> {
static from<T>(base: ExtendedModel, defaultValueProvider?: () => T): CoreModelBuilder<T> {
return new CoreModelBuilder(base, defaultValueProvider);
}
declare const $named: unique symbol;

export type NamedModelBuilder = Readonly<{ [$named]: true }>;

protected readonly [$base]: ExtendedModel;
protected readonly [$properties]: Record<keyof any, PropertyDescriptor> = {};
export class CoreModelBuilder<T, C extends object = EmptyRecord, N extends boolean = false> {
declare readonly [$named]: N;
protected readonly [$model]: C & Model<T>;

protected constructor(base: ExtendedModel, defaultValueProvider?: () => T) {
this[$base] = base;
constructor(base: Model, defaultValueProvider?: DefaultValueProvider<T, C>) {
this[$model] = Object.create(base);

if (defaultValueProvider) {
this[$properties].defaultValue = {
get: defaultValueProvider,
};
Object.defineProperty(this[$model], $defaultValue, {
get(this: C & Model<T>) {
return defaultValueProvider(this);
},
});
}
}

Expand All @@ -40,61 +42,69 @@ export class CoreModelBuilder<T, C extends object = EmptyRecord> {
return this;
}

define<K extends symbol, V>(key: K, value: V): CoreModelBuilder<T, C & Record<K, V>> {
this[$properties][key] = { value };
return this as CoreModelBuilder<T, C & Record<K, V>>;
define<K extends symbol, V>(key: K, value: V): CoreModelBuilder<T, C & Readonly<Record<K, V>>, N> {
Object.defineProperty(this[$model], key, { value });
return this as CoreModelBuilder<T, C & Readonly<Record<K, V>>, N>;
}

name(name: string): this {
name(name: string): CoreModelBuilder<T, C, true> {
this.define($name, name);
return this;
return this as CoreModelBuilder<T, C, true>;
}

build(): ExtendedModel<T, C> {
return Object.create(this[$base], this[$properties]);
build(): this extends NamedModelBuilder ? C & Model<T> : never {
return this[$model] as this extends NamedModelBuilder ? C & Model<T> : never;
}
}

export type ModelProvider<T extends object = Record<keyof any, unknown>, K extends keyof T = keyof T> = (
model: Model<T>,
) => Model<T[K]>;

export class ObjectModelBuilder<
T extends object,
U extends object = object,
C extends object = EmptyRecord,
> extends CoreModelBuilder<T, C> {
static extend<T extends object, U extends object = object>(base: ExtendedModel): ObjectModelBuilder<T, U> {
return new ObjectModelBuilder<T, U>(base);
}

protected constructor(base: ExtendedModel) {
N extends boolean = false,
> extends CoreModelBuilder<T, C, N> {
constructor(base: Model) {
super(
base,
() =>
(m) =>
Object.fromEntries(
Object.entries(this[$properties]).map(
([key, descriptor]) => [key, (descriptor.value as Model)[$defaultValue]] as const,
),
) as T,
(Object.entries(m) as ReadonlyArray<readonly [string, Model<T[keyof T]>]>).map(([key, child]) => [
key,
child[$defaultValue],
]),
) as ReturnType<DefaultValueProvider<T, C>>,
);
}

declare ['build']: () => U extends T ? ExtendedModel<T, C> : never;
declare ['define']: <K extends symbol, V>(key: K, value: V) => ObjectModelBuilder<T, U, C & Readonly<Record<K, V>>>;
declare ['name']: (name: string) => this;
declare ['build']: () => this extends NamedModelBuilder ? (U extends T ? C & ObjectModel<T> : never) : never;

declare ['define']: <K extends symbol, V>(
key: K,
value: V,
) => ObjectModelBuilder<T, U, C & Readonly<Record<K, V>>, N>;

declare ['name']: <NT extends object>(name: string) => ObjectModelBuilder<NT, U, C, true>;

declare ['meta']: (value: ModelMetadata) => this;

property<K extends keyof T>(
key: K,
model: ExtendedModel<T[K]>,
model: Model<T[K]> | ModelProvider<T, K>,
options?: ModelBuilderPropertyOptions,
): ObjectModelBuilder<T, Readonly<Record<K, T[K]>> & U, C> {
this[$properties][key] = {
): ObjectModelBuilder<T, Readonly<Record<K, T[K]>> & U, C, N> {
Object.defineProperty(this[$model], key, {
enumerable: true,
value: ObjectModelBuilder.extend(model)
value: new CoreModelBuilder(typeof model === 'function' ? model(this[$model]) : model)
.define($key, key)
.define($owner, this)
.define($owner, this[$model])
.define($meta, options?.meta)
.build(),
};
});

return this as ObjectModelBuilder<T, U, C & Readonly<Record<K, T[K]>>>;
return this as ObjectModelBuilder<T, Readonly<Record<K, T[K]>> & U, C, N>;
}
}
43 changes: 25 additions & 18 deletions packages/ts/form-models/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,42 @@
import { CoreModelBuilder } from './builders.js';
import { $enum, $itemModel, type Enum, Model } from './model.js';
import { $enum, $itemModel, type $members, type Enum, Model } from './model.js';

export const PrimitiveModel = CoreModelBuilder.from(Model, (): unknown => undefined)
.name('primitive')
.build();
export const PrimitiveModel = new CoreModelBuilder(Model, (): unknown => undefined).name('primitive').build();

export const StringModel = CoreModelBuilder.from(PrimitiveModel, () => '')
.name('string')
.build();
export const StringModel = new CoreModelBuilder(PrimitiveModel, () => '').name('string').build();

export const NumberModel = CoreModelBuilder.from(PrimitiveModel, () => 0)
.name('number')
.build();
export const NumberModel = new CoreModelBuilder(PrimitiveModel, () => 0).name('number').build();

export const BooleanModel = CoreModelBuilder.from(PrimitiveModel, () => false)
.name('boolean')
.build();
export const BooleanModel = new CoreModelBuilder(PrimitiveModel, () => false).name('boolean').build();

export interface ArrayModel<T> extends Model<T[]> {
readonly [$itemModel]: Model<T>;
}

export const ArrayModel = CoreModelBuilder.from(Model, (): unknown[] => [])
export const ArrayModel = new CoreModelBuilder(Model, (): unknown[] => [])
.name('Array')
.define($itemModel, Model)
.build();

export const ObjectModel = CoreModelBuilder.from(Model, () => ({}))
.name('Object')
.build();
export type ObjectModel<T extends object = object> = Model<T> &
Readonly<{
[K in keyof T]: Model<T[K]>;
}>;

export const ObjectModel = new CoreModelBuilder(Model, (): object => ({})).name('Object').build();

export const EnumModel = CoreModelBuilder.from(
export interface EnumModel<T extends typeof Enum> extends Model<T[keyof T]> {
readonly [$enum]: T;
}

export const EnumModel = new CoreModelBuilder<typeof Enum>(
Model,
(): (typeof Enum)[keyof typeof Enum] => Object.values(EnumModel[$enum])[0],
)
.name('Enum')
.define($enum, {} as typeof Enum)
.build();

export interface UnionModel<TT extends unknown[]> extends Model<TT[number]> {
readonly [$members]: ReadonlyArray<Model<TT[number]>>;
}
46 changes: 46 additions & 0 deletions packages/ts/form-models/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { CoreModelBuilder, ObjectModelBuilder } from './builders.js';
import { ArrayModel, EnumModel, ObjectModel, type UnionModel } from './core.js';
import {
$defaultValue,
$enum,
$itemModel,
$members,
$name,
$optional,
type EmptyRecord,
type Enum,
Model,
type ModelValue,
} from './model.js';

export * from './model.js';
export * from './core.js';

const m = {
extend<SU extends object>(base: Model<SU>): ObjectModelBuilder<object, SU> {
return new ObjectModelBuilder(base);
},
optional<T>(base: Model): Model<T | undefined> {
return new CoreModelBuilder<T | undefined>(base).define($optional, true).build();
},
array<T>(itemModel: Model<T>): ArrayModel<T> {
return new CoreModelBuilder<T[]>(ArrayModel)
.name(`Array<${itemModel[$name]}>`)
.define($itemModel, itemModel)
.build();
},
object<T extends object>(name: string): ObjectModelBuilder<T, object, EmptyRecord, true> {
return m.extend(ObjectModel).name<T>(name);
},
enum<T extends typeof Enum>(obj: T, name: string): EnumModel<T> {
return new CoreModelBuilder<T[keyof T]>(EnumModel).define($enum, obj).name(name).build();
},
union<TT extends unknown[]>(...members: ReadonlyArray<Model<TT[number]>>): UnionModel<TT> {
return new CoreModelBuilder(Model, () => members[0][$defaultValue] as ModelValue<TT[number]>)
.name(members.map((model) => model.constructor.name).join(' | '))
.define($members, members)
.build();
},
};

export default m;
43 changes: 0 additions & 43 deletions packages/ts/form-models/src/m.ts

This file was deleted.

Loading

0 comments on commit b64f0f6

Please sign in to comment.