Skip to content

Commit

Permalink
refactor(model): improve optional method & add isOptional checker…
Browse files Browse the repository at this point in the history
… & improve validators
  • Loading branch information
Lodin committed Aug 5, 2024
1 parent 5e5779c commit bc66791
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 181 deletions.
4 changes: 0 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions packages/ts/models/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"test": "karma start ../../../karma.config.cjs",
"test:coverage": "c8 --experimental-monocart -c ../../../.c8rc.json npm test",
"test:coverage": "npm run test -- --coverage",
"test:watch": "npm run test -- --watch",
"typecheck": "tsc --noEmit"
},
Expand Down Expand Up @@ -64,19 +64,15 @@
"@types/chai-as-promised": "^7.1.8",
"@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",
"c8": "^10.1.2",
"chai-as-promised": "^7.1.1",
"chai-dom": "^1.11.0",
"glob": "^10.4.1",
"karma": "^6.4.3",
"mocha": "^10.4.0",
"monocart-coverage-reports": "^2.8.4",
"sinon": "^16.0.0",
"sinon-chai": "^3.7.0",
"typescript": "5.5.2"
Expand Down
8 changes: 5 additions & 3 deletions packages/ts/models/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export class CoreModelBuilder<
key: DK,
value: TypedPropertyDescriptor<DV>,
): CoreModelBuilder<V, DK extends keyof Model ? EX : EX & Readonly<Record<DK, DV>>, F> {
return defineProperty(this[$model], key, value) as any;
defineProperty(this[$model], key, value);
return this as any;
}

/**
Expand Down Expand Up @@ -287,7 +288,7 @@ export class ObjectModelBuilder<
selfRefKeys: F['selfRefKeys'] | PK;
}
> {
return defineProperty(this[$model], key, {
defineProperty(this[$model], key, {
enumerable: true,
get(this: Model<V, EX & Readonly<Record<PK, Model<V[PK], EXK>>>>) {
if (!propertyRegistry.has(this)) {
Expand All @@ -310,7 +311,8 @@ export class ObjectModelBuilder<

return props[key];
},
}) as any;
});
return this as any;
}

/**
Expand Down
25 changes: 20 additions & 5 deletions packages/ts/models/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import type { EmptyObject } from 'type-fest';
import { CoreModelBuilder } from './builders.js';
import { $enum, $itemModel, type $members, type AnyObject, type Enum, Model, type Value } from './model.js';
import {
type $defaultValue,
$enum,
$itemModel,
type $members,
type $optional,
type AnyObject,
type Enum,
Model,
type Value,
} from './model.js';

/* eslint-disable tsdoc/syntax */

Expand All @@ -14,11 +24,11 @@ export const $parse = Symbol('parse');
/**
* The model of a primitive value, like `string`, `number` or `boolean`.
*/
export type PrimitiveModel<V = unknown> = Model<V, Readonly<{ [$parse](value: string): V }>>;
export type PrimitiveModel<V = unknown> = Model<V, Readonly<{ [$parse](value: unknown): V }>>;
export const PrimitiveModel = CoreModelBuilder.create(Model, (): unknown => undefined)
.name('primitive')
.define($parse, {
value: (value: string) => value,
value: (value: unknown) => String(value),
})
.build();

Expand All @@ -37,7 +47,7 @@ export type NumberModel = PrimitiveModel<number>;
export const NumberModel = CoreModelBuilder.create(PrimitiveModel, () => 0)
.name('number')
.define($parse, {
value: (value: string) => Number(value),
value: (value: unknown) => Number(String(value)),
})
.build();

Expand All @@ -48,7 +58,7 @@ export type BooleanModel = PrimitiveModel<boolean>;
export const BooleanModel = CoreModelBuilder.create(PrimitiveModel, () => false)
.name('boolean')
.define($parse, {
value: (value: string) => value !== '',
value: (value: unknown) => String(value) !== '',
})
.build();

Expand Down Expand Up @@ -99,6 +109,11 @@ export const EnumModel = CoreModelBuilder.create(Model)
.defaultValueProvider((self) => Object.values(self[$enum])[0])
.build();

export type OptionalModel<T = unknown, EX extends AnyObject = EmptyObject, R extends string = never> =
Extract<T, undefined> extends never
? Model<T, EX, R>
: Model<T, EX & Readonly<{ [$defaultValue]: undefined; [$optional]: true }>, R>;

