Skip to content

Commit

Permalink
feat(store): add record property type (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
smalluban authored Jul 5, 2024
1 parent 780c0dd commit 8b1f308
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 34 deletions.
68 changes: 51 additions & 17 deletions docs/store/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,37 +244,45 @@ If the first item of the array is an enumerable model definition, the property r

The parent model's storage may provide a list of data for model instances or a list of identifiers. The update process and binding between models work the same as for a single external nested object.

## Cache Invalidation
## Record

By default, the store does not invalidate the cached value of the model instance when nested in the array external model changes. Because of the nature of the binding between models, when the nested model updates its state, the change will be reflected without a need to update the parent model's state.
For the structure with variable keys, use the `store.record()` method, which creates a record definition. The record is a map of all of the supported types of properties (including nested objects and arrays):

However, the list value might be related to the current state of nested models. For example, the model definition representing a paginated structure ordered by name must update when one of the nested models changes. After the change, the result pages might have different content. To support that case, you can pass a second object to the nested array definition with the `loose` option:
```typescript
store.record(value): object;
```

```javascript
import { store } from 'hybrids';
import User from './user.js';
* **arguments**:
* `value` - any supported property value
* **returns**:
* an empty object representing the record definition

const UserList = {
id: true,
users: [User, { loose: true }],
```javascript
const Settings = {
...,
[store.connect]: (params) => api.get('/users/search', params),
flags: store.record(false),
};
```

const pageOne = store.get(UserList, { page: 1, query: '' });
The record definition by default is an empty object. For example, to set individual keys of the record use the `store.set()` method:

// Updates some user and invalidates cached value of the `pageOne` model instance
store.set(pageOne.users[0], { name: 'New name' });
```
```javascript
// Set the record value
store.set(Settings, { flags: { a: true, b: false } });

To prevent an endless loop of fetching data, the cached value of the parent model instance only invalidates if the `store.set` method is used. Updating the state of the nested model definition by fetching new values with the `store.get` action won't invalidate the parent model. Get action still respects the `cache` option of the parent storage. If you need a high rate of accuracy of the external data, you should set a very low value of the `cache` option in the storage, or even set it to `false`.
// Remove the record key
store.set(Settings, { flags: { a: null } });

// Reset the record completely
store.set(Settings, { flags: null });
```

## Self Reference & Import Cycles

The model definition is based on the plain object definition, so by the JavaScript constraints, it is not possible to create a property, which is a model definition of itself, or use the definition from another ES module, which depends on the source file (there is an import cycle). In those situations, use the `store.ref(fn)` method, which sets a property to the result of the passed function in time of the definition (when the model definition is used for the first time).
The model definition is based on the plain object definition, so by the JavaScript constraints, it is not possible to create a property, which references the model itself, or use the definition from another ES module, which depends on the source file (there is an import cycle). In those situations, use the `store.ref()` method, which sets a property to the result of the passed function in time of the definition (when the model definition is used for the first time).

```typescript
store.ref(fn: () => Property): fn;
store.ref(fn: () => value): fn;
```

* **arguments**:
Expand All @@ -292,3 +300,29 @@ const Model = {
models: store.ref(() => [Model]),
};
```

## Cache Invalidation

By default, the store does not invalidate the cached value of the model instance when nested in the array external model changes. Because of the nature of the binding between models, when the nested model updates its state, the change will be reflected without a need to update the parent model's state.

However, the list value might be related to the current state of nested models. For example, the model definition representing a paginated structure ordered by name must update when one of the nested models changes. After the change, the result pages might have different content. To support that case, you can pass a second object to the nested array definition with the `loose` option:

```javascript
import { store } from 'hybrids';
import User from './user.js';

const UserList = {
id: true,
users: [User, { loose: true }],
...,
[store.connect]: (params) => api.get('/users/search', params),
};

const pageOne = store.get(UserList, { page: 1, query: '' });

// Updates some user and invalidates cached value of the `pageOne` model instance
store.set(pageOne.users[0], { name: 'New name' });
```

To prevent an endless loop of fetching data, the cached value of the parent model instance only invalidates if the `store.set` method is used. Updating the state of the nested model definition by fetching new values with the `store.get` action won't invalidate the parent model. Get action still respects the `cache` option of the parent storage. If you need a high rate of accuracy of the external data, you should set a very low value of the `cache` option in the storage, or even set it to `false`.

70 changes: 69 additions & 1 deletion src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const connect = Symbol("store.connect");

const definitions = new WeakMap();
const stales = new WeakMap();
const refs = new WeakSet();

function resolve(config, model, lastModel) {
if (lastModel) {
Expand Down Expand Up @@ -343,6 +342,9 @@ function uuid(temp) {
: ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
}

const refs = new WeakSet();
const records = new WeakMap();

function ref(fn) {
if (typeof fn !== "function") {
throw TypeError(`The first argument must be a function: ${typeof fn}`);
Expand All @@ -352,10 +354,40 @@ function ref(fn) {
return fn;
}

function record(value) {
if (value === undefined || value === null) {
throw TypeError(`The value must be defined: ${value}`);
}

if (!refs.has(value) && typeof value === "function") {
throw TypeError(`A function is not supported as the value of the record`);
}

const model = Object.freeze({});
records.set(model, value);

return model;
}

const validationMap = new WeakMap();
function resolveKey(Model, key, config) {
let defaultValue = config.model[key];
if (refs.has(defaultValue)) defaultValue = defaultValue();

if (records.has(defaultValue)) {
const value = records.get(defaultValue);
if (typeof value === "function") {
throw TypeError(
`A function is not supported as the value of the record for '${key}' property`,
);
}

return {
defaultValue: { id: true, value },
type: "record",
};
}

let type = typeof defaultValue;

if (
Expand Down Expand Up @@ -686,6 +718,41 @@ function setupModel(Model, nested) {
}
};
}
case "record": {
const localConfig = bootstrap(defaultValue, true);

return (model, data, lastModel) => {
const record = data[key];
const result =
lastModel && lastModel[key] ? { ...lastModel[key] } : {};

if (record === null || record === undefined) {
model[key] = {};
return;
}

for (const id of Object.keys(record)) {
if (record[id] === null || record[id] === undefined) {
delete result[id];
continue;
}

const item = localConfig.create(
{ id, value: record[id] },
result[id],
);

Object.defineProperty(result, id, {
get() {
return cache.get(this, id, () => item.value);
},
enumerable: true,
});
}

model[key] = result;
};
}
// eslint-disable-next-line no-fallthrough
default: {
const Constructor = getTypeConstructor(type, key);
Expand Down Expand Up @@ -1658,5 +1725,6 @@ export default Object.freeze(
value: valueWithValidation,
resolve: resolveToLatest,
ref,
record,
}),
);
172 changes: 172 additions & 0 deletions test/spec/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,178 @@ describe("store:", () => {
});
});

describe("record()", () => {
it("throws when argument is not set", () => {
expect(() => store.record()).toThrow();
expect(() => store.record(null)).toThrow();
expect(() => store.record(undefined)).toThrow();
});

it("throws when argument is a function", () => {
expect(() => store.record(() => {})).toThrow();
});

it("throws when model with value as a function passed to ref", () => {
Model = {
other: "",
values: store.record(store.ref(({ other }) => other)),
};

expect(() => store.get(Model)).toThrow();
});

describe("for primitive value", () => {
beforeEach(() => {
Model = {
id: true,
values: store.record(""),
};
});

it("creates a record property", () => {
return store
.set(Model, { values: { a: "b", c: "d" } })
.then((model) => {
expect(model.values).toEqual({ a: "b", c: "d" });
return store
.set(Model, { values: { a: "e", f: "g" } })
.then((otherModel) => {
expect(model.values).toEqual({ a: "b", c: "d" });
expect(otherModel.values).toEqual({ a: "e", f: "g" });
});
});
});

it("updates item in record property", () => {
return store
.set(Model, { values: { a: "b", c: "d" } })
.then((model) => store.set(model, { values: { a: "e", f: "g" } }))
.then((model) => {
expect(model.values).toEqual({ a: "e", c: "d", f: "g" });
});
});

it("deletes item in record property", () => {
return store
.set(Model, { values: { a: "b", c: "d" } })
.then((model) => store.set(model, { values: { a: null } }))
.then((model) => {
expect(model.values).toEqual({ c: "d" });
});
});

it("clears a record property", () => {
return store
.set(Model, { values: { a: "b", c: "d" } })
.then((model) => store.set(model, { values: null }))
.then((model) => {
expect(model.values).toEqual({});
});
});
});

describe("for nested model value", () => {
beforeEach(() => {
Model = {
id: true,
values: store.record({ value: "" }),
};
});

it("creates a record property", () => {
return store
.set(Model, { values: { a: { value: "b" }, c: { value: "d" } } })
.then((model) => {
expect(model.values).toEqual({
a: { value: "b" },
c: { value: "d" },
});
});
});

it("updates item in record property", () => {
return store
.set(Model, { values: { a: { value: "b" }, c: { value: "d" } } })
.then((model) =>
store.set(model, {
values: { a: { value: "e" }, f: { value: "g" } },
}),
)
.then((model) => {
expect(model.values).toEqual({
a: { value: "e" },
c: { value: "d" },
f: { value: "g" },
});
});
});

it("deletes item in record property", () => {
return store
.set(Model, { values: { a: { value: "b" }, c: { value: "d" } } })
.then((model) => store.set(model, { values: { a: null } }))
.then((model) => {
expect(model.values).toEqual({
c: { value: "d" },
});
});
});

it("clears a record property", () => {
return store
.set(Model, { values: { a: { value: "b" }, c: { value: "d" } } })
.then((model) => store.set(model, { values: null }))
.then((model) => {
expect(model.values).toEqual({});
});
});
});

describe("for external model value", () => {
let ExternalModel;
beforeEach(() => {
ExternalModel = {
id: true,
value: "",
};

Model = {
id: true,
values: store.record(ExternalModel),
};
});

it("creates a record property and preserves the relation", () => {
return store
.set(Model, { values: { a: { id: "1", value: "b" } } })
.then((model) => {
expect(store.get(ExternalModel, "1").value).toBe("b");
expect(model.values).toEqual({
a: { id: "1", value: "b" },
});

return store.set(model.values.a, null).then(() => {
expect(store.error(model.values.a)).toBeInstanceOf(Error);
});
});
});

it("set record property item by the external model id", () => {
return store
.set(ExternalModel, { value: "test" })
.then((externalModel) => {
return store
.set(Model, { values: { a: externalModel.id } })
.then((model) => {
expect(model.values).toEqual({
a: { id: externalModel.id, value: "test" },
});
});
});
});
});
});

describe("guards", () => {
it("returns false if value is not an object instance", () => {
expect(store.pending(null)).toBe(false);
Expand Down
Loading

0 comments on commit 8b1f308

Please sign in to comment.