New Record decorator to define models (#362)

* Added missing files

* Record specs

* Fixes

* pool v1

* now works with pool

* fix errors and tslint

* update doc

* Fix tests

* Fix tests

* Fix tests
This commit is contained in:
Timothee Guerin 2017-05-10 14:41:32 -07:00 коммит произвёл GitHub
Родитель 889acca6bc
Коммит ad940e97f6
11 изменённых файлов: 422 добавлений и 98 удалений

Просмотреть файл

@ -1,2 +1,3 @@
export * from "./dynamic-form"
export * from "./dto"
export * from "./dto";
export * from "./dynamic-form";
export * from "./record";

Просмотреть файл

@ -0,0 +1,83 @@
import { RecordMissingExtendsError } from "./errors";
import { setProp, updateTypeMetadata } from "./helpers";
import { Record } from "./record";
// tslint:disable:only-arrow-functions
/**
* Model attribute decorator.
*
* @example
* ```ts
* @Prop
* public foo: string = "default";
*
* @Prop
* public bar:string; // Default will be null
* ```
*/
export function Prop<T>(...args) {
return (target, attr, descriptor?: TypedPropertyDescriptor<T>) => {
const ctr = target.constructor;
const type = Reflect.getMetadata("design:type", target, attr);
if (!type) {
throw `Cannot retrieve the type for RecordAttribute ${target.constructor.name}#${attr}`
+ "Check your nested type is defined in another file or above this DtoAttr";
}
updateTypeMetadata(ctr, attr, { type, list: false });
if (descriptor) {
descriptor.writable = false;
} else {
setProp(ctr, attr);
}
};
}
/**
* Model list attribute decorator. Use this if the attribute is an array
*
* @example
* ```ts
* @ListProp(Bar)
* public foos: List<Bar> = List([]);
* ```
*/
export function ListProp<T>(type: any) {
return (target, attr, descriptor?: TypedPropertyDescriptor<T>) => {
const ctr = target.constructor;
updateTypeMetadata(ctr, attr, { type, list: true });
if (descriptor) {
descriptor.writable = false;
} else {
setProp(ctr, attr);
}
};
}
export function Model() {
return <T extends { new (...args: any[]): {} }>(ctr: T) => {
if (!(ctr.prototype instanceof Record)) {
throw new RecordMissingExtendsError(ctr);
}
// save a reference to the original constructor
const original = ctr;
// the new constructor behaviour
const f: any = function (this: T, data, ...args) {
if (data instanceof ctr) {
return data;
}
const obj = original.apply(this, [data, ...args]);
obj._completeInitialization();
return obj;
};
// copy prototype so intanceof operator still works
f.prototype = original.prototype;
// return new constructor (will override original)
return f;
};
}

17
app/core/record/errors.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
/**
* Execption to be thrown if the user created a model with the @Model decorator but forgot to extend the Record class.
*/
export class RecordMissingExtendsError extends Error {
constructor(ctr: Function) {
super(`Class ${ctr.name} with the @Model Decorator should also extends the Record class`);
}
}
/**
* Execption to be thrown if the user tries to call setter of attribute.
*/
export class RecordSetAttributeError extends Error {
constructor(ctr: Function, attr: string) {
super(`Cannot set attribute ${attr} of immutable Record ${ctr.name}!`);
}
}

Просмотреть файл

@ -0,0 +1,39 @@
import { Type } from "@angular/core";
import { RecordSetAttributeError } from "./errors";
import { Record } from "./record";
const attrMetadataKey = "record:attrs";
export const primitives = new Set(["Array", "Number", "String", "Object", "Boolean"]);
export function metadataForRecord(record: Record<any>) {
return Reflect.getMetadata(attrMetadataKey, record.constructor) || {};
}
interface TypeMetadata {
list: boolean;
type: Type<any>;
}
export function updateTypeMetadata(ctr: Type<any>, attr: string, type: TypeMetadata) {
const metadata = Reflect.getMetadata(attrMetadataKey, ctr) || {};
metadata[attr] = type;
Reflect.defineMetadata(attrMetadataKey, metadata, ctr);
}
export function setProp(ctr: Type<any>, attr: string) {
Object.defineProperty(ctr.prototype, attr, {
get: function (this: Record<any>) {
return (this as any)._map.get(attr) || (this as any)._defaultValues[attr];
},
set: function <T>(this: Record<any>, value: T) {
if ((this as any)._initialized) {
throw new RecordSetAttributeError(this.constructor, attr);
} else {
const defaults = (this as any)._defaultValues;
defaults[attr] = value;
}
},
});
}

3
app/core/record/index.ts Normal file
Просмотреть файл

@ -0,0 +1,3 @@
export * from "./record";
export * from "./decorators";
export * from "./errors";

63
app/core/record/record.ts Normal file
Просмотреть файл

@ -0,0 +1,63 @@
import { List, Map } from "immutable";
import { metadataForRecord, primitives } from "./helpers";
/**
* Base class for a record.
* @template TInput Interface of the data returned by the server.
*/
export class Record<TInput> {
private _map: Map<string, any> = Map({});
private _defaultValues = {};
private _initialized = false;
private _keys: Set<string>;
constructor(data: Partial<TInput> = {}) {
this._init(data);
}
public equals(other: this) {
return this === other || this._map.equals(other._map);
}
public get(key: string) {
return this._map.get(key);
}
public toJS(): any {
return Object.assign({}, this._defaultValues, this._map.toJS());
}
/**
* This method will be called by the decorator.
*/
private _init(data: any) {
const attrs = metadataForRecord(this);
const obj = {};
const keys = Object.keys(attrs);
this._keys = new Set(keys);
for (let key of keys) {
this._defaultValues[key] = null;
const typeMetadata = attrs[key];
if (!(key in data)) {
continue;
}
const value = (data as any)[key];
if (value && typeMetadata && !primitives.has(typeMetadata.type.name)) {
if (typeMetadata.list) {
obj[key] = List(value && value.map(x => new typeMetadata.type(x)));
} else {
obj[key] = new typeMetadata.type(value);
}
} else {
obj[key] = value;
}
}
this._map = Map(obj);
}
// tslint:disable-next-line:no-unused-variable
private _completeInitialization() {
this._initialized = true;
}
}

Просмотреть файл

@ -1,6 +1,7 @@
import { List, Record } from "immutable";
import { List } from "immutable";
import { Duration } from "moment";
import { ListProp, Model, Prop, Record } from "app/core";
import { ModelUtils } from "app/utils";
import { CloudServiceConfiguration } from "./cloud-service-configuration";
import { Metadata, MetadataAttributes } from "./metadata";
@ -9,36 +10,6 @@ import { StartTask } from "./start-task";
import { UserAccount, UserAccountAttributes } from "./user-account";
import { VirtualMachineConfiguration, VirtualMachineConfigurationAttributes } from "./virtual-machine-configuration";
const PoolRecord = Record({
allocationState: null,
allocationStateTransitionTime: null,
applicationPackageReferences: [],
certificateReferences: [],
cloudServiceConfiguration: null,
creationTime: null,
currentDedicated: 0,
displayName: null,
enableAutoScale: false,
enableInterNodeCommunication: false,
id: null,
lastModified: null,
maxTasksPerNode: 1,
resizeError: null,
resizeTimeout: null,
state: null,
stateTransitionTime: null,
targetDedicated: 0,
autoScaleEvaluationInterval: null,
autoScaleFormula: null,
taskSchedulingPolicy: null,
url: null,
virtualMachineConfiguration: null,
vmSize: null,
startTask: null,
metadata: List([]),
userAccounts: List([]),
});
export interface PoolAttributes {
allocationState: string;
allocationStateTransitionTime: Date;
@ -70,34 +41,62 @@ export interface PoolAttributes {
/**
* Class for displaying Batch pool information.
*/
export class Pool extends PoolRecord {
@Model()
export class Pool extends Record<PoolAttributes> {
@Prop()
public allocationState: string;
@Prop()
public allocationStateTransitionTime: Date;
@Prop()
public applicationPackageReferences: any[];
@Prop()
public certificateReferences: any[];
@Prop()
public cloudServiceConfiguration: CloudServiceConfiguration;
@Prop()
public creationTime: Date;
@Prop()
public currentDedicated: number;
@Prop()
public displayName: string;
@Prop()
public enableAutoScale: boolean;
@Prop()
public enableInterNodeCommunication: boolean;
@Prop()
public id: string;
@Prop()
public lastModified: Date;
public maxTasksPerNode: number;
@Prop()
public maxTasksPerNode: number = 1;
@Prop()
public resizeError: ResizeError;
@Prop()
public resizeTimeout: Duration;
@Prop()
public state: string;
@Prop()
public stateTransitionTime: Date;
public targetDedicated: number;
@Prop()
public targetDedicated: number = 0;
@Prop()
public autoScaleFormula: string;
@Prop()
public autoScaleEvaluationInterval: Duration;
@Prop()
public taskSchedulingPolicy: any;
@Prop()
public url: string;
@Prop()
public virtualMachineConfiguration: VirtualMachineConfiguration;
@Prop()
public vmSize: string;
@Prop()
public startTask: StartTask;
public metadata: List<Metadata>;
public userAccounts: List<UserAccount>;
@ListProp(Metadata)
public metadata: List<Metadata> = List([]);
@ListProp(UserAccount)
public userAccounts: List<UserAccount> = List([]);
/**
* Tags are computed from the metadata using an internal key
@ -105,12 +104,7 @@ export class Pool extends PoolRecord {
public tags: List<string> = List([]);
constructor(data: Partial<PoolAttributes> = {}) {
super(Object.assign({}, data, {
resizeError: data.resizeError && new ResizeError(data.resizeError),
startTask: data.startTask && new StartTask(data.startTask),
metadata: List(data.metadata && data.metadata.map(x => new Metadata(x))),
userAccounts: List(data.userAccounts && data.userAccounts.map(x => new UserAccount(x))),
}));
super(data);
this.tags = ModelUtils.tagsFromMetadata(this.metadata);
}
}

Просмотреть файл

@ -1,7 +1,6 @@
import { ObjectUtils, SecureUtils } from "app/utils";
import { Map } from "immutable";
import { BehaviorSubject, Observable, Subject } from "rxjs";
import { ObjectUtils, SecureUtils } from "app/utils";
import { PollService } from "./poll-service";
import { QueryCache } from "./query-cache";

Просмотреть файл

@ -2,20 +2,19 @@
This is a documentation to help create models which are DataStructure that maps entities returned by apis.
All models should be immmutable using `immutable.Record` otherwise the `RxProxy` that is using immutable `List` and `Map` will not handle those correctly.
All models should be immmutable using the record api defined in `app/core`.
Immutable.js will convert those to a Map automatically which then lose the ability to run `myModel.myAttr`
If you are just making a model that is internal to a component:
* doesn't it really need to be shared with others
* if yes maybe just export from the component file/folder
Note: Before writting a model double check this is the best option:
* Models should be for containg data returned from remote APIs.
* Models are immutable which means it should not be for a structure containing user edit.
* Don't use models for internal data structure.(For a component or a small set of components)
### Step 1: Create file
Create model file `my-new-model.ts` in `app/models`
add this to `app/models/index.ts`
```typescript
export * from "./my-new-model"
export * from "./my-model"
```
Then you should be able to have
@ -28,25 +27,7 @@ import { MyNewModel } from "app/models"
import { MyNewModel } from "app/models/myNewModel"
```
### Step 2: Write the Record
Use this to specify default values for each input. This is quite useful for inputs which are array for example and prevent a null check later in the code
**It is important to have every input defined here otherwise they will be ignored**
```typescript
import { List, Record } from "immutable";
// Note the record is not being exported
const FooRecord = Record({
id: null,
state: null,
files: List([]),
bar: null,
});
```
### Step 3: Write the attribute interface
### Step 2: Write the attribute interface
In this interface define all the attributes of the model
This may look like we are creating a lot of duplicate code here but it makes it worth it when using the model later as you'll have typing when creating a new model.
@ -57,43 +38,36 @@ import { Partial } from "app/utils"
export interface MyModelAttributes {
id: string;
state: string;
files: List<string>;
files: Partial<BarAttributes>[];
bar: Partial<BarAttributes>
}
```
### Step 4: Write the model class
### Step 3: Write the model class
You'll need to redefine the inputs. This is for typing purposes.
You need to do the following for the class:
- Decorate the class with the `@Model` decorator
- Extend the class with the `Record` class
- Decorate all attributes of the model with `@Prop` and all list attributes with `@ListProp`. Note: `@Prop` will be able to get the type of a nested model automatically. However you need to pass the type of the model in the list decorator.
- For default values just set the value in the class body `@Prop public a: string = "abc"`
```typescript
import { ListProp, Model, Prop, Record } from "app/core";
import { Bar, BarAttributes } from "./bar"
export class Foo extends FooRecord implements MyModelAttributes {
public id: string;
@Model()
export class MyModel extends Record<MyModelAttributes> {
@Prop()
public id: string = "default-value";
@Prop()
public state: string;
public files: List<string>;
@Prop()
public bar: Bar;
constructor(data: Partial<MyModelAttributes> = {}) {
super(data);
}
}
```
### Step 4(If applicable): created nested Record
In the case some of the attributes are other models(Record). Then you'll need to do the following to make sure they are initialized correctly
**Important If the model has some attributes with complex object as type. Write another model(Extending record) for it.**
```typescript
// Add this constructor
constructor(data: Partial<MyModelAttributes> = {}) {
super(Object.assign({}, data, {
files: List(data.files),
bar: data.bar && new Bar(data.bar),
}));
@ListProp(Bar)
public files: List<Bar>;
}
```
The record api will make all attributes with `@Prop` immutable. If you have a nested object it will automatically create it. And when using `@ListProp` it will automatically create a immutable list of items.

Просмотреть файл

@ -26,7 +26,7 @@ const pool1 = new Pool({ id: "pool-1", targetDedicated: 3, virtualMachineConfigu
const pool2 = new Pool({ id: "pool-2", targetDedicated: 1, virtualMachineConfiguration: config });
const pool3 = new Pool({ id: "pool-3", targetDedicated: 19, virtualMachineConfiguration: config });
describe("PoolPickerComponenent", () => {
describe("PoolPickerComponent", () => {
let fixture: ComponentFixture<TestComponent>;
let testComponent: TestComponent;
let component: PoolPickerComponent;

Просмотреть файл

@ -0,0 +1,151 @@
import { ListProp, Model, Prop, Record, RecordMissingExtendsError, RecordSetAttributeError } from "app/core";
import { List } from "immutable";
@Model()
class NestedRec extends Record<any> {
@Prop()
public name: string = "default-name";
}
@Model()
class TestRec extends Record<any> {
@Prop()
public id: string = "default-id";
@Prop()
public nested: NestedRec;
@ListProp(NestedRec)
public nestedList: List<NestedRec> = List([]);
}
@Model()
class SimpleTestRec extends Record<any> {
@Prop()
public id: string;
@Prop()
public a: number;
@Prop()
public b: number;
@Prop()
public c: number;
}
describe("Record", () => {
it("should throw an expecption when record doesn't extends Record class", () => {
try {
@Model()
class MissingExtendRecord {
@Prop()
public name: string;
}
expect(true).toBe(false, "Should have thrown an expecption");
} catch (e) {
expect(true).toBe(true, "Throw an excpetion as expected");
expect(e instanceof RecordMissingExtendsError).toBe(true, "Throw an excpetion as expected");
}
});
it("should set the defaults", () => {
const record = new TestRec();
expect(record.id).toEqual("default-id");
expect(record.nested).toBe(null);
expect(record.nestedList).toEqual(List([]));
});
it("should set basic value", () => {
const record = new TestRec({ id: "some-id" });
expect(record.id).toEqual("some-id");
});
it("should set nested value", () => {
const record = new TestRec({ nested: { name: "some-name" } });
expect(record.nested).not.toBeFalsy();
expect(record.nested instanceof NestedRec).toBe(true);
expect(record.nested.name).toEqual("some-name");
});
it("should set list attributes", () => {
const record = new TestRec({ nestedList: [{ name: "name-1" }, { name: "name-2" }, {}] });
const list = record.nestedList;
expect(list).not.toBeFalsy();
expect(list instanceof List).toBe(true);
expect(list.size).toBe(3);
expect(list.get(0) instanceof NestedRec).toBe(true, "Item 0 in list should be of nested type");
expect(list.get(0).name).toEqual("name-1");
expect(list.get(1) instanceof NestedRec).toBe(true, "Item 1 in list should be of nested type");
expect(list.get(1).name).toEqual("name-2");
expect(list.get(2) instanceof NestedRec).toBe(true, "Item 2 in list should be of nested type");
expect(list.get(2).name).toEqual("default-name");
});
it("should not allow to set attributes after created", () => {
const record = new SimpleTestRec({ a: 1, b: 2 });
try {
record.a = 3;
expect(false).toBe(true, "Should have caught an error");
} catch (e) {
expect(e instanceof RecordSetAttributeError).toBe(true, "Throw an excpetion as expected");
}
});
it("should pass record of the same type", () => {
const a = new TestRec({ id: "some-id" });
const c = new SimpleTestRec(a);
expect(new TestRec(a)).toBe(a, "IF applying constructor again it should just return the same object");
expect(c instanceof SimpleTestRec).toBe(true);
expect(c.id).toEqual(a.id);
});
it("is a value type and equals other similar Records", () => {
let t1 = new SimpleTestRec({ a: 10 });
let t2 = new SimpleTestRec({ a: 10, b: 2 });
expect(t1.equals(t2));
});
it("skips unknown keys", () => {
let record = new SimpleTestRec({ a: 29, d: 12, u: 2 });
expect(record.a).toBe(29);
expect(record.get("d")).toBeUndefined();
expect(record.get("u")).toBeUndefined();
});
it("toJS() returns correct values", () => {
let a = new SimpleTestRec({ a: 29, b: 12, u: 2 });
expect(a.toJS()).toEqual({ id: null, a: 29, b: 12, c: null });
let b = new TestRec({ id: "id-1", nested: { name: "name-1", other: "invalid" }, nestedList: [{}] });
expect(b.toJS()).toEqual({ id: "id-1", nested: { name: "name-1" }, nestedList: [{ name: "default-name" }] });
});
it("should have access to values in constructor", () => {
@Model()
class ComputedValueRec extends Record<any> {
@Prop()
public a = 1;
@Prop()
public b = 2;
public computedA;
public computedB;
constructor(data: any) {
super(data);
this.computedA = `A${this.a}`;
this.computedB = `B${this.b}`;
}
}
const rec1 = new ComputedValueRec({});
expect(rec1.computedA).toEqual("A1");
expect(rec1.computedB).toEqual("B2");
const rec2 = new ComputedValueRec({ a: 3, b: 50 });
expect(rec2.computedA).toEqual("A3");
expect(rec2.computedB).toEqual("B50");
});
});