/**
* The model of a union data.
*/
Expand Down
123 changes: 67 additions & 56 deletions packages/ts/models/src/m.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { EmptyObject } from 'type-fest';
import type { EmptyObject, SetOptional } from 'type-fest';
import { CoreModelBuilder, ObjectModelBuilder } from './builders.js';
import { $parse, ArrayModel, EnumModel, ObjectModel, type PrimitiveModel, type UnionModel } from './core.js';
import {
$parse,
ArrayModel,
EnumModel,
ObjectModel,
type OptionalModel,
type PrimitiveModel,
type UnionModel,
} from './core.js';
import {
$defaultValue,
$enum,
Expand Down Expand Up @@ -79,10 +87,24 @@ export function object<T extends AnyObject>(
*
* @param base - The base model to extend.
*/
export function optional<M extends Model>(base: M): M {
return (CoreModelBuilder.create(base) as CoreModelBuilder<unknown, EmptyObject, { named: true; selfRefKeys: never }>)
export function optional<T, EX extends AnyObject, R extends string>(
base: Model<NonNullable<T>, EX, R>,
): OptionalModel<NonNullable<T> | undefined, EX, R> {
return (CoreModelBuilder.create(base) as CoreModelBuilder<T | undefined, EX, { named: true; selfRefKeys: R }>)
.define($optional, { value: true })
.build() as M;
.defaultValueProvider(() => undefined)
.build() as any;
}

/**
* Checks if the given model is an optional model.
*
* @param model - The model to check.
*/
export function isOptional<T, EX extends AnyObject, R extends string>(
model: Model<T, EX, R>,
): model is OptionalModel<T, EX, R> {
return model[$optional];
}

/**
Expand Down Expand Up @@ -122,6 +144,10 @@ export function validators(model: Model): readonly Validator[] {
return model[$validators];
}

export function defaultValue<T>(model: Model<T>): T {
return model[$defaultValue];
}

/**
* Provides the value the given model represents. For attached models it will
* be the owner value or its part, for detached models or in case the value
Expand All @@ -144,7 +170,8 @@ export function value<T>(model: Model<T>): T;
export function value<T>(model: Model<T>, newValue: T): void;
// eslint-disable-next-line consistent-return
export function value(...args: [Model, unknown?]): unknown {
const [model] = args;
const isSetter = args.length > 1;
const [model, newValue] = args;

const keys: Array<keyof any> = [];
let target: Target;
Expand Down Expand Up @@ -175,69 +202,53 @@ export function value(...args: [Model, unknown?]): unknown {

// If the method is called with a single argument, it means we need to get
// the value of the target object the given model represents
if (args.length === 1) {
if (target.value === $nothing) {
if (target.value === $nothing) {
if (!isSetter) {
// If the model is detached, we return the default value of the model.
return model[$defaultValue];
}
}

let current = target.value;

// We execute the collected keys in reverse order to get the value of the
// nested property the model represents.
for (let i = keys.length - 1; i >= 0; i--) {
// If we are not at the model's level, and the current value is not an
// object we can take a key of, we throw an error.
if (!hasCorrectShape(current)) {
throw new Error('The value shape does not fit the model.');
}

// Otherwise, we take a key of the current value and continue the loop.
current = current[keys[i]];
if (!keys.length) {
if (isSetter) {
// If we are at the root level, we just set the whole value.
target.value = newValue;
} else {
return target.value;
}

return current;
}

// If the method is called with two arguments, it means we need to set the
// value of the nested property the model represents.
const [, newValue] = args;

// Here we check if we are at the root (top) level of the model structure, and
// the model represents the whole `value` of the target.
if (keys.length) {
// In case we collected some keys, it means that we are working with the
// nested properties of the target object.
let current = target.value;
let current = target.value;

// We execute the collected keys in reverse order to get the nested property
// the model represents. Since we have to stop one step before the last key,
// the condition is `i > 0`, not `i >= 0` like it was in previous case.
for (let i = keys.length - 1; i >= 0; i--) {
// If we are not at the model's level, and the current value is not an
// object we can take a key of, we throw an error.
if (!hasCorrectShape(current)) {
// eslint-disable-next-line consistent-return
throw new Error('The value shape does not fit the model.');
}
// We execute the collected keys in reverse order to get the value of the
// nested property the model represents.
for (let i = keys.length - 1; i >= 0; i--) {
// If we are not at the model's level, and the current value is not an
// object we can take a key of, we throw an error.
if (!hasCorrectShape(current)) {
throw new Error('The value shape does not fit the model.');
}

if (i === 0) {
// We are at the end of the loop, so we have to assign the new value to
// a property the model describes.
if (i === 0) {
// We are at the end of the loop.
if (isSetter) {
// If it is a setter, we assign the new value to a property the model
// describes.
current[keys[i]] = newValue;

// Then we have to trigger a setter of the target value. E.g., it's
// needed for the Signal to update.
// eslint-disable-next-line no-self-assign
target.value = target.value;
} else {
// Otherwise, we take a key of the current value and continue the loop.
current = current[keys[i]];
// If it is a getter, we return the value of the property the model
// describes.
return current[keys[i]];
}
} else {
// Otherwise, we take a key of the current value and continue the loop.
current = current[keys[i]];
}

// Then we have to trigger a setter of the target value. E.g., it's needed
// for the Signal to update.
// eslint-disable-next-line no-self-assign
target.value = target.value;
} else {
// If we are at the root level, we just set the whole value.
target.value = newValue;
}
}

Expand Down
Loading

0 comments on commit bc66791

Please sign in to comment.