Here is everything about us.
;
* }
* }
* ```
*
* @category Link Rendering
*/
Link!: (props: BrowserNavigatorLinkProps) => any;
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
}
================================================
FILE: packages/browser-navigator/src/index.ts
================================================
export * from './browser-navigator';
export * from './utilities';
================================================
FILE: packages/browser-navigator/src/utilities.ts
================================================
export function isInternalURL(url: URL | string) {
if (!(url instanceof URL)) {
url = new URL(url, 'internal:/');
}
if (url.protocol === 'internal:') {
return true;
}
const currentURL = new URL(window.location.href);
if (
url.protocol === currentURL.protocol &&
url.username === currentURL.username &&
url.password === currentURL.password &&
url.host === currentURL.host
) {
return true;
}
return false;
}
================================================
FILE: packages/browser-navigator/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component/README.md
================================================
# @layr/component
The base class of all your components.
## Installation
```
npm install @layr/component
```
## License
MIT
================================================
FILE: packages/component/package.json
================================================
{
"name": "@layr/component",
"version": "2.0.51",
"description": "The base class of all your components",
"keywords": [
"layr",
"component",
"base",
"class"
],
"author": "Manuel Vila
",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/observable": "^1.0.16",
"@layr/utilities": "^1.0.9",
"core-helpers": "^1.0.8",
"cuid": "^2.1.8",
"lodash": "^4.17.21",
"possibly-async": "^1.0.7",
"simple-cloning": "^1.0.5",
"simple-forking": "^1.0.7",
"simple-serialization": "^1.0.12",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191",
"sleep-promise": "^9.1.0"
}
}
================================================
FILE: packages/component/src/cloning.test.ts
================================================
import {Component} from './component';
import {attribute, primaryIdentifier, provide} from './decorators';
describe('Cloning', () => {
test('Simple component', async () => {
class Movie extends Component {
@attribute() title?: string;
@attribute() tags?: string[];
@attribute() specs?: {duration?: number};
}
expect(Movie.clone()).toBe(Movie);
let movie = new Movie({title: 'Inception', tags: ['drama'], specs: {duration: 120}});
let clonedMovie = movie.clone();
expect(clonedMovie).not.toBe(movie);
expect(clonedMovie.getComponentType()).toBe('Movie');
expect(clonedMovie.isNew()).toBe(true);
expect(clonedMovie.title).toBe(movie.title);
expect(clonedMovie.tags).not.toBe(movie.tags);
expect(clonedMovie.tags).toEqual(movie.tags);
expect(clonedMovie.specs).not.toBe(movie.specs);
expect(clonedMovie.specs).toEqual(movie.specs);
movie = Movie.instantiate();
movie.title = 'Inception';
clonedMovie = movie.clone();
expect(clonedMovie).not.toBe(movie);
expect(clonedMovie.getComponentType()).toBe('Movie');
expect(clonedMovie.isNew()).toBe(false);
expect(clonedMovie.title).toBe(movie.title);
expect(clonedMovie.getAttribute('tags').isSet()).toBe(false);
expect(clonedMovie.getAttribute('specs').isSet()).toBe(false);
});
test('Referenced component', async () => {
class Movie extends Component {
@attribute() director!: Director;
}
class Director extends Component {
@attribute() name?: string;
}
const movie = new Movie({director: new Director({name: 'Christopher Nolan'})});
const clonedMovie = movie.clone();
expect(clonedMovie.director.getComponentType()).toBe('Director');
expect(clonedMovie.director).not.toBe(movie.director);
expect(clonedMovie.director.name).toBe('Christopher Nolan');
clonedMovie.director.name = 'Christopher Nolan 2';
expect(clonedMovie.director.name).toBe('Christopher Nolan 2');
expect(movie.director.name).toBe('Christopher Nolan');
});
test('Identifiable component', async () => {
class Movie extends Component {
@primaryIdentifier() id!: string;
@attribute('string') title = '';
}
const movie = new Movie({title: 'Inception'});
const clonedMovie = movie.clone();
expect(clonedMovie).toBe(movie);
});
test('Referenced identifiable component', async () => {
class Director extends Component {
@primaryIdentifier() id!: string;
@attribute('string') name = '';
}
class Movie extends Component {
@provide() static Director = Director;
@attribute('Director') director!: Director;
}
const movie = new Movie({director: new Director({name: 'Christopher Nolan'})});
const clonedMovie = movie.clone();
expect(clonedMovie).not.toBe(movie);
expect(clonedMovie.director).toBe(movie.director);
});
});
================================================
FILE: packages/component/src/cloning.ts
================================================
import {clone as simpleClone, CloneOptions} from 'simple-cloning';
import {isComponentClass, isComponentInstance} from './utilities';
export {CloneOptions};
/**
* Deeply clones any type of values including objects, arrays, and component instances (using Component's [`clone()`](https://layrjs.com/docs/v2/reference/component#clone-instance-method) instance method).
*
* @param value A value of any type.
*
* @returns A clone of the specified value.
*
* @example
* ```
* import {clone} from '﹫layr/component';
*
* const data = {
* token: 'xyz123',
* timestamp: 1596600889609,
* movie: new Movie({title: 'Inception'})
* };
*
* const dataClone = clone(data);
* dataClone.token; // => 'xyz123';
* dataClone.timestamp; // => 1596600889609
* dataClone.movie; // => A clone of data.movie
* ```
*
* @category Cloning
* @possiblyasync
*/
export function clone(value: any, options: CloneOptions = {}): any {
const {objectCloner: originalObjectCloner, ...otherOptions} = options;
const objectCloner = function (object: object): object | void {
if (originalObjectCloner !== undefined) {
const clonedObject = originalObjectCloner(object);
if (clonedObject !== undefined) {
return clonedObject;
}
}
if (isComponentClass(object)) {
return object.clone();
}
if (isComponentInstance(object)) {
return object.clone(options);
}
};
return simpleClone(value, {...otherOptions, objectCloner});
}
================================================
FILE: packages/component/src/component.test.ts
================================================
import type {ExtendedError} from '@layr/utilities';
import sleep from 'sleep-promise';
import {Component} from './component';
import {EmbeddedComponent} from './embedded-component';
import {
isPropertyInstance,
Attribute,
isAttributeInstance,
Method,
isMethodInstance
} from './properties';
import {validators} from './validation';
import {attribute, method, expose, provide, consume} from './decorators';
import {isComponentClass, isComponentInstance} from './utilities';
describe('Component', () => {
describe('Creation', () => {
test('new ()', async () => {
class Movie extends Component {
static classAttribute = 1;
instanceAttribute = 2;
title!: string;
country!: string;
}
Movie.prototype.setAttribute('title', {valueType: 'string', default: ''});
Movie.prototype.setAttribute('country', {valueType: 'string', default: ''});
expect(isComponentClass(Movie)).toBe(true);
expect(Object.keys(Movie)).toEqual(['classAttribute']);
expect(Movie.classAttribute).toBe(1);
let movie = new Movie();
expect(isComponentInstance(movie)).toBe(true);
expect(movie).toBeInstanceOf(Movie);
expect(Object.keys(movie)).toEqual(['instanceAttribute']);
expect(movie.instanceAttribute).toBe(2);
expect(movie.title).toBe('');
expect(movie.country).toBe('');
movie = new Movie({title: 'Inception'});
expect(movie.title).toBe('Inception');
expect(movie.country).toBe('');
Movie.prototype.getAttribute('country').markAsControlled();
movie = new Movie();
expect(movie.title).toBe('');
expect(movie.getAttribute('country').isSet()).toBe(false);
});
test('instantiate()', async () => {
class Movie extends Component {
title!: string;
country!: string;
}
Movie.prototype.setAttribute('title', {valueType: 'string', default: ''});
Movie.prototype.setAttribute('country', {valueType: 'string', default: ''});
let movie = Movie.instantiate();
expect(movie.isNew()).toBe(false);
expect(movie.getAttribute('title').isSet()).toBe(false);
expect(movie.getAttribute('country').isSet()).toBe(false);
expect(() => Movie.instantiate({title: 'Inception'})).toThrow(
"Cannot instantiate an unidentifiable component with an identifier (component: 'Movie')"
);
});
});
describe('Initialization', () => {
test('Synchronous initializer', async () => {
class Movie extends Component {
static isInitialized = false;
static initializer() {
this.isInitialized = true;
}
}
expect(Movie.isInitialized).toBe(false);
Movie.initialize();
expect(Movie.isInitialized).toBe(true);
});
test('Asynchronous initializer', async () => {
class Movie extends Component {
static isInitialized = false;
static async initializer() {
await sleep(10);
this.isInitialized = true;
}
}
expect(Movie.isInitialized).toBe(false);
await Movie.initialize();
expect(Movie.isInitialized).toBe(true);
});
});
describe('Naming', () => {
test('getComponentName() and setComponentName()', async () => {
class Movie extends Component {}
expect(Movie.getComponentName()).toBe('Movie');
Movie.setComponentName('Film');
expect(Movie.getComponentName()).toBe('Film');
Movie.setComponentName('MotionPicture');
expect(Movie.getComponentName()).toBe('MotionPicture');
// Make sure there are no enumerable properties
expect(Object.keys(Movie)).toHaveLength(0);
expect(() => Movie.setComponentName('')).toThrow('A component name cannot be empty');
expect(() => Movie.setComponentName('1Place')).toThrow(
"The specified component name ('1Place') is invalid"
);
expect(() => Movie.setComponentName('motionPicture')).toThrow(
"The specified component name ('motionPicture') is invalid"
);
expect(() => Movie.setComponentName('MotionPicture!')).toThrow(
"The specified component name ('MotionPicture!') is invalid"
);
});
test('getComponentPath()', async () => {
class MovieDetails extends Component {}
class Movie extends Component {
@provide() static MovieDetails = MovieDetails;
}
class App extends Component {
@provide() static Movie = Movie;
}
expect(App.getComponentPath()).toBe('App');
expect(Movie.getComponentPath()).toBe('App.Movie');
expect(MovieDetails.getComponentPath()).toBe('App.Movie.MovieDetails');
});
test('getComponentType()', async () => {
class Movie extends Component {}
expect(Movie.getComponentType()).toBe('typeof Movie');
expect(Movie.prototype.getComponentType()).toBe('Movie');
Movie.setComponentName('Film');
expect(Movie.getComponentType()).toBe('typeof Film');
expect(Movie.prototype.getComponentType()).toBe('Film');
});
});
describe('isNew mark', () => {
test('isNew(), markAsNew(), and markAsNotNew()', async () => {
class Movie extends Component {}
const movie = new Movie();
expect(movie.isNew()).toBe(true);
movie.markAsNotNew();
expect(movie.isNew()).toBe(false);
movie.markAsNew();
expect(movie.isNew()).toBe(true);
});
});
describe('Properties', () => {
test('getProperty()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
@attribute() title = '';
}
let property = Movie.getProperty('limit');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('limit');
expect(property.getParent()).toBe(Movie);
property = Movie.getProperty('find');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('find');
expect(property.getParent()).toBe(Movie);
class Film extends Movie {}
property = Film.getProperty('limit');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('limit');
expect(property.getParent()).toBe(Film);
property = Film.getProperty('find');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('find');
expect(property.getParent()).toBe(Film);
const movie = new Movie();
property = movie.getProperty('title');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('title');
expect(property.getParent()).toBe(movie);
const film = new Film();
property = film.getProperty('title');
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('title');
expect(property.getParent()).toBe(film);
expect(Movie.hasProperty('offset')).toBe(false);
expect(() => Movie.getProperty('offset')).toThrow(
"The property 'offset' is missing (component: 'Movie')"
);
});
test('hasProperty()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
}
expect(Movie.hasProperty('limit')).toBe(true);
expect(Movie.hasProperty('find')).toBe(true);
expect(Movie.hasProperty('offset')).toBe(false);
expect(Movie.prototype.hasProperty('limit')).toBe(false);
});
test('setProperty()', async () => {
class Movie extends Component {}
expect(Movie.hasProperty('limit')).toBe(false);
let setPropertyResult: any = Movie.setProperty('limit', Attribute);
expect(Movie.hasProperty('limit')).toBe(true);
let property = Movie.getProperty('limit');
expect(property).toBe(setPropertyResult);
expect(isPropertyInstance(property)).toBe(true);
expect(isAttributeInstance(property)).toBe(true);
expect(property.getName()).toBe('limit');
expect(property.getParent()).toBe(Movie);
expect(Movie.hasProperty('find')).toBe(false);
setPropertyResult = Movie.setProperty('find', Method);
expect(Movie.hasProperty('find')).toBe(true);
property = Movie.getProperty('find');
expect(property).toBe(setPropertyResult);
expect(isPropertyInstance(property)).toBe(true);
expect(isMethodInstance(property)).toBe(true);
expect(property.getName()).toBe('find');
expect(property.getParent()).toBe(Movie);
class Film extends Movie {}
expect(Film.hasProperty('limit')).toBe(true);
setPropertyResult = Film.setProperty('limit', Attribute);
expect(Film.hasProperty('limit')).toBe(true);
property = Film.getProperty('limit');
expect(property).toBe(setPropertyResult);
expect(isPropertyInstance(property)).toBe(true);
expect(property.getName()).toBe('limit');
expect(property.getParent()).toBe(Film);
});
test('getProperties()', async () => {
class Movie extends Component {
@attribute() title = '';
@attribute() duration = 0;
@method() load() {}
@method() save() {}
}
const movie = new Movie();
let properties = movie.getProperties();
expect(typeof properties[Symbol.iterator]).toBe('function');
expect(Array.from(properties).map((property) => property.getName())).toEqual([
'title',
'duration',
'load',
'save'
]);
const classProperties = Movie.getProperties();
expect(typeof classProperties[Symbol.iterator]).toBe('function');
expect(Array.from(classProperties)).toHaveLength(0);
class Film extends Movie {
@attribute() director?: {name: string};
}
const film = new Film();
properties = film.getProperties();
expect(typeof properties[Symbol.iterator]).toBe('function');
expect(Array.from(properties).map((property) => property.getName())).toEqual([
'title',
'duration',
'load',
'save',
'director'
]);
properties = film.getProperties({
filter(property) {
expect(property.getParent() === this);
return property.getName() !== 'duration';
}
});
expect(typeof properties[Symbol.iterator]).toBe('function');
expect(Array.from(properties).map((property) => property.getName())).toEqual([
'title',
'load',
'save',
'director'
]);
});
test('getPropertyNames()', async () => {
class Movie extends Component {
@attribute() title = '';
@attribute() duration = 0;
@method() load() {}
@method() save() {}
}
expect(Movie.getPropertyNames()).toHaveLength(0);
expect(Movie.prototype.getPropertyNames()).toEqual(['title', 'duration', 'load', 'save']);
const movie = new Movie();
expect(movie.getPropertyNames()).toEqual(['title', 'duration', 'load', 'save']);
class Film extends Movie {
@attribute() director?: {name: string};
}
const film = new Film();
expect(film.getPropertyNames()).toEqual(['title', 'duration', 'load', 'save', 'director']);
});
});
describe('Attributes', () => {
test('getAttribute()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
@attribute() title = '';
}
let attr = Movie.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Movie);
expect(() => Movie.getAttribute('find')).toThrow(
"A property with the specified name was found, but it is not an attribute (method: 'Movie.find')"
);
class Film extends Movie {}
attr = Film.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Film);
const movie = new Movie();
const instanceAttribute = movie.getAttribute('title');
expect(isAttributeInstance(instanceAttribute)).toBe(true);
expect(instanceAttribute.getName()).toBe('title');
expect(instanceAttribute.getParent()).toBe(movie);
const film = new Film();
attr = film.getAttribute('title');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('title');
expect(attr.getParent()).toBe(film);
expect(Movie.hasAttribute('offset')).toBe(false);
expect(() => Movie.getAttribute('offset')).toThrow(
"The attribute 'offset' is missing (component: 'Movie')"
);
});
test('hasAttribute()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
}
expect(Movie.hasAttribute('limit')).toBe(true);
expect(Movie.hasAttribute('offset')).toBe(false);
expect(Movie.prototype.hasAttribute('limit')).toBe(false);
expect(() => Movie.hasAttribute('find')).toThrow(
"A property with the specified name was found, but it is not an attribute (method: 'Movie.find')"
);
});
test('setAttribute()', async () => {
class Movie extends Component {
@method() static find() {}
}
expect(Movie.hasAttribute('limit')).toBe(false);
let setAttributeResult = Movie.setAttribute('limit');
expect(Movie.hasAttribute('limit')).toBe(true);
let attr = Movie.getAttribute('limit');
expect(attr).toBe(setAttributeResult);
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Movie);
expect(() => Movie.setAttribute('find')).toThrow(
"Cannot change the type of a property (method: 'Movie.find')"
);
class Film extends Movie {}
expect(Film.hasAttribute('limit')).toBe(true);
setAttributeResult = Film.setAttribute('limit');
expect(Film.hasAttribute('limit')).toBe(true);
attr = Film.getAttribute('limit');
expect(attr).toBe(setAttributeResult);
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Film);
expect(() => Film.setAttribute('find')).toThrow(
"Cannot change the type of a property (method: 'Film.find')"
);
});
test('getAttributes()', async () => {
class Movie extends Component {
@attribute() title = '';
@attribute() duration = 0;
@method() load() {}
@method() save() {}
}
const movie = new Movie();
let attributes = movie.getAttributes();
expect(typeof attributes[Symbol.iterator]).toBe('function');
expect(Array.from(attributes).map((property) => property.getName())).toEqual([
'title',
'duration'
]);
attributes = Movie.getAttributes();
expect(typeof attributes[Symbol.iterator]).toBe('function');
expect(Array.from(attributes)).toHaveLength(0);
class Film extends Movie {
@attribute() director?: string;
}
const film = Film.instantiate();
film.title = 'Inception';
film.director = 'Christopher Nolan';
attributes = film.getAttributes();
expect(Array.from(attributes).map((property) => property.getName())).toEqual([
'title',
'duration',
'director'
]);
attributes = film.getAttributes({attributeSelector: {title: true, director: true}});
expect(Array.from(attributes).map((property) => property.getName())).toEqual([
'title',
'director'
]);
attributes = film.getAttributes({setAttributesOnly: true});
expect(Array.from(attributes).map((property) => property.getName())).toEqual([
'title',
'director'
]);
attributes = film.getAttributes({
filter(property) {
expect(property.getParent() === this);
return property.getName() !== 'duration';
}
});
expect(Array.from(attributes).map((property) => property.getName())).toEqual([
'title',
'director'
]);
});
test('traverseAttributes()', async () => {
class Article extends EmbeddedComponent {
@attribute('string') title = '';
}
class Blog extends Component {
@provide() static Article = Article;
@attribute('string') name = '';
@attribute('Article[]') articles = new Array();
}
const traverseAttributes = (component: Component, options?: any) => {
const traversedAttributes = new Array();
component.traverseAttributes((attribute) => {
traversedAttributes.push(attribute);
}, options);
return traversedAttributes;
};
expect(traverseAttributes(Blog.prototype)).toEqual([
Blog.prototype.getAttribute('name'),
Blog.prototype.getAttribute('articles'),
Article.prototype.getAttribute('title')
]);
expect(traverseAttributes(Blog.prototype, {attributeSelector: {}})).toEqual([]);
expect(traverseAttributes(Blog.prototype, {attributeSelector: {name: true}})).toEqual([
Blog.prototype.getAttribute('name')
]);
expect(traverseAttributes(Blog.prototype, {attributeSelector: {articles: {}}})).toEqual([
Blog.prototype.getAttribute('articles')
]);
expect(
traverseAttributes(Blog.prototype, {attributeSelector: {articles: {title: true}}})
).toEqual([Blog.prototype.getAttribute('articles'), Article.prototype.getAttribute('title')]);
const blog = Blog.instantiate();
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([]);
blog.name = 'The Blog';
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name')
]);
blog.articles = [];
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name'),
blog.getAttribute('articles')
]);
const article1 = Article.instantiate();
blog.articles.push(article1);
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name'),
blog.getAttribute('articles')
]);
article1.title = 'First Article';
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name'),
blog.getAttribute('articles'),
article1.getAttribute('title')
]);
const article2 = Article.instantiate();
blog.articles.push(article2);
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name'),
blog.getAttribute('articles'),
article1.getAttribute('title')
]);
article2.title = 'Second Article';
expect(traverseAttributes(blog, {setAttributesOnly: true})).toEqual([
blog.getAttribute('name'),
blog.getAttribute('articles'),
article1.getAttribute('title'),
article2.getAttribute('title')
]);
});
test('resolveAttributeSelector()', async () => {
class Person extends EmbeddedComponent {
@attribute('string') name = '';
}
class Movie extends Component {
@provide() static Person = Person;
@attribute('string') title = '';
@attribute('number') duration = 0;
@attribute('Person?') director?: Person;
@attribute('Person[]') actors: Person[] = [];
}
expect(Movie.prototype.resolveAttributeSelector(true)).toStrictEqual({
title: true,
duration: true,
director: {name: true},
actors: {name: true}
});
expect(Movie.prototype.resolveAttributeSelector(false)).toStrictEqual({});
expect(Movie.prototype.resolveAttributeSelector({})).toStrictEqual({});
expect(Movie.prototype.resolveAttributeSelector({title: true, director: true})).toStrictEqual(
{
title: true,
director: {name: true}
}
);
expect(
Movie.prototype.resolveAttributeSelector({title: true, director: false})
).toStrictEqual({
title: true
});
expect(Movie.prototype.resolveAttributeSelector({title: true, director: {}})).toStrictEqual({
title: true,
director: {}
});
expect(Movie.prototype.resolveAttributeSelector(true, {depth: 0})).toStrictEqual({
title: true,
duration: true,
director: true,
actors: true
});
expect(
Movie.prototype.resolveAttributeSelector({title: true, actors: true}, {depth: 0})
).toStrictEqual({title: true, actors: true});
const movie = Movie.instantiate();
expect(movie.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({});
movie.getAttribute('title').setValue('Interception', {source: 'client'});
movie.getAttribute('duration').setValue(120, {source: 'client'});
expect(movie.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
title: true,
duration: true
});
expect(
movie.resolveAttributeSelector(true, {setAttributesOnly: true, target: 'client'})
).toStrictEqual({});
movie.getAttribute('title').setValue('Interception 2', {source: 'local'});
expect(
movie.resolveAttributeSelector(true, {setAttributesOnly: true, target: 'client'})
).toStrictEqual({title: true});
// --- With an embedded component ---
class UserDetails extends EmbeddedComponent {
@attribute('string') country = '';
}
class User extends Component {
@provide() static UserDetails = UserDetails;
@attribute('string') name = '';
@attribute('UserDetails?') details?: UserDetails;
}
expect(User.prototype.resolveAttributeSelector(true)).toStrictEqual({
name: true,
details: {country: true}
});
const user = User.instantiate();
expect(user.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({});
user.name = 'John';
expect(user.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true
});
user.details = UserDetails.instantiate();
expect(user.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
details: {}
});
user.details.country = 'USA';
expect(user.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
details: {country: true}
});
// --- With an array of embedded components ---
class Article extends EmbeddedComponent {
@attribute('string') title = '';
}
class Blog extends Component {
@provide() static Article = Article;
@attribute('string') name = '';
@attribute('Article[]') articles = new Array();
}
expect(Blog.prototype.resolveAttributeSelector(true)).toStrictEqual({
name: true,
articles: {title: true}
});
expect(Blog.prototype.resolveAttributeSelector({articles: {}})).toStrictEqual({
articles: {}
});
expect(
Blog.prototype.resolveAttributeSelector({articles: {}}, {allowPartialArrayItems: false})
).toStrictEqual({
articles: {title: true}
});
const blog = Blog.instantiate();
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({});
blog.name = 'The Blog';
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true
});
blog.articles = [];
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
articles: {}
});
const article1 = Article.instantiate();
blog.articles.push(article1);
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
articles: {}
});
article1.title = 'First Article';
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
articles: {title: true}
});
const article2 = Article.instantiate();
blog.articles.push(article2);
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
articles: {title: true}
});
expect(
blog.resolveAttributeSelector(true, {
setAttributesOnly: true,
aggregationMode: 'intersection'
})
).toStrictEqual({
name: true,
articles: {}
});
article2.title = 'Second Article';
expect(blog.resolveAttributeSelector(true, {setAttributesOnly: true})).toStrictEqual({
name: true,
articles: {title: true}
});
expect(
blog.resolveAttributeSelector(true, {
setAttributesOnly: true,
aggregationMode: 'intersection'
})
).toStrictEqual({
name: true,
articles: {title: true}
});
});
test('Validation', async () => {
const notEmpty = validators.notEmpty();
const maxLength = validators.maxLength(3);
class Person extends EmbeddedComponent {
@attribute('string', {validators: [notEmpty]}) name = '';
@attribute('string?') country?: string;
}
class Movie extends Component {
@provide() static Person = Person;
@attribute('string', {validators: [notEmpty]}) title = '';
@attribute('string[]', {
validators: [maxLength],
items: {validators: [notEmpty]}
})
tags: string[] = [];
@attribute('Person?') director?: Person;
@attribute('Person[]') actors: Person[] = [];
}
const movie = new Movie();
expect(() => movie.validate()).toThrow(
"The following error(s) occurred while validating the component 'Movie': The validator `notEmpty()` failed (path: 'title')"
);
expect(movie.isValid()).toBe(false);
expect(movie.runValidators()).toEqual([{validator: notEmpty, path: 'title'}]);
expect(movie.runValidators({title: true})).toEqual([{validator: notEmpty, path: 'title'}]);
expect(movie.runValidators({tags: true})).toEqual([]);
movie.title = 'Inception';
expect(() => movie.validate()).not.toThrow();
expect(movie.isValid()).toBe(true);
expect(movie.runValidators()).toEqual([]);
movie.tags = ['action'];
expect(() => movie.validate()).not.toThrow();
expect(movie.isValid()).toBe(true);
expect(movie.runValidators()).toEqual([]);
movie.tags.push('adventure');
expect(() => movie.validate()).not.toThrow();
expect(movie.isValid()).toBe(true);
expect(movie.runValidators()).toEqual([]);
movie.tags.push('');
expect(() => movie.validate()).toThrow(
"The following error(s) occurred while validating the component 'Movie': The validator `notEmpty()` failed (path: 'tags[2]')"
);
expect(movie.isValid()).toBe(false);
expect(movie.runValidators()).toEqual([{validator: notEmpty, path: 'tags[2]'}]);
expect(movie.runValidators({tags: true})).toEqual([{validator: notEmpty, path: 'tags[2]'}]);
expect(movie.runValidators({title: true})).toEqual([]);
movie.tags.push('sci-fi');
expect(() => movie.validate()).toThrow(
"The following error(s) occurred while validating the component 'Movie': The validator `maxLength(3)` failed (path: 'tags'), The validator `notEmpty()` failed (path: 'tags[2]')"
);
expect(movie.isValid()).toBe(false);
expect(movie.runValidators()).toEqual([
{validator: maxLength, path: 'tags'},
{validator: notEmpty, path: 'tags[2]'}
]);
movie.tags.splice(2, 1);
movie.director = new Person();
expect(() => movie.validate()).toThrow(
"The following error(s) occurred while validating the component 'Movie': The validator `notEmpty()` failed (path: 'director.name')"
);
expect(movie.isValid()).toBe(false);
expect(movie.runValidators()).toEqual([{validator: notEmpty, path: 'director.name'}]);
expect(movie.runValidators({director: {name: true}})).toEqual([
{validator: notEmpty, path: 'director.name'}
]);
expect(movie.runValidators({director: {country: true}})).toEqual([]);
movie.director.name = 'Christopher Nolan';
expect(() => movie.validate()).not.toThrow();
expect(movie.isValid()).toBe(true);
expect(movie.runValidators()).toEqual([]);
movie.actors.push(new Person());
expect(() => movie.validate()).toThrow(
"The following error(s) occurred while validating the component 'Movie': The validator `notEmpty()` failed (path: 'actors[0].name')"
);
expect(movie.isValid()).toBe(false);
expect(movie.runValidators()).toEqual([{validator: notEmpty, path: 'actors[0].name'}]);
movie.actors[0].name = 'Leonardo DiCaprio';
expect(() => movie.validate()).not.toThrow();
expect(movie.isValid()).toBe(true);
expect(movie.runValidators()).toEqual([]);
// --- With a custom message ---
class Cinema extends Component {
@attribute('string', {validators: [validators.notEmpty('The name cannot be empty.')]})
name = '';
}
const cinema = new Cinema({name: 'Paradiso'});
expect(() => cinema.validate()).not.toThrow();
cinema.name = '';
let error: ExtendedError;
try {
cinema.validate();
} catch (err: any) {
error = err;
}
expect(error!.message).toBe(
"The following error(s) occurred while validating the component 'Cinema': The name cannot be empty. (path: 'name')"
);
expect(error!.displayMessage).toBe('The name cannot be empty.');
});
});
describe('Methods', () => {
test('getMethod()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
@method() load() {}
}
let meth = Movie.getMethod('find');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Movie);
expect(() => Movie.getMethod('limit')).toThrow(
"A property with the specified name was found, but it is not a method (attribute: 'Movie.limit')"
);
class Film extends Movie {}
meth = Film.getMethod('find');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Film);
const movie = new Movie();
meth = movie.getMethod('load');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('load');
expect(meth.getParent()).toBe(movie);
const film = new Film();
meth = film.getMethod('load');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('load');
expect(meth.getParent()).toBe(film);
expect(Movie.hasMethod('load')).toBe(false);
expect(() => Movie.getMethod('load')).toThrow(
"The method 'load' is missing (component: 'Movie')"
);
});
test('hasMethod()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@method() static find() {}
}
expect(Movie.hasMethod('find')).toBe(true);
expect(Movie.hasMethod('load')).toBe(false);
expect(Movie.prototype.hasMethod('find')).toBe(false);
expect(() => Movie.hasMethod('limit')).toThrow(
"A property with the specified name was found, but it is not a method (attribute: 'Movie.limit')"
);
});
test('setMethod()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
}
expect(Movie.hasMethod('find')).toBe(false);
let setMethodResult = Movie.setMethod('find');
expect(Movie.hasMethod('find')).toBe(true);
let meth = Movie.getMethod('find');
expect(meth).toBe(setMethodResult);
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Movie);
expect(() => Movie.setMethod('limit')).toThrow(
"Cannot change the type of a property (attribute: 'Movie.limit')"
);
class Film extends Movie {}
expect(Film.hasMethod('find')).toBe(true);
setMethodResult = Film.setMethod('find');
expect(Film.hasMethod('find')).toBe(true);
meth = Film.getMethod('find');
expect(meth).toBe(setMethodResult);
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Film);
expect(() => Film.setMethod('limit')).toThrow(
"Cannot change the type of a property (attribute: 'Film.limit')"
);
});
test('getMethods()', async () => {
class Movie extends Component {
@attribute() title = '';
@attribute() duration = 0;
@method() load() {}
@method() save() {}
}
const movie = new Movie();
let methods = movie.getMethods();
expect(typeof methods[Symbol.iterator]).toBe('function');
expect(Array.from(methods).map((property) => property.getName())).toEqual(['load', 'save']);
methods = Movie.getMethods();
expect(typeof methods[Symbol.iterator]).toBe('function');
expect(Array.from(methods)).toHaveLength(0);
class Film extends Movie {
@method() delete() {}
}
const film = new Film();
methods = film.getMethods();
expect(typeof methods[Symbol.iterator]).toBe('function');
expect(Array.from(methods).map((property) => property.getName())).toEqual([
'load',
'save',
'delete'
]);
methods = film.getMethods({
filter(property) {
expect(property.getParent() === this);
return property.getName() !== 'save';
}
});
expect(typeof methods[Symbol.iterator]).toBe('function');
expect(Array.from(methods).map((property) => property.getName())).toEqual(['load', 'delete']);
});
});
describe('Dependency management', () => {
test('Component getters', async () => {
class Movie extends Component {
@consume() static Director: typeof Director;
}
class Director extends Component {
@consume() static Movie: typeof Movie;
}
class App extends Component {
@provide() static Movie = Movie;
@provide() static Director = Director;
}
expect(App.getComponent('App')).toBe(App);
expect(App.getComponent('Movie')).toBe(Movie);
expect(App.getComponent('Director')).toBe(Director);
expect(Movie.getComponent('Director')).toBe(Director);
expect(Director.getComponent('Movie')).toBe(Movie);
expect(App.hasComponent('Movie')).toBe(true);
expect(App.hasComponent('Producer')).toBe(false);
expect(() => App.getComponent('Producer')).toThrow(
"Cannot get the component 'Producer' from the component 'App'"
);
expect(App.getComponentOfType('typeof Movie')).toBe(Movie);
expect(App.getComponentOfType('Movie')).toBe(Movie.prototype);
expect(App.hasComponentOfType('typeof Movie')).toBe(true);
expect(App.hasComponentOfType('Movie')).toBe(true);
expect(App.hasComponentOfType('typeof Producer')).toBe(false);
expect(App.hasComponentOfType('Producer')).toBe(false);
expect(() => App.getComponentOfType('typeof Producer')).toThrow(
"Cannot get the component of type 'typeof Producer' from the component 'App'"
);
});
test('Component provision', async () => {
class Movie extends Component {
static Director: typeof Director;
static Actor: typeof Actor;
static Producer: typeof Producer;
}
class Director extends Component {}
class Actor extends Component {}
class Producer extends Component {}
expect(Movie.getProvidedComponent('Director')).toBeUndefined();
expect(Movie.getProvidedComponent('Actor')).toBeUndefined();
expect(Movie.getProvidedComponent('Producer')).toBeUndefined();
expect(Movie.getComponentProvider()).toBe(Movie);
expect(Director.getComponentProvider()).toBe(Director);
expect(Actor.getComponentProvider()).toBe(Actor);
Movie.provideComponent(Director);
Movie.provideComponent(Actor);
// Providing the same component a second time should have no effects
Movie.provideComponent(Director);
expect(Movie.getProvidedComponent('Director')).toBe(Director);
expect(Movie.getProvidedComponent('Actor')).toBe(Actor);
expect(Movie.getProvidedComponent('Producer')).toBeUndefined();
// Provided components should also be accessible through component accessors
expect(Movie.Director).toBe(Director);
expect(Movie.Actor).toBe(Actor);
expect(Movie.Producer).toBe(undefined);
// And through `getComponent()`
expect(Movie.getComponent('Director')).toBe(Director);
expect(Movie.getComponent('Actor')).toBe(Actor);
expect(Movie.hasComponent('Producer')).toBe(false);
// It should be possible to get the component provider of a provided component
expect(Director.getComponent('Movie')).toBe(Movie);
expect(Array.from(Movie.getProvidedComponents())).toEqual([Director, Actor]);
expect(Movie.getComponentProvider()).toBe(Movie);
expect(Director.getComponentProvider()).toBe(Movie);
expect(Actor.getComponentProvider()).toBe(Movie);
const MovieFork = Movie.fork();
const DirectorFork = MovieFork.Director;
expect(DirectorFork?.isForkOf(Director)).toBe(true);
expect(DirectorFork).not.toBe(Director);
const SameDirectorFork = MovieFork.Director;
expect(SameDirectorFork).toBe(DirectorFork);
expect(DirectorFork.getComponentProvider()).toBe(MovieFork);
class Root extends Component {}
Root.provideComponent(Movie);
expect(Array.from(Root.getProvidedComponents())).toEqual([Movie]);
expect(Array.from(Root.getProvidedComponents({deep: true}))).toEqual([
Movie,
Director,
Actor
]);
expect(Array.from(Root.traverseComponents())).toEqual([Root, Movie, Director, Actor]);
class Film extends Component {}
expect(() => Film.provideComponent(Director)).toThrow(
"Cannot provide the component 'Director' from 'Film' because 'Director' is already provided by 'Movie'"
);
class OtherComponent extends Component {}
OtherComponent.setComponentName('Director');
expect(() => Movie.provideComponent(OtherComponent)).toThrow(
"Cannot provide the component 'Director' from 'Movie' because a component with the same name is already provided"
);
(Movie as any).Producer = {};
expect(() => Movie.provideComponent(Producer)).toThrow(
"Cannot provide the component 'Producer' from 'Movie' because there is an existing property with the same name"
);
});
test('Component consumption', async () => {
class Movie extends Component {
static Director: typeof Director;
static Producer: typeof Producer;
}
class Director extends Component {
static Movie: typeof Movie;
}
class Producer extends Component {}
class App extends Component {
@provide() static Movie = Movie;
@provide() static Director = Director;
static Producer: typeof Producer;
}
expect(Movie.getConsumedComponent('Director')).toBeUndefined();
expect(Director.getConsumedComponent('Movie')).toBeUndefined();
expect(Movie.getConsumedComponent('Producer')).toBeUndefined();
expect(Movie.getComponentProvider()).toBe(App);
expect(Director.getComponentProvider()).toBe(App);
Movie.consumeComponent('Director');
Director.consumeComponent('Movie');
// Consuming the same component a second time should have no effects
Movie.consumeComponent('Director');
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Movie.getConsumedComponent('Producer')).toBeUndefined();
// Consumed components should also be accessible through component accessors
expect(Movie.Director).toBe(Director);
expect(Director.Movie).toBe(Movie);
expect(Movie.Producer).toBe(undefined);
// And through `getComponent()`
expect(Movie.getComponent('Director')).toBe(Director);
expect(Director.getComponent('Movie')).toBe(Movie);
expect(Movie.hasComponent('Producer')).toBe(false);
expect(Array.from(Movie.getConsumedComponents())).toEqual([Director]);
expect(Array.from(Director.getConsumedComponents())).toEqual([Movie]);
const AppFork = App.fork();
const MovieFork = AppFork.Movie;
const DirectorFork = AppFork.Director;
expect(MovieFork?.isForkOf(Movie)).toBe(true);
expect(MovieFork).not.toBe(Movie);
expect(DirectorFork?.isForkOf(Director)).toBe(true);
expect(DirectorFork).not.toBe(Director);
const SameMovieFork = AppFork.Movie;
const SameDirectorFork = AppFork.Director;
expect(SameMovieFork).toBe(MovieFork);
expect(SameDirectorFork).toBe(DirectorFork);
expect(MovieFork.Director).toBe(DirectorFork);
expect(DirectorFork.Movie).toBe(MovieFork);
expect(MovieFork.getComponentProvider()).toBe(AppFork);
expect(DirectorFork.getComponentProvider()).toBe(AppFork);
(Movie as any).Producer = Producer;
expect(() => Movie.consumeComponent('Producer')).toThrow(
"Cannot consume the component 'Producer' from 'Movie' because there is an existing property with the same name"
);
});
});
describe('Attachment', () => {
test('attach(), detach(), isAttached(), and isDetached()', async () => {
class Movie extends Component {}
class App extends Component {
@provide() static Movie = Movie;
}
const movie = new Movie();
const otherMovie = new Movie();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(true);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(false);
expect(otherMovie.isDetached()).toBe(false);
App.detach();
expect(App.isAttached()).toBe(false);
expect(Movie.isAttached()).toBe(false);
expect(movie.isAttached()).toBe(false);
expect(otherMovie.isAttached()).toBe(false);
expect(App.isDetached()).toBe(true);
expect(Movie.isDetached()).toBe(true);
expect(movie.isDetached()).toBe(true);
expect(otherMovie.isDetached()).toBe(true);
App.attach();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(true);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(false);
expect(otherMovie.isDetached()).toBe(false);
Movie.detach();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(false);
expect(movie.isAttached()).toBe(false);
expect(otherMovie.isAttached()).toBe(false);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(true);
expect(movie.isDetached()).toBe(true);
expect(otherMovie.isDetached()).toBe(true);
Movie.attach();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(true);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(false);
expect(otherMovie.isDetached()).toBe(false);
movie.detach();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(false);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(true);
expect(otherMovie.isDetached()).toBe(false);
movie.attach();
expect(App.isAttached()).toBe(true);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(true);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(false);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(false);
expect(otherMovie.isDetached()).toBe(false);
App.detach();
// Since Movie and movie has been explicitly attached,
// they should remain so even though App is detached
expect(App.isAttached()).toBe(false);
expect(Movie.isAttached()).toBe(true);
expect(movie.isAttached()).toBe(true);
expect(otherMovie.isAttached()).toBe(true);
expect(App.isDetached()).toBe(true);
expect(Movie.isDetached()).toBe(false);
expect(movie.isDetached()).toBe(false);
expect(otherMovie.isDetached()).toBe(false);
});
});
describe('Execution mode', () => {
test('getExecutionMode() and setExecutionMode()', async () => {
class Movie extends Component {}
class App extends Component {
@provide() static Movie = Movie;
}
expect(App.getExecutionMode()).toBe('foreground');
expect(Movie.getExecutionMode()).toBe('foreground');
App.setExecutionMode('background');
expect(App.getExecutionMode()).toBe('background');
expect(Movie.getExecutionMode()).toBe('background');
expect(() => {
App.setExecutionMode('foreground');
}).toThrow();
});
});
describe('Introspection', () => {
test('introspect()', async () => {
class Movie extends EmbeddedComponent {
@consume() static Cinema: typeof Cinema;
@attribute('number') static limit = 100;
@attribute('number?') static offset?: number;
@method() static find() {}
@attribute('string') title = '';
@attribute('string?') country?: string;
@method() load() {}
}
class Cinema extends Component {
@provide() static Movie = Movie;
@attribute('Movie[]?') movies?: Movie[];
}
const defaultTitle = Movie.prototype.getAttribute('title').getDefault();
expect(typeof defaultTitle).toBe('function');
expect(Movie.introspect()).toBeUndefined();
expect(Cinema.introspect()).toBeUndefined();
Cinema.prototype.getAttribute('movies').setExposure({get: true});
expect(Cinema.introspect()).toStrictEqual({
name: 'Cinema',
prototype: {
properties: [
{name: 'movies', type: 'Attribute', valueType: 'Movie[]?', exposure: {get: true}}
]
}
});
Movie.getAttribute('limit').setExposure({get: true});
expect(Movie.introspect()).toStrictEqual({
name: 'Movie',
isEmbedded: true,
properties: [
{name: 'limit', type: 'Attribute', valueType: 'number', value: 100, exposure: {get: true}}
],
consumedComponents: ['Cinema']
});
expect(Cinema.introspect()).toStrictEqual({
name: 'Cinema',
prototype: {
properties: [
{name: 'movies', type: 'Attribute', valueType: 'Movie[]?', exposure: {get: true}}
]
},
providedComponents: [
{
name: 'Movie',
isEmbedded: true,
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
}
],
consumedComponents: ['Cinema']
}
]
});
Movie.getAttribute('limit').setExposure({get: true});
Movie.getAttribute('offset').setExposure({get: true});
Movie.getMethod('find').setExposure({call: true});
expect(Movie.introspect()).toStrictEqual({
name: 'Movie',
isEmbedded: true,
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
},
{
name: 'offset',
type: 'Attribute',
valueType: 'number?',
value: undefined,
exposure: {get: true}
},
{name: 'find', type: 'Method', exposure: {call: true}}
],
consumedComponents: ['Cinema']
});
Movie.prototype.getAttribute('title').setExposure({get: true, set: true});
Movie.prototype.getAttribute('country').setExposure({get: true});
Movie.prototype.getMethod('load').setExposure({call: true});
expect(Movie.introspect()).toStrictEqual({
name: 'Movie',
isEmbedded: true,
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
},
{
name: 'offset',
type: 'Attribute',
valueType: 'number?',
value: undefined,
exposure: {get: true}
},
{name: 'find', type: 'Method', exposure: {call: true}}
],
prototype: {
properties: [
{
name: 'title',
type: 'Attribute',
valueType: 'string',
default: defaultTitle,
exposure: {get: true, set: true}
},
{name: 'country', type: 'Attribute', valueType: 'string?', exposure: {get: true}},
{name: 'load', type: 'Method', exposure: {call: true}}
]
},
consumedComponents: ['Cinema']
});
// --- With a mixin ---
const Storable = (Base = Component) => {
const _Storable = class extends Base {};
Object.defineProperty(_Storable, '__mixin', {value: 'Storable'});
return _Storable;
};
class Film extends Storable(Component) {
@expose({get: true, set: true}) @attribute('string?') title?: string;
}
expect(Film.introspect()).toStrictEqual({
name: 'Film',
mixins: ['Storable'],
prototype: {
properties: [
{
name: 'title',
type: 'Attribute',
valueType: 'string?',
exposure: {get: true, set: true}
}
]
}
});
});
test('unintrospect()', async () => {
const defaultTitle = function () {
return '';
};
const Cinema = Component.unintrospect({
name: 'Cinema',
prototype: {
properties: [
{name: 'movies', type: 'Attribute', valueType: 'Movie[]?', exposure: {get: true}}
]
},
providedComponents: [
{
name: 'Movie',
isEmbedded: true,
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true, set: true}
},
{name: 'find', type: 'Method', exposure: {call: true}}
],
prototype: {
properties: [
{
name: 'title',
type: 'Attribute',
valueType: 'string',
default: defaultTitle,
exposure: {get: true, set: true}
}
]
},
consumedComponents: ['Cinema']
}
]
});
expect(Cinema.getComponentName()).toBe('Cinema');
expect(Cinema.isEmbedded()).toBe(false);
expect(Cinema.prototype.getAttribute('movies').getValueType().toString()).toBe('Movie[]?');
expect(Cinema.prototype.getAttribute('movies').getExposure()).toStrictEqual({get: true});
expect(Cinema.prototype.getAttribute('movies').isControlled()).toBe(true);
const Movie = Cinema.getProvidedComponent('Movie')!;
expect(Movie.getComponentName()).toBe('Movie');
expect(Movie.isEmbedded()).toBe(true);
expect(Movie.getAttribute('limit').getValue()).toBe(100);
expect(Movie.getAttribute('limit').getExposure()).toStrictEqual({get: true, set: true});
expect(Movie.getAttribute('limit').isControlled()).toBe(false);
expect(Movie.getMethod('find').getExposure()).toStrictEqual({call: true});
expect(Movie.getConsumedComponent('Cinema')).toBe(Cinema);
expect(Movie.prototype.getAttribute('title').getDefault()).toBe(defaultTitle);
expect(Movie.prototype.getAttribute('title').getExposure()).toStrictEqual({
get: true,
set: true
});
expect(Movie.prototype.getAttribute('title').isControlled()).toBe(false);
const movie = new Movie();
expect(movie.getAttribute('title').getValue()).toBe('');
// --- With a mixin ---
const Storable = (Base = Component) => {
const _Storable = class extends Base {
static storableMethod() {}
};
Object.defineProperty(_Storable, '__mixin', {value: 'Storable'});
return _Storable;
};
const Film = Component.unintrospect(
{
name: 'Film',
mixins: ['Storable'],
prototype: {
properties: [
{
name: 'title',
type: 'Attribute',
valueType: 'string?',
exposure: {get: true, set: true}
}
]
}
},
{mixins: [Storable]}
);
expect(Film.getComponentName()).toBe('Film');
expect(Film.prototype.getAttribute('title').getValueType().toString()).toBe('string?');
expect(typeof (Film as any).storableMethod).toBe('function');
expect(() => Component.unintrospect({name: 'Film', mixins: ['Storable']})).toThrow(
"Couldn't find a component mixin named 'Storable'. Please make sure you specified it when creating your 'ComponentClient'."
);
});
});
describe('Utilities', () => {
test('toObject()', async () => {
class MovieDetails extends EmbeddedComponent {
@attribute() duration = 0;
}
class Movie extends Component {
@provide() static MovieDetails = MovieDetails;
@attribute() title = '';
@attribute('MovieDetails') details!: MovieDetails;
}
const movie = new Movie({title: 'Inception', details: new MovieDetails({duration: 120})});
expect(movie.toObject()).toStrictEqual({title: 'Inception', details: {duration: 120}});
class Cinema extends Component {
@provide() static Movie = Movie;
@attribute() name = '';
@attribute('Movie[]') movies = new Array();
}
const cinema = new Cinema({name: 'Paradiso', movies: [movie]});
expect(cinema.toObject()).toStrictEqual({
name: 'Paradiso',
movies: [{title: 'Inception', details: {duration: 120}}]
});
});
});
});
================================================
FILE: packages/component/src/component.ts
================================================
import {Observable} from '@layr/observable';
import {throwError} from '@layr/utilities';
import {
hasOwnProperty,
isPrototypeOf,
getTypeOf,
PlainObject,
isPlainObject,
PromiseLikeable,
getFunctionName,
assertIsFunction
} from 'core-helpers';
import {possiblyAsync} from 'possibly-async';
import cuid from 'cuid';
import isEmpty from 'lodash/isEmpty';
import {
Property,
PropertyOptions,
PropertyOperationSetting,
PropertyFilterSync,
IntrospectedProperty,
Attribute,
isAttributeClass,
isAttributeInstance,
AttributeOptions,
ValueSource,
IntrospectedAttribute,
IdentifierAttribute,
isIdentifierAttributeInstance,
PrimaryIdentifierAttribute,
isPrimaryIdentifierAttributeInstance,
SecondaryIdentifierAttribute,
isSecondaryIdentifierAttributeInstance,
IdentifierValue,
AttributeSelector,
getFromAttributeSelector,
setWithinAttributeSelector,
normalizeAttributeSelector,
Method,
isMethodInstance,
MethodOptions,
IntrospectedMethod,
isComponentValueTypeInstance
} from './properties';
import {IdentityMap} from './identity-map';
import {clone, CloneOptions} from './cloning';
import {ForkOptions} from './forking';
import {merge, MergeOptions} from './merging';
import {SerializeOptions} from './serialization';
import {DeserializeOptions} from './deserialization';
import {
isComponentClass,
isComponentInstance,
isComponentClassOrInstance,
assertIsComponentClass,
assertIsComponentInstance,
ensureComponentClass,
assertIsComponentName,
getComponentNameFromComponentClassType,
getComponentNameFromComponentInstanceType,
assertIsComponentType,
getComponentClassTypeFromComponentName,
getComponentInstanceTypeFromComponentName,
joinAttributePath
} from './utilities';
export type ComponentSet = Set;
export type ComponentMixin = (Base: typeof Component) => typeof Component;
export type TraverseAttributesIteratee = (attribute: Attribute) => void;
export type TraverseAttributesOptions = {
attributeSelector: AttributeSelector;
setAttributesOnly: boolean;
};
export type IdentifierDescriptor = NormalizedIdentifierDescriptor | IdentifierValue;
export type NormalizedIdentifierDescriptor = {[name: string]: IdentifierValue};
export type IdentifierSelector = NormalizedIdentifierSelector | IdentifierValue;
export type NormalizedIdentifierSelector = {[name: string]: IdentifierValue};
export type ResolveAttributeSelectorOptions = {
filter?: PropertyFilterSync;
setAttributesOnly?: boolean;
target?: ValueSource;
aggregationMode?: 'union' | 'intersection';
includeReferencedComponents?: boolean;
alwaysIncludePrimaryIdentifierAttributes?: boolean;
allowPartialArrayItems?: boolean;
depth?: number;
_isDeep?: boolean;
_skipUnchangedAttributes?: boolean;
_isArrayItem?: boolean;
_attributeStack?: Set;
};
type MethodBuilder = (name: string) => Function;
export type ExecutionMode = 'foreground' | 'background';
export type IntrospectedComponent = {
name: string;
isEmbedded?: boolean;
mixins?: string[];
properties?: (IntrospectedProperty | IntrospectedAttribute | IntrospectedMethod)[];
prototype?: {
properties?: (IntrospectedProperty | IntrospectedAttribute | IntrospectedMethod)[];
};
providedComponents?: IntrospectedComponent[];
consumedComponents?: string[];
};
type IntrospectedComponentMap = Map;
/**
* *Inherits from [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class).*
*
* A component is an elementary building block allowing you to define your data models and implement the business logic of your app. Typically, an app is composed of several components that are connected to each other by using the [`@provide()`](https://layrjs.com/docs/v2/reference/component#provide-decorator) and [`@consume()`](https://layrjs.com/docs/v2/reference/component#consume-decorator) decorators.
*
* #### Usage
*
* Just extend the `Component` class to define a component with some attributes and methods that are specific to your app.
*
* For example, a `Movie` component with a `title` attribute and a `play()` method could be defined as follows:
*
* ```
* // JS
*
* import {Component} from '@layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title;
*
* ﹫method() play() {
* console.log(`Playing '${this.title}...'`);
* }
* }
* ```
*
* ```
* // TS
*
* import {Component} from '@layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title!: string;
*
* ﹫method() play() {
* console.log(`Playing '${this.title}...'`);
* }
* }
* ```
*
* The [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) and [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorators allows you to get the full power of Layr, such as attribute validation or remote method invocation.
*
* > Note that you don't need to use the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator for all your methods. Typically, you use this decorator only for some backend methods that you want to expose to the frontend.
*
* Once you have defined a component, you can use it as any JavaScript/TypeScript class:
*
* ```
* const movie = new Movie({title: 'Inception'});
*
* movie.play(); // => 'Playing Inception...'
* ```
*
* #### Embedded Components
*
* Use the [`EmbeddedComponent`](https://layrjs.com/docs/v2/reference/embedded-component) class to embed a component into another component. An embedded component is strongly attached to the parent component that owns it, and it cannot "live" by itself like a regular component.
*
* Here are some characteristics of an embedded component:
*
* - An embedded component has one parent only, and therefore cannot be embedded in more than one component.
* - When the parent of an embedded component is [validated](https://layrjs.com/docs/v2/reference/validator), the embedded component is validated as well.
* - When the parent of an embedded component is loaded, saved, or deleted (using a [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storage-operations) method), the embedded component is loaded, saved, or deleted as well.
*
* See the [`EmbeddedComponent`](https://layrjs.com/docs/v2/reference/embedded-component) class for an example of use.
*
* #### Referenced Components
*
* Any non-embedded component can be referenced by another component. Contrary to an embedded component, a referenced component is an independent entity that can "live" by itself. So a referenced component behaves like a regular JavaScript object that can be referenced by another object.
*
* Here are some characteristics of a referenced component:
*
* - A referenced component can be referenced by any number of components.
* - When a component holding a reference to another component is [validated](https://layrjs.com/docs/v2/reference/validator), the referenced component is considered as an independent entity, and is therefore not validated automatically.
* - When a component holding a reference to another component is loaded, saved, or deleted (using a [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storage-operations) method), the referenced component may optionally be loaded in the same operation, but it has to be saved or deleted independently.
*
* For example, let's say we have a `Director` component defined as follows:
*
* ```
* // JS
*
* class Director extends Component {
* ﹫attribute('string') fullName;
* }
* ```
*
* ```
* // TS
*
* class Director extends Component {
* ﹫attribute('string') fullName!: string;
* }
* ```
*
* Next, we can add an attribute to the `Movie` component to store a reference to a `Director`:
*
* ```
* // JS
*
* class Movie extends Component {
* ﹫provide() static Director = Director;
*
* // ...
*
* ﹫attribute('Director') director;
* }
* ```
*
* ```
* // TS
*
* class Movie extends Component {
* ﹫provide() static Director = Director;
*
* // ...
*
* ﹫attribute('Director') director!: Director;
* }
* ```
*
* > Note that to be able to specify the `'Director'` type for the `director` attribute, you first have to make the `Movie` component aware of the `Director` component by using the [`@provide()`](https://layrjs.com/docs/v2/reference/component#provide-decorator) decorator.
*
* Then, to create a `Movie` with a `Director`, we can do something like the following:
*
* ```
* const movie = new Movie({
* title: 'Inception',
* director: new Director({fullName: 'Christopher Nolan'})
* });
*
* movie.title; // => 'Inception'
* movie.director.fullName; // => 'Christopher Nolan'
* ```
*/
export class Component extends Observable(Object) {
declare ['constructor']: typeof Component;
// === Creation ===
/**
* Creates an instance of a component class.
*
* @param [object] An optional object specifying the value of the component attributes.
*
* @returns The component instance that was created.
*
* @example
* ```
* // JS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title // => 'Inception'
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title!: string;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title // => 'Inception'
* ```
*
* @category Creation
*/
constructor(object: PlainObject = {}) {
super();
this.markAsNew();
for (const attribute of this.getAttributes()) {
const name = attribute.getName();
let value;
if (hasOwnProperty(object, name)) {
value = object[name];
} else {
if (attribute.isControlled()) {
continue; // Controlled attributes should not be set
}
value = attribute.evaluateDefault();
}
attribute.setValue(value);
attribute._fixDecoration();
}
}
static instantiate(
this: T,
identifiers?: IdentifierSelector,
options: {
source?: ValueSource;
} = {}
): InstanceType {
const {source} = options;
let component: InstanceType | undefined;
if (this.prototype.hasPrimaryIdentifierAttribute()) {
if (identifiers === undefined) {
throw new Error(
`An identifier is required to instantiate an identifiable component, but received a value of type '${getTypeOf(
identifiers
)}' (${this.describeComponent()})`
);
}
identifiers = this.normalizeIdentifierSelector(identifiers);
component = this.getIdentityMap().getComponent(identifiers) as InstanceType | undefined;
if (component === undefined) {
component = Object.create(this.prototype);
for (const [name, value] of Object.entries(identifiers)) {
component!.getAttribute(name).setValue(value, {source});
}
}
} else {
// The component does not have an identifier attribute
if (!(identifiers === undefined || (isPlainObject(identifiers) && isEmpty(identifiers)))) {
throw new Error(
`Cannot instantiate an unidentifiable component with an identifier (${this.describeComponent()})`
);
}
component = Object.create(this.prototype);
}
return component!;
}
// === Initialization ===
/**
* Invoke this method to call any `initializer()` static method defined in your component classes.
*
* If the current class has an `initializer()` static method, it is invoked, and if the current class has some other components as dependencies, the `initializer()` method is also invoked for those components.
*
* Note that your `initializer()` methods can be asynchronous, and therefore you should call the `initialize()` method with `await`.
*
* Typically, you will call the `initialize()` method on the root component of your frontend service when your app starts. Backend services are usually managed by a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server), which automatically invokes the `initialize()` method on the root component.
*
* Note that if you use [Boostr](https://boostr.dev) to manage your frontend service, you should not call the `initialize()` method manually.
*
* @category Initialization
* @possiblyasync
*/
static initialize() {
return possiblyAsync.forEach(this.traverseComponents(), (component) => {
const initializer = (component as any).initializer;
if (typeof initializer === 'function') {
return initializer.call(component);
}
});
}
// === Naming ===
static getBaseComponentName() {
return 'Component';
}
/**
* Returns the name of the component, which is usually the name of the corresponding class.
*
* @returns A string.
*
* @example
* ```
* Movie.getComponentName(); // => 'Movie'
* ```
*
* @category Naming
*/
static getComponentName() {
const name = this.name;
if (typeof name === 'string' && name !== '') {
return name;
}
throw new Error('The name of the component is missing');
}
/**
* Sets the name of the component. As the name of a component is usually inferred from the name of its class, this method should rarely be used.
*
* @param name The name you wish for the component.
*
* @example
* ```
* Movie.getComponentName(); // => 'Movie'
* Movie.setComponentName('Film');
* Movie.getComponentName(); // => 'Film'
* ```
*
* @category Naming
*/
static setComponentName(name: string) {
assertIsComponentName(name);
Object.defineProperty(this, 'name', {value: name});
}
/**
* Returns the path of the component starting from its root component.
*
* For example, if an `Application` component provides a `Movie` component, this method will return `'Application.Movie'` when called on the `Movie` component.
*
* @returns A string.
*
* @example
* ```
* class Movie extends Component {}
*
* Movie.getComponentPath(); // => 'Movie'
*
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Movie.getComponentPath(); // => 'Application.Movie'
* ```
*
* @category Naming
*/
static getComponentPath() {
let path: string[] = [];
let currentComponent = this;
while (true) {
path.unshift(currentComponent.getComponentName());
const componentProvider = currentComponent.getComponentProvider();
if (componentProvider === currentComponent) {
break;
}
currentComponent = componentProvider;
}
return path.join('.');
}
// === Typing ===
static getBaseComponentType() {
return getComponentClassTypeFromComponentName(this.getBaseComponentName());
}
getBaseComponentType() {
return getComponentInstanceTypeFromComponentName(this.constructor.getBaseComponentName());
}
/**
* Returns the type of the component class. A component class type is composed of the component class name prefixed with the string `'typeof '`.
*
* For example, with a component class named `'Movie'`, this method will return `'typeof Movie'`.
*
* @returns A string.
*
* @example
* ```
* Movie.getComponentType(); // => 'typeof Movie'
* ```
*
* @category Typing
*/
static getComponentType() {
return getComponentClassTypeFromComponentName(this.getComponentName());
}
/**
* Returns the type of the component instance. A component instance type is equivalent to the component class name.
*
* For example, with a component class named `'Movie'`, this method will return `'Movie'` when called on a `Movie` instance.
*
* @returns A string.
*
* @example
* ```
* Movie.prototype.getComponentType(); // => 'Movie'
*
* const movie = new Movie();
* movie.getComponentType(); // => 'Movie'
* ```
*
* @category Typing
*/
getComponentType() {
return getComponentInstanceTypeFromComponentName(this.constructor.getComponentName());
}
// === isNew Mark ===
__isNew: boolean | undefined;
/**
* Returns whether the component instance is marked as new or not.
*
* @alias isNew
*
* @returns A boolean.
*
* @example
* ```
* let movie = new Movie();
* movie.getIsNewMark(); // => true
* ```
*
* @category isNew Mark
*/
getIsNewMark() {
return this.__isNew === true;
}
/**
* Sets whether the component instance is marked as new or not.
*
* @param isNew A boolean specifying if the component instance should be marked as new or not.
*
* @example
* ```
* const movie = new Movie();
* movie.getIsNewMark(); // => true
* movie.setIsNewMark(false);
* movie.getIsNewMark(); // => false
* ```
*
* @category isNew Mark
*/
setIsNewMark(isNew: boolean) {
Object.defineProperty(this, '__isNew', {value: isNew, configurable: true});
}
/**
* Returns whether the component instance is marked as new or not.
*
* @returns A boolean.
*
* @example
* ```
* let movie = new Movie();
* movie.isNew(); // => true
* ```
*
* @category isNew Mark
*/
isNew() {
return this.getIsNewMark();
}
/**
* Marks the component instance as new.
*
* This method is a shortcut for `setIsNewMark(true)`.
*
* @category isNew Mark
*/
markAsNew() {
this.setIsNewMark(true);
}
/**
* Marks the component instance as not new.
*
* This method is a shortcut for `setIsNewMark(false)`.
*
* @category isNew Mark
*/
markAsNotNew() {
this.setIsNewMark(false);
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Embeddability ===
/**
* Returns whether the component is an [`EmbeddedComponent`](https://layrjs.com/docs/v2/reference/embedded-component).
*
* @returns A boolean.
*
* @category Embeddability
*/
static isEmbedded() {
return false;
}
// === Properties ===
static getPropertyClass(type: string) {
if (type === 'Property') {
return Property;
}
if (type === 'Attribute') {
return Attribute;
}
if (type === 'PrimaryIdentifierAttribute') {
return PrimaryIdentifierAttribute;
}
if (type === 'SecondaryIdentifierAttribute') {
return SecondaryIdentifierAttribute;
}
if (type === 'Method') {
return Method;
}
throw new Error(`The specified property type ('${type}') is unknown`);
}
/**
* Gets a property of the component.
*
* @param name The name of the property to get.
*
* @returns An instance of a [`Property`](https://layrjs.com/docs/v2/reference/property) (or a subclass of [`Property`](https://layrjs.com/docs/v2/reference/property) such as [`Attribute`](https://layrjs.com/docs/v2/reference/attribute), [`Method`](https://layrjs.com/docs/v2/reference/method), etc.).
*
* @example
* ```
* movie.getProperty('title'); // => 'title' attribute property
* movie.getProperty('play'); // => 'play()' method property
* ```
*
* @category Properties
*/
static get getProperty() {
return this.prototype.getProperty;
}
/**
* Gets a property of the component.
*
* @param name The name of the property to get.
*
* @returns An instance of a [`Property`](https://layrjs.com/docs/v2/reference/property) (or a subclass of [`Property`](https://layrjs.com/docs/v2/reference/property) such as [`Attribute`](https://layrjs.com/docs/v2/reference/attribute), [`Method`](https://layrjs.com/docs/v2/reference/method), etc.).
*
* @example
* ```
* movie.getProperty('title'); // => 'title' attribute property
* movie.getProperty('play'); // => 'play()' method property
* ```
*
* @category Properties
*/
getProperty(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
throw new Error(`The property '${name}' is missing (${this.describeComponent()})`);
}
return property;
}
/**
* Returns whether the component has the specified property.
*
* @param name The name of the property to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasProperty('title'); // => true
* movie.hasProperty('play'); // => true
* movie.hasProperty('name'); // => false
* ```
*
* @category Properties
*/
static get hasProperty() {
return this.prototype.hasProperty;
}
/**
* Returns whether the component has the specified property.
*
* @param name The name of the property to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasProperty('title'); // => true
* movie.hasProperty('play'); // => true
* movie.hasProperty('name'); // => false
* ```
*
* @category Properties
*/
hasProperty(name: string) {
return this.__getProperty(name, {autoFork: false}) !== undefined;
}
static get __getProperty() {
return this.prototype.__getProperty;
}
__getProperty(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const properties = this.__getProperties();
let property = properties[name];
if (property === undefined) {
return undefined;
}
if (autoFork && property.getParent() !== this) {
property = property.fork(this);
properties[name] = property;
}
return property;
}
/**
* Defines a property in the component. Typically, instead of using this method, you would rather use a decorator such as [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) or [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator).
*
* @param name The name of the property to define.
* @param PropertyClass The class of the property (e.g., [`Attribute`](https://layrjs.com/docs/v2/reference/attribute), [`Method`](https://layrjs.com/docs/v2/reference/method)) to use.
* @param [propertyOptions] The options to create the `PropertyClass`.
*
* @returns The property that was created.
*
* @example
* ```
* Movie.prototype.setProperty('title', Attribute, {valueType: 'string'});
* ```
*
* @category Properties
*/
static get setProperty() {
return this.prototype.setProperty;
}
/**
* Defines a property in the component. Typically, instead of using this method, you would rather use a decorator such as [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) or [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator).
*
* @param name The name of the property to define.
* @param PropertyClass The class of the property (e.g., [`Attribute`](https://layrjs.com/docs/v2/reference/attribute), [`Method`](https://layrjs.com/docs/v2/reference/method)) to use.
* @param [propertyOptions] The options to create the `PropertyClass`.
*
* @returns The property that was created.
*
* @example
* ```
* Movie.prototype.setProperty('title', Attribute, {valueType: 'string'});
* ```
*
* @category Properties
*/
setProperty(
name: string,
PropertyClass: T,
propertyOptions?: PropertyOptions
): InstanceType;
setProperty(name: string, PropertyClass: typeof Property, propertyOptions: PropertyOptions = {}) {
let property = this.hasProperty(name) ? this.getProperty(name) : undefined;
if (property === undefined) {
property = new PropertyClass(name, this, propertyOptions);
const properties = this.__getProperties();
properties[name] = property;
} else {
if (getTypeOf(property) !== getTypeOf(PropertyClass.prototype)) {
throw new Error(`Cannot change the type of a property (${property.describe()})`);
}
property.setOptions(propertyOptions);
}
if (isAttributeClass(PropertyClass)) {
const descriptor: PropertyDescriptor = {
configurable: true,
enumerable: true,
get(this: typeof Component | Component) {
return this.getAttribute(name).getValue();
},
set(this: typeof Component | Component, value: any): void {
this.getAttribute(name).setValue(value);
}
};
Object.defineProperty(this, name, descriptor);
}
return property;
}
/**
* Removes a property from the component. If the specified property doesn't exist, nothing happens.
*
* @param name The name of the property to remove.
*
* @returns A boolean.
*
* @category Properties
*/
static get deleteProperty() {
return this.prototype.deleteProperty;
}
/**
* Removes a property from the component. If the specified property doesn't exist, nothing happens.
*
* @param name The name of the property to remove.
*
* @returns A boolean.
*
* @category Properties
*/
deleteProperty(name: string) {
const properties = this.__getProperties();
if (!hasOwnProperty(properties, name)) {
return false;
}
delete properties[name];
return true;
}
/**
* Returns an iterator providing the properties of the component.
*
* @param [options.filter] A function used to filter the properties to be returned. The function is invoked for each property with a [`Property`](https://layrjs.com/docs/v2/reference/property) instance as first argument.
* @param [options.attributesOnly] A boolean specifying whether only attribute properties should be returned (default: `false`).
* @param [options.setAttributesOnly] A boolean specifying whether only set attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be returned (default: `true`, which means that all the attributes should be returned).
* @param [options.methodsOnly] A boolean specifying whether only method properties should be returned (default: `false`).
*
* @returns A [`Property`](https://layrjs.com/docs/v2/reference/property) instance iterator.
*
* @example
* ```
* for (const property of movie.getProperties()) {
* console.log(property.getName());
* }
*
* // Should output:
* // title
* // play
* ```
*
* @category Properties
*/
static get getProperties() {
return this.prototype.getProperties;
}
/**
* Returns an iterator providing the properties of the component.
*
* @param [options.filter] A function used to filter the properties to be returned. The function is invoked for each property with a [`Property`](https://layrjs.com/docs/v2/reference/property) instance as first argument.
* @param [options.attributesOnly] A boolean specifying whether only attribute properties should be returned (default: `false`).
* @param [options.setAttributesOnly] A boolean specifying whether only set attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be returned (default: `true`, which means that all the attributes should be returned).
* @param [options.methodsOnly] A boolean specifying whether only method properties should be returned (default: `false`).
*
* @returns A [`Property`](https://layrjs.com/docs/v2/reference/property) instance iterator.
*
* @example
* ```
* for (const property of movie.getProperties()) {
* console.log(property.getName());
* }
*
* // Should output:
* // title
* // play
* ```
*
* @category Properties
*/
getProperties(
options: {
filter?: PropertyFilterSync;
autoFork?: boolean;
} & CreatePropertyFilterOptions = {}
) {
const {
filter: originalFilter,
autoFork = true,
attributesOnly = false,
attributeSelector = true,
setAttributesOnly = false,
methodsOnly = false
} = options;
const component = this;
const filter = createPropertyFilter(originalFilter, {
attributesOnly,
attributeSelector,
setAttributesOnly,
methodsOnly
});
return {
*[Symbol.iterator]() {
for (const name of component.getPropertyNames()) {
const property = component.getProperty(name, {autoFork});
if (filter.call(component, property)) {
yield property as PropertyType;
}
}
}
};
}
static __properties?: {[name: string]: Property};
__properties?: {[name: string]: Property};
/**
* Returns the name of all the properties of the component.
*
* @returns An array of the property names.
*
* @example
* ```
* movie.getPropertyNames(); // => ['title', 'play']
* ```
*
* @category Properties
*/
static get getPropertyNames() {
return this.prototype.getPropertyNames;
}
/**
* Returns the name of all the properties of the component.
*
* @returns An array of the property names.
*
* @example
* ```
* movie.getPropertyNames(); // => ['title', 'play']
* ```
*
* @category Properties
*/
getPropertyNames() {
const names = [];
let currentObject: {__properties: any} = this as unknown as {__properties: any};
while ('__properties' in currentObject) {
if (hasOwnProperty(currentObject, '__properties')) {
const currentNames = Object.getOwnPropertyNames(currentObject.__properties);
names.unshift(...currentNames);
}
currentObject = Object.getPrototypeOf(currentObject);
}
return Array.from(new Set(names));
}
static get __getProperties() {
return this.prototype.__getProperties;
}
__getProperties({autoCreateOrFork = true} = {}) {
if (autoCreateOrFork) {
if (!('__properties' in this)) {
Object.defineProperty(this, '__properties', {value: Object.create(null)});
} else if (!hasOwnProperty(this, '__properties')) {
Object.defineProperty(this, '__properties', {value: Object.create(this.__properties!)});
}
}
return this.__properties!;
}
// === Property exposure ===
static normalizePropertyOperationSetting(
setting: PropertyOperationSetting,
options: {throwIfInvalid?: boolean} = {}
): PropertyOperationSetting | undefined {
const {throwIfInvalid = true} = options;
if (setting === true) {
return true;
}
if (throwIfInvalid) {
throw new Error(
`The specified property operation setting (${JSON.stringify(setting)}) is invalid`
);
}
return undefined;
}
static get resolvePropertyOperationSetting() {
return this.prototype.resolvePropertyOperationSetting;
}
resolvePropertyOperationSetting(
setting: PropertyOperationSetting
): PromiseLikeable {
if (setting === true) {
return true;
}
return undefined;
}
// === Attribute Properties ===
__constructorSourceCode?: string; // Used by @attribute() decorator
/**
* Gets an attribute of the component.
*
* @param name The name of the attribute to get.
*
* @returns An instance of [`Attribute`](https://layrjs.com/docs/v2/reference/attribute).
*
* @example
* ```
* movie.getAttribute('title'); // => 'title' attribute property
* movie.getAttribute('play'); // => Error ('play' is a method)
* ```
*
* @category Attribute Properties
*/
static get getAttribute() {
return this.prototype.getAttribute;
}
/**
* Gets an attribute of the component.
*
* @param name The name of the attribute to get.
*
* @returns An instance of [`Attribute`](https://layrjs.com/docs/v2/reference/attribute).
*
* @example
* ```
* movie.getAttribute('title'); // => 'title' attribute property
* movie.getAttribute('play'); // => Error ('play' is a method)
* ```
*
* @category Attribute Properties
*/
getAttribute(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const attribute = this.__getAttribute(name, {autoFork});
if (attribute === undefined) {
throw new Error(`The attribute '${name}' is missing (${this.describeComponent()})`);
}
return attribute;
}
/**
* Returns whether the component has the specified attribute.
*
* @param name The name of the attribute to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasAttribute('title'); // => true
* movie.hasAttribute('name'); // => false
* movie.hasAttribute('play'); // => Error ('play' is a method)
* ```
*
* @category Attribute Properties
*/
static get hasAttribute() {
return this.prototype.hasAttribute;
}
/**
* Returns whether the component has the specified attribute.
*
* @param name The name of the attribute to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasAttribute('title'); // => true
* movie.hasAttribute('name'); // => false
* movie.hasAttribute('play'); // => Error ('play' is a method)
* ```
*
* @category Attribute Properties
*/
hasAttribute(name: string) {
return this.__getAttribute(name, {autoFork: false}) !== undefined;
}
static get __getAttribute() {
return this.prototype.__getAttribute;
}
__getAttribute(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isAttributeInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not an attribute (${property.describe()})`
);
}
return property;
}
/**
* Defines an attribute in the component. Typically, instead of using this method, you would rather use the [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) decorator.
*
* @param name The name of the attribute to define.
* @param [attributeOptions] The options to create the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#constructor).
*
* @returns The [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) that was created.
*
* @example
* ```
* Movie.prototype.setAttribute('title', {valueType: 'string'});
* ```
*
* @category Attribute Properties
*/
static get setAttribute() {
return this.prototype.setAttribute;
}
/**
* Defines an attribute in the component. Typically, instead of using this method, you would rather use the [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) decorator.
*
* @param name The name of the attribute to define.
* @param [attributeOptions] The options to create the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#constructor).
*
* @returns The [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) that was created.
*
* @example
* ```
* Movie.prototype.setAttribute('title', {valueType: 'string'});
* ```
*
* @category Attribute Properties
*/
setAttribute(name: string, attributeOptions: AttributeOptions = {}) {
return this.setProperty(name, Attribute, attributeOptions);
}
/**
* Returns an iterator providing the attributes of the component.
*
* @param [options.filter] A function used to filter the attributes to be returned. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.setAttributesOnly] A boolean specifying whether only set attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be returned (default: `true`, which means that all the attributes should be returned).
*
* @returns An [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance iterator.
*
* @example
* ```
* for (const attr of movie.getAttributes()) {
* console.log(attr.getName());
* }
*
* // Should output:
* // title
* ```
*
* @category Attribute Properties
*/
static get getAttributes() {
return this.prototype.getAttributes;
}
/**
* Returns an iterator providing the attributes of the component.
*
* @param [options.filter] A function used to filter the attributes to be returned. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.setAttributesOnly] A boolean specifying whether only set attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be returned (default: `true`, which means that all the attributes should be returned).
*
* @returns An [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance iterator.
*
* @example
* ```
* for (const attr of movie.getAttributes()) {
* console.log(attr.getName());
* }
*
* // Should output:
* // title
* ```
*
* @category Attribute Properties
*/
getAttributes(
options: {
filter?: PropertyFilterSync;
autoFork?: boolean;
} & CreatePropertyFilterOptionsForAttributes = {}
) {
const {filter, attributeSelector = true, setAttributesOnly = false, autoFork = true} = options;
return this.getProperties({
filter,
autoFork,
attributesOnly: true,
attributeSelector,
setAttributesOnly
});
}
static get traverseAttributes() {
return this.prototype.traverseAttributes;
}
traverseAttributes(
iteratee: TraverseAttributesIteratee,
options: Partial & ResolveAttributeSelectorOptions = {}
) {
assertIsFunction(iteratee);
const {
attributeSelector = true,
filter,
setAttributesOnly = false,
depth = Number.MAX_SAFE_INTEGER,
includeReferencedComponents = false
} = options;
const resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
filter,
setAttributesOnly,
depth,
includeReferencedComponents
});
this.__traverseAttributes(iteratee, {
attributeSelector: resolvedAttributeSelector,
setAttributesOnly
});
}
static get __traverseAttributes() {
return this.prototype.__traverseAttributes;
}
__traverseAttributes(
iteratee: TraverseAttributesIteratee,
{attributeSelector, setAttributesOnly}: TraverseAttributesOptions
) {
for (const attribute of this.getAttributes({attributeSelector})) {
if (setAttributesOnly && !attribute.isSet()) {
continue;
}
const name = attribute.getName();
const subattributeSelector = getFromAttributeSelector(attributeSelector, name);
if (subattributeSelector !== false) {
iteratee(attribute);
}
attribute._traverseAttributes(iteratee, {
attributeSelector: subattributeSelector,
setAttributesOnly
});
}
}
// === Identifier attributes ===
/**
* Gets an identifier attribute of the component.
*
* @param name The name of the identifier attribute to get.
*
* @returns An instance of [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) or [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute).
*
* @example
* ```
* movie.getIdentifierAttribute('id'); // => 'id' primary identifier attribute
* movie.getIdentifierAttribute('slug'); // => 'slug' secondary identifier attribute
* ```
*
* @category Attribute Properties
*/
getIdentifierAttribute(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const identifierAttribute = this.__getIdentifierAttribute(name, {autoFork});
if (identifierAttribute === undefined) {
throw new Error(
`The identifier attribute '${name}' is missing (${this.describeComponent()})`
);
}
return identifierAttribute;
}
/**
* Returns whether the component has the specified identifier attribute.
*
* @param name The name of the identifier attribute to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasIdentifierAttribute('id'); // => true
* movie.hasIdentifierAttribute('slug'); // => true
* movie.hasIdentifierAttribute('name'); // => false (the property 'name' doesn't exist)
* movie.hasIdentifierAttribute('title'); // => Error ('title' is not an identifier attribute)
* ```
*
* @category Attribute Properties
*/
hasIdentifierAttribute(name: string) {
return this.__getIdentifierAttribute(name, {autoFork: false}) !== undefined;
}
__getIdentifierAttribute(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isIdentifierAttributeInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not an identifier attribute (${property.describe()})`
);
}
return property;
}
/**
* Gets the primary identifier attribute of the component.
*
* @returns An instance of [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
*
* @example
* ```
* movie.getPrimaryIdentifierAttribute(); // => 'id' primary identifier attribute
* ```
*
* @category Attribute Properties
*/
getPrimaryIdentifierAttribute(options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const primaryIdentifierAttribute = this.__getPrimaryIdentifierAttribute({autoFork});
if (primaryIdentifierAttribute === undefined) {
throw new Error(
`The component '${this.constructor.getComponentName()}' doesn't have a primary identifier attribute`
);
}
return primaryIdentifierAttribute;
}
/**
* Returns whether the component as a primary identifier attribute.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasPrimaryIdentifierAttribute(); // => true
* ```
*
* @category Attribute Properties
*/
hasPrimaryIdentifierAttribute() {
return this.__getPrimaryIdentifierAttribute({autoFork: false}) !== undefined;
}
__getPrimaryIdentifierAttribute(options: {autoFork: boolean}) {
const {autoFork} = options;
for (const identifierAttribute of this.getIdentifierAttributes({autoFork})) {
if (isPrimaryIdentifierAttributeInstance(identifierAttribute)) {
return identifierAttribute;
}
}
return undefined;
}
/**
* Defines the primary identifier attribute of the component. Typically, instead of using this method, you would rather use the [`@primaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#primary-identifier-decorator) decorator.
*
* @param name The name of the primary identifier attribute to define.
* @param [attributeOptions] The options to create the [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
*
* @returns The [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) that was created.
*
* @example
* ```
* User.prototype.setPrimaryIdentifierAttribute('id', {
* valueType: 'number',
* default() {
* return Math.random();
* }
* });
* ```
*
* @category Attribute Properties
*/
setPrimaryIdentifierAttribute(name: string, attributeOptions: AttributeOptions = {}) {
return this.setProperty(name, PrimaryIdentifierAttribute, attributeOptions);
}
/**
* Gets a secondary identifier attribute of the component.
*
* @param name The name of the secondary identifier attribute to get.
*
* @returns A [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) instance.
*
* @example
* ```
* movie.getSecondaryIdentifierAttribute('slug'); // => 'slug' secondary identifier attribute
* movie.getSecondaryIdentifierAttribute('id'); // => Error ('id' is not a secondary identifier attribute)
* ```
*
* @category Attribute Properties
*/
getSecondaryIdentifierAttribute(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const secondaryIdentifierAttribute = this.__getSecondaryIdentifierAttribute(name, {autoFork});
if (secondaryIdentifierAttribute === undefined) {
throw new Error(
`The secondary identifier attribute '${name}' is missing (${this.describeComponent()})`
);
}
return secondaryIdentifierAttribute;
}
/**
* Returns whether the component has the specified secondary identifier attribute.
*
* @param name The name of the secondary identifier attribute to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasSecondaryIdentifierAttribute('slug'); // => true
* movie.hasSecondaryIdentifierAttribute('name'); // => false (the property 'name' doesn't exist)
* movie.hasSecondaryIdentifierAttribute('id'); // => Error ('id' is not a secondary identifier attribute)
* ```
*
* @category Attribute Properties
*/
hasSecondaryIdentifierAttribute(name: string) {
return this.__getSecondaryIdentifierAttribute(name, {autoFork: false}) !== undefined;
}
__getSecondaryIdentifierAttribute(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isSecondaryIdentifierAttributeInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not a secondary identifier attribute (${property.describe()})`
);
}
return property;
}
/**
* Defines a secondary identifier attribute in the component. Typically, instead of using this method, you would rather use the [`@secondaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#secondary-identifier-decorator) decorator.
*
* @param name The name of the secondary identifier attribute to define.
* @param [attributeOptions] The options to create the [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute).
*
* @returns The [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) that was created.
*
* @example
* ```
* User.prototype.setSecondaryIdentifierAttribute('slug', {valueType: 'string'});
* ```
*
* @category Attribute Properties
*/
setSecondaryIdentifierAttribute(name: string, attributeOptions: AttributeOptions = {}) {
return this.setProperty(name, SecondaryIdentifierAttribute, attributeOptions);
}
/**
* Returns an iterator providing the identifier attributes of the component.
*
* @param [options.filter] A function used to filter the identifier attributes to be returned. The function is invoked for each identifier attribute with an `IdentifierAttribute` instance as first argument.
* @param [options.setAttributesOnly] A boolean specifying whether only set identifier attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the identifier attributes to be returned (default: `true`, which means that all identifier attributes should be returned).
*
* @returns An iterator of [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) or [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute).
*
* @example
* ```
* for (const attr of movie.getIdentifierAttributes()) {
* console.log(attr.getName());
* }
*
* // Should output:
* // id
* // slug
* ```
*
* @category Attribute Properties
*/
getIdentifierAttributes(
options: {
filter?: PropertyFilterSync;
autoFork?: boolean;
} & CreatePropertyFilterOptionsForAttributes = {}
) {
const {
filter: originalFilter,
attributeSelector = true,
setAttributesOnly = false,
autoFork = true
} = options;
const filter = function (this: Component, property: Property) {
if (!isIdentifierAttributeInstance(property)) {
return false;
}
if (originalFilter !== undefined) {
return originalFilter.call(this, property);
}
return true;
};
return this.getProperties({
filter,
autoFork,
attributeSelector,
setAttributesOnly
});
}
/**
* Returns an iterator providing the secondary identifier attributes of the component.
*
* @param [options.filter] A function used to filter the secondary identifier attributes to be returned. The function is invoked for each identifier attribute with a [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) instance as first argument.
* @param [options.setAttributesOnly] A boolean specifying whether only set secondary identifier attributes should be returned (default: `false`).
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the secondary identifier attributes to be returned (default: `true`, which means that all secondary identifier attributes should be returned).
*
* @returns A [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) instance iterator.
*
* @example
* ```
* for (const attr of movie.getSecondaryIdentifierAttributes()) {
* console.log(attr.getName());
* }
*
* // Should output:
* // slug
* ```
*
* @category Attribute Properties
*/
getSecondaryIdentifierAttributes(
options: {
filter?: PropertyFilterSync;
autoFork?: boolean;
} & CreatePropertyFilterOptionsForAttributes = {}
) {
const {
filter: originalFilter,
attributeSelector = true,
setAttributesOnly = false,
autoFork = true
} = options;
const filter = function (this: Component, property: Property) {
if (!isSecondaryIdentifierAttributeInstance(property)) {
return false;
}
if (originalFilter !== undefined) {
return originalFilter.call(this, property);
}
return true;
};
return this.getProperties({
filter,
autoFork,
attributeSelector,
setAttributesOnly
});
}
/**
* Returns an object composed of all the identifiers that are set in the component. The shape of the returned object is `{[identifierName]: identifierValue}`. Throws an error if the component doesn't have any set identifiers.
*
* @returns An object.
*
* @example
* ```
* movie.getIdentifiers(); // => {id: 'abc123', slug: 'inception'}
* ```
*
* @category Attribute Properties
*/
getIdentifiers() {
const identifiers = this.__getIdentifiers();
if (identifiers === undefined) {
throw new Error(
`Cannot get the identifiers of a component that has no set identifier (${this.describeComponent()})`
);
}
return identifiers;
}
/**
* Returns whether the component has an identifier that is set or not.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasIdentifiers(); // => true
* ```
*
* @category Attribute Properties
*/
hasIdentifiers() {
return this.__getIdentifiers() !== undefined;
}
__getIdentifiers() {
let identifiers: NormalizedIdentifierDescriptor | undefined;
for (const identifierAttribute of this.getIdentifierAttributes({
setAttributesOnly: true,
autoFork: false
})) {
const name = identifierAttribute.getName();
const value = identifierAttribute.getValue() as IdentifierValue;
if (identifiers === undefined) {
identifiers = {};
}
identifiers[name] = value;
}
return identifiers;
}
/**
* Generates a unique identifier using the [cuid](https://github.com/ericelliott/cuid) library.
*
* @returns The generated identifier.
*
* @example
* ```
* Movie.generateId(); // => 'ck41vli1z00013h5xx1esffyn'
* ```
*
* @category Attribute Properties
*/
static generateId() {
return cuid();
}
__getMinimumAttributeCount() {
return this.hasPrimaryIdentifierAttribute() ? 1 : 0;
}
// === Identifier Descriptor ===
/**
* Returns the `IdentifierDescriptor` of the component.
*
* An `IdentifierDescriptor` is a plain object composed of one pair of name/value corresponding to the name and value of the first identifier attribute encountered in a component. Usually it is the primary identifier, but if the primary identifier is not set, it can be a secondary identifier.
*
* If there is no set identifier in the component, an error is thrown.
*
* @returns An object.
*
* @example
* ```
* movie.getIdentifierDescriptor(); // => {id: 'abc123'}
* ```
*
* @category Identifier Descriptor
*/
getIdentifierDescriptor() {
const identifierDescriptor = this.__getIdentifierDescriptor();
if (identifierDescriptor === undefined) {
throw new Error(
`Cannot get an identifier descriptor from a component that has no set identifier (${this.describeComponent()})`
);
}
return identifierDescriptor;
}
/**
* Returns whether the component can provide an `IdentifierDescriptor` (using the [`getIdentifierDescriptor()`](https://layrjs.com/docs/v2/reference/component#get-identifier-descriptor-instance-method) method) or not.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasIdentifierDescriptor(); // => true
* ```
*
* @category Identifier Descriptor
*/
hasIdentifierDescriptor() {
return this.__getIdentifierDescriptor() !== undefined;
}
__getIdentifierDescriptor(): NormalizedIdentifierDescriptor | undefined {
const primaryIdentifierAttribute = this.getPrimaryIdentifierAttribute();
if (primaryIdentifierAttribute.isSet()) {
const name = primaryIdentifierAttribute.getName();
const value = primaryIdentifierAttribute.getValue() as IdentifierValue;
return {[name]: value};
}
for (const secondaryIdentifierAttribute of this.getSecondaryIdentifierAttributes({
setAttributesOnly: true
})) {
const name = secondaryIdentifierAttribute.getName();
const value = secondaryIdentifierAttribute.getValue() as IdentifierValue;
return {[name]: value};
}
return undefined;
}
static normalizeIdentifierDescriptor(
identifierDescriptor: IdentifierDescriptor
): NormalizedIdentifierDescriptor {
if (typeof identifierDescriptor === 'string' || typeof identifierDescriptor === 'number') {
const primaryIdentifierAttribute = this.prototype.getPrimaryIdentifierAttribute();
const name = primaryIdentifierAttribute.getName();
primaryIdentifierAttribute.checkValue(identifierDescriptor);
return {[name]: identifierDescriptor};
}
if (!isPlainObject(identifierDescriptor)) {
throw new Error(
`An identifier descriptor should be a string, a number, or an object, but received a value of type '${getTypeOf(
identifierDescriptor
)}' (${this.describeComponent()})`
);
}
const attributes = Object.entries(identifierDescriptor);
if (attributes.length !== 1) {
throw new Error(
`An identifier descriptor should be a string, a number, or an object composed of one attribute, but received an object composed of ${
attributes.length
} attributes (${this.describeComponent()}, received object: ${JSON.stringify(
identifierDescriptor
)})`
);
}
const [name, value] = attributes[0];
const identifierAttribute = this.prototype.getIdentifierAttribute(name);
identifierAttribute.checkValue(value);
return {[name]: value};
}
static describeIdentifierDescriptor(identifierDescriptor: IdentifierDescriptor) {
const normalizedIdentifierDescriptor = this.normalizeIdentifierDescriptor(identifierDescriptor);
const [[name, value]] = Object.entries(normalizedIdentifierDescriptor);
const valueString = typeof value === 'string' ? `'${value}'` : value.toString();
return `${name}: ${valueString}`;
}
// === Identifier Selector ====
static normalizeIdentifierSelector(
identifierSelector: IdentifierSelector
): NormalizedIdentifierSelector {
if (typeof identifierSelector === 'string' || typeof identifierSelector === 'number') {
const primaryIdentifierAttribute = this.prototype.getPrimaryIdentifierAttribute();
const name = primaryIdentifierAttribute.getName();
primaryIdentifierAttribute.checkValue(identifierSelector);
return {[name]: identifierSelector};
}
if (!isPlainObject(identifierSelector)) {
throw new Error(
`An identifier selector should be a string, a number, or an object, but received a value of type '${getTypeOf(
identifierSelector
)}' (${this.describeComponent()})`
);
}
const attributes = Object.entries(identifierSelector);
if (attributes.length === 0) {
throw new Error(
`An identifier selector should be a string, a number, or a non-empty object, but received an empty object (${this.describeComponent()})`
);
}
const normalizedIdentifierSelector: NormalizedIdentifierSelector = {};
for (const [name, value] of attributes) {
const identifierAttribute = this.prototype.getIdentifierAttribute(name);
identifierAttribute.checkValue(value);
normalizedIdentifierSelector[name] = value;
}
return normalizedIdentifierSelector;
}
__createIdentifierSelectorFromObject(object: PlainObject) {
const identifierSelector: NormalizedIdentifierSelector = {};
for (const identifierAttribute of this.getIdentifierAttributes({autoFork: false})) {
const name = identifierAttribute.getName();
const value: IdentifierValue | undefined = object[name];
if (value !== undefined) {
identifierSelector[name] = value;
}
}
return identifierSelector;
}
// === Attribute Value Assignment ===
/**
* Assigns the specified attribute values to the current component class.
*
* @param object An object specifying the attribute values to assign.
* @param [options.source] A string specifying the [source](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the attribute values (default: `'local'`).
*
* @returns The current component class.
*
* @example
* ```
* import {Component, attribute} from '﹫layr/component';
*
* class Application extends Component {
* ﹫attribute('string') static language = 'en';
* }
*
* Application.language // => 'en'
*
* Application.assign({language: 'fr'});
*
* Application.language // => 'fr'
* ```
*
* @category Attribute Value Assignment
*/
static assign(
this: T,
object: PlainObject = {},
options: {
source?: ValueSource;
} = {}
): T {
const {source} = options;
this.__assignAttributes(object, {source});
return this;
}
/**
* Assigns the specified attribute values to the current component instance.
*
* @param object An object specifying the attribute values to assign.
* @param [options.source] A string specifying the [source](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the attribute values (default: `'local'`).
*
* @returns The current component instance.
*
* @example
* ```
* // JS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title;
* ﹫attribute('number') rating;
* }
*
* const movie = new Movie({title: 'Inception', rating: 8.7});
*
* movie.title // => 'Inception'
* movie.rating // => 8.7
*
* movie.assign({rating: 8.8});
*
* movie.title // => 'Inception'
* movie.rating // => 8.8
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title!: string;
* ﹫attribute('number') rating!: number;
* }
*
* const movie = new Movie({title: 'Inception', rating: 8.7});
*
* movie.title // => 'Inception'
* movie.rating // => 8.7
*
* movie.assign({rating: 8.8});
*
* movie.rating // => 8.8
* ```
*
* @category Attribute Value Assignment
*/
assign(
this: T,
object: PlainObject = {},
options: {
source?: ValueSource;
} = {}
): T {
const {source} = options;
this.__assignAttributes(object, {source});
return this;
}
static get __assignAttributes() {
return this.prototype.__assignAttributes;
}
__assignAttributes(object: PlainObject, {source}: {source: ValueSource | undefined}) {
for (const [name, value] of Object.entries(object)) {
this.getAttribute(name).setValue(value, {source});
}
}
// === Identity Mapping ===
static __identityMap: IdentityMap;
/**
* Gets the [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map) of the component.
*
* @returns An [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map) instance.
*
* @category Identity Mapping
*/
static getIdentityMap() {
if (this.__identityMap === undefined) {
Object.defineProperty(this, '__identityMap', {value: new IdentityMap(this)});
} else if (!hasOwnProperty(this, '__identityMap')) {
Object.defineProperty(this, '__identityMap', {value: this.__identityMap.fork(this)});
}
return this.__identityMap;
}
static __isAttached: boolean;
/**
* Attaches the component class to its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map). By default, all component classes are attached, so unless you have detached a component class earlier, you should not have to use this method.
*
* @returns The component class.
*
* @category Identity Mapping
*/
static attach(this: T) {
Object.defineProperty(this, '__isAttached', {value: true, configurable: true});
return this;
}
/**
* Detaches the component class from its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns The component class.
*
* @category Identity Mapping
*/
static detach(this: T) {
Object.defineProperty(this, '__isAttached', {value: false, configurable: true});
return this;
}
/**
* Returns whether the component class is attached to its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns A boolean.
*
* @category Identity Mapping
*/
static isAttached() {
let currentComponent = this;
while (true) {
const isAttached = currentComponent.__isAttached;
if (isAttached !== undefined) {
return isAttached;
}
const componentProvider = currentComponent.getComponentProvider();
if (componentProvider === currentComponent) {
return true;
}
currentComponent = componentProvider;
}
}
/**
* Returns whether the component class is detached from its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns A boolean.
*
* @category Identity Mapping
*/
static isDetached() {
return !this.isAttached();
}
__isAttached?: boolean;
/**
* Attaches the component instance to its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map). By default, all component instances are attached, so unless you have detached a component instance earlier, you should not have to use this method.
*
* @returns The component instance.
*
* @category Identity Mapping
*/
attach() {
Object.defineProperty(this, '__isAttached', {value: true, configurable: true});
if (this.hasPrimaryIdentifierAttribute()) {
const identityMap = this.constructor.getIdentityMap();
identityMap.addComponent(this);
}
return this;
}
/**
* Detaches the component instance from its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns The component instance.
*
* @category Identity Mapping
*/
detach() {
if (this.hasPrimaryIdentifierAttribute()) {
const identityMap = this.constructor.getIdentityMap();
identityMap.removeComponent(this);
}
Object.defineProperty(this, '__isAttached', {value: false, configurable: true});
return this;
}
/**
* Returns whether the component instance is attached to its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns A boolean.
*
* @category Identity Mapping
*/
isAttached() {
if (this.__isAttached !== undefined) {
return this.__isAttached;
}
return this.constructor.isAttached();
}
/**
* Returns whether the component instance is detached from its [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map).
*
* @returns A boolean.
*
* @category Identity Mapping
*/
isDetached() {
return !this.isAttached();
}
// === Attribute selectors ===
static get resolveAttributeSelector() {
return this.prototype.resolveAttributeSelector;
}
resolveAttributeSelector(
attributeSelector: AttributeSelector,
options: ResolveAttributeSelectorOptions = {}
) {
attributeSelector = normalizeAttributeSelector(attributeSelector);
const {
filter,
setAttributesOnly = false,
target,
aggregationMode = 'union',
includeReferencedComponents = false,
alwaysIncludePrimaryIdentifierAttributes = true,
allowPartialArrayItems = true,
depth = Number.MAX_SAFE_INTEGER,
_isDeep = false,
_isArrayItem = false,
_attributeStack = new Set()
} = options;
const _skipUnchangedAttributes =
setAttributesOnly &&
target !== undefined &&
(target === 'client' || typeof (this as any).isStorable === 'function');
return this.__resolveAttributeSelector(attributeSelector, {
filter,
setAttributesOnly,
target,
aggregationMode,
includeReferencedComponents,
alwaysIncludePrimaryIdentifierAttributes,
allowPartialArrayItems,
depth,
_isDeep,
_skipUnchangedAttributes,
_isArrayItem,
_attributeStack
});
}
static get __resolveAttributeSelector() {
return this.prototype.__resolveAttributeSelector;
}
__resolveAttributeSelector(
attributeSelector: AttributeSelector,
options: ResolveAttributeSelectorOptions
) {
const {
filter,
setAttributesOnly,
target,
includeReferencedComponents,
alwaysIncludePrimaryIdentifierAttributes,
allowPartialArrayItems,
depth,
_isDeep,
_skipUnchangedAttributes,
_isArrayItem,
_attributeStack
} = options;
if (depth! < 0) {
return attributeSelector;
}
const newDepth = depth! - 1;
let resolvedAttributeSelector: AttributeSelector = {};
if (attributeSelector === false) {
return resolvedAttributeSelector; // Optimization
}
const isEmbedded = isComponentInstance(this) && this.constructor.isEmbedded();
if (!setAttributesOnly && isEmbedded && _isArrayItem && !allowPartialArrayItems) {
attributeSelector = true;
}
// By default, referenced components are not resolved
if (!_isDeep || includeReferencedComponents || isEmbedded) {
for (const attribute of this.getAttributes({filter, setAttributesOnly})) {
const name = attribute.getName();
const subattributeSelector = getFromAttributeSelector(attributeSelector, name);
if (subattributeSelector === false) {
continue;
}
if (_skipUnchangedAttributes && attribute.getValueSource() === target) {
const valueType = attribute.getValueType();
const attributeIsReferencedComponent =
isComponentValueTypeInstance(valueType) &&
!ensureComponentClass(valueType.getComponent(attribute)).isEmbedded();
if (!attributeIsReferencedComponent) {
continue;
}
}
if (_attributeStack!.has(attribute)) {
continue; // Avoid looping indefinitely when a circular attribute is encountered
}
_attributeStack!.add(attribute);
const resolvedSubattributeSelector = attribute._resolveAttributeSelector(
subattributeSelector,
{...options, depth: newDepth, _isDeep: true}
);
_attributeStack!.delete(attribute);
if (resolvedSubattributeSelector !== false) {
resolvedAttributeSelector = setWithinAttributeSelector(
resolvedAttributeSelector,
name,
resolvedSubattributeSelector
);
}
}
}
if (
isComponentInstance(this) &&
alwaysIncludePrimaryIdentifierAttributes &&
this.hasPrimaryIdentifierAttribute()
) {
const primaryIdentifierAttribute = this.getPrimaryIdentifierAttribute();
const isNotFilteredOut =
filter !== undefined ? filter.call(this, primaryIdentifierAttribute) : true;
if (isNotFilteredOut && (!setAttributesOnly || primaryIdentifierAttribute.isSet())) {
resolvedAttributeSelector = setWithinAttributeSelector(
resolvedAttributeSelector,
primaryIdentifierAttribute.getName(),
true
);
}
}
return resolvedAttributeSelector;
}
// === Validation ===
/**
* Validates the attributes of the component. If an attribute doesn't pass the validation, an error is thrown. The error is a JavaScript `Error` instance with a `failedValidators` custom attribute which contains the result of the [`runValidators()`](https://layrjs.com/docs/v2/reference/component#run-validators-dual-method) method.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be validated (default: `true`, which means that all the attributes will be validated).
*
* @example
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title; // => 'Inception'
* movie.validate(); // All good!
* movie.title = '';
* movie.validate(); // Error {failedValidators: [{validator: ..., path: 'title'}]}
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title!: string;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title; // => 'Inception'
* movie.validate(); // All good!
* movie.title = '';
* movie.validate(); // Error {failedValidators: [{validator: ..., path: 'title'}]}
* ```
*
* @category Validation
*/
static get validate() {
return this.prototype.validate;
}
/**
* Validates the attributes of the component. If an attribute doesn't pass the validation, an error is thrown. The error is a JavaScript `Error` instance with a `failedValidators` custom attribute which contains the result of the [`runValidators()`](https://layrjs.com/docs/v2/reference/component#run-validators-dual-method) method.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be validated (default: `true`, which means that all the attributes will be validated).
*
* @example
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title; // => 'Inception'
* movie.validate(); // All good!
* movie.title = '';
* movie.validate(); // Error {failedValidators: [{validator: ..., path: 'title'}]}
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title!: string;
* }
*
* const movie = new Movie({title: 'Inception'});
*
* movie.title; // => 'Inception'
* movie.validate(); // All good!
* movie.title = '';
* movie.validate(); // Error {failedValidators: [{validator: ..., path: 'title'}]}
* ```
*
* @category Validation
*/
validate(attributeSelector: AttributeSelector = true) {
const failedValidators = this.runValidators(attributeSelector);
if (failedValidators.length === 0) {
return;
}
const details = failedValidators
.map(({validator, path}) => `${validator.getMessage()} (path: '${path}')`)
.join(', ');
let displayMessage: string | undefined;
for (const {validator} of failedValidators) {
const message = validator.getMessage({generateIfMissing: false});
if (message !== undefined) {
displayMessage = message;
break;
}
}
throwError(
`The following error(s) occurred while validating the component '${ensureComponentClass(
this
).getComponentName()}': ${details}`,
{displayMessage, failedValidators}
);
}
/**
* Returns whether the attributes of the component are valid.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be checked (default: `true`, which means that all the attributes will be checked).
*
* @returns A boolean.
*
* @example
* ```
* // See the `movie` definition in the `validate()` example
*
* movie.title; // => 'Inception'
* movie.isValid(); // => true
* movie.title = '';
* movie.isValid(); // => false
* ```
*
* @category Validation
*/
static get isValid() {
return this.prototype.isValid;
}
/**
* Returns whether the attributes of the component are valid.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be checked (default: `true`, which means that all the attributes will be checked).
*
* @returns A boolean.
*
* @example
* ```
* // See the `movie` definition in the `validate()` example
*
* movie.title; // => 'Inception'
* movie.isValid(); // => true
* movie.title = '';
* movie.isValid(); // => false
* ```
*
* @category Validation
*/
isValid(attributeSelector: AttributeSelector = true) {
const failedValidators = this.runValidators(attributeSelector);
return failedValidators.length === 0;
}
/**
* Runs the validators for all the set attributes of the component.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be validated (default: `true`, which means that all the attributes will be validated).
*
* @returns An array containing the validators that have failed. Each item is a plain object composed of a `validator` (a [`Validator`](https://layrjs.com/docs/v2/reference/validator) instance) and a `path` (a string representing the path of the attribute containing the validator that has failed).
*
* @example
* ```
* // See the `movie` definition in the `validate()` example
*
* movie.title; // => 'Inception'
* movie.runValidators(); // => []
* movie.title = '';
* movie.runValidators(); // => [{validator: ..., path: 'title'}]
* ```
*
* @category Validation
*/
static get runValidators() {
return this.prototype.runValidators;
}
/**
* Runs the validators for all the set attributes of the component.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be validated (default: `true`, which means that all the attributes will be validated).
*
* @returns An array containing the validators that have failed. Each item is a plain object composed of a `validator` (a [`Validator`](https://layrjs.com/docs/v2/reference/validator) instance) and a `path` (a string representing the path of the attribute containing the validator that has failed).
*
* @example
* ```
* // See the `movie` definition in the `validate()` example
*
* movie.title; // => 'Inception'
* movie.runValidators(); // => []
* movie.title = '';
* movie.runValidators(); // => [{validator: ..., path: 'title'}]
* ```
*
* @category Validation
*/
runValidators(attributeSelector: AttributeSelector = true) {
attributeSelector = this.resolveAttributeSelector(attributeSelector);
const failedValidators = [];
for (const attribute of this.getAttributes({setAttributesOnly: true})) {
const name = attribute.getName();
const subattributeSelector = getFromAttributeSelector(attributeSelector, name);
if (subattributeSelector === false) {
continue;
}
const attributeFailedValidators = attribute.runValidators(subattributeSelector);
for (const {validator, path} of attributeFailedValidators) {
failedValidators.push({validator, path: joinAttributePath([name, path])});
}
}
return failedValidators;
}
// === Method Properties ===
/**
* Gets a method of the component.
*
* @param name The name of the method to get.
*
* @returns A [`Method`](https://layrjs.com/docs/v2/reference/method) instance.
*
* @example
* ```
* movie.getMethod('play'); // => 'play' method property
* movie.getMethod('title'); // => Error ('title' is an attribute property)
* ```
*
* @category Method Properties
*/
static get getMethod() {
return this.prototype.getMethod;
}
/**
* Gets a method of the component.
*
* @param name The name of the method to get.
*
* @returns A [`Method`](https://layrjs.com/docs/v2/reference/method) instance.
*
* @example
* ```
* movie.getMethod('play'); // => 'play' method property
* movie.getMethod('title'); // => Error ('title' is an attribute property)
* ```
*
* @category Method Properties
*/
getMethod(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const method = this.__getMethod(name, {autoFork});
if (method === undefined) {
throw new Error(`The method '${name}' is missing (${this.describeComponent()})`);
}
return method;
}
/**
* Returns whether the component has the specified method.
*
* @param name The name of the method to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasMethod('play'); // => true
* movie.hasMethod('destroy'); // => false
* movie.hasMethod('title'); // => Error ('title' is an attribute property)
* ```
*
* @category Method Properties
*/
static get hasMethod() {
return this.prototype.hasMethod;
}
/**
* Returns whether the component has the specified method.
*
* @param name The name of the method to check.
*
* @returns A boolean.
*
* @example
* ```
* movie.hasMethod('play'); // => true
* movie.hasMethod('destroy'); // => false
* movie.hasMethod('title'); // => Error ('title' is an attribute property)
* ```
*
* @category Method Properties
*/
hasMethod(name: string) {
return this.__getMethod(name, {autoFork: false}) !== undefined;
}
static get __getMethod() {
return this.prototype.__getMethod;
}
__getMethod(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isMethodInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not a method (${property.describe()})`
);
}
return property;
}
/**
* Defines a method in the component. Typically, instead of using this method, you would rather use the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator.
*
* @param name The name of the method to define.
* @param [methodOptions] The options to create the [`Method`](https://layrjs.com/docs/v2/reference/method#constructor).
*
* @returns The [`Method`](https://layrjs.com/docs/v2/reference/method) that was created.
*
* @example
* ```
* Movie.prototype.setMethod('play');
* ```
*
* @category Method Properties
*/
static get setMethod() {
return this.prototype.setMethod;
}
/**
* Defines a method in the component. Typically, instead of using this method, you would rather use the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator.
*
* @param name The name of the method to define.
* @param [methodOptions] The options to create the [`Method`](https://layrjs.com/docs/v2/reference/method#constructor).
*
* @returns The [`Method`](https://layrjs.com/docs/v2/reference/method) that was created.
*
* @example
* ```
* Movie.prototype.setMethod('play');
* ```
*
* @category Method Properties
*/
setMethod(name: string, methodOptions: MethodOptions = {}) {
return this.setProperty(name, Method, methodOptions);
}
/**
* Returns an iterator providing the methods of the component.
*
* @param [options.filter] A function used to filter the methods to be returned. The function is invoked for each method with a [`Method`](https://layrjs.com/docs/v2/reference/method) instance as first argument.
*
* @returns A [`Method`](https://layrjs.com/docs/v2/reference/method) instance iterator.
*
* @example
* ```
* for (const meth of movie.getMethods()) {
* console.log(meth.getName());
* }
*
* // Should output:
* // play
* ```
*
* @category Method Properties
*/
static get getMethods() {
return this.prototype.getMethods;
}
/**
* Returns an iterator providing the methods of the component.
*
* @param [options.filter] A function used to filter the methods to be returned. The function is invoked for each method with a [`Method`](https://layrjs.com/docs/v2/reference/method) instance as first argument.
*
* @returns A [`Method`](https://layrjs.com/docs/v2/reference/method) instance iterator.
*
* @example
* ```
* for (const meth of movie.getMethods()) {
* console.log(meth.getName());
* }
*
* // Should output:
* // play
* ```
*
* @category Method Properties
*/
getMethods(options: {filter?: PropertyFilterSync; autoFork?: boolean} = {}) {
const {filter, autoFork = true} = options;
return this.getProperties({filter, autoFork, methodsOnly: true});
}
// === Dependency Management ===
// --- Component getters ---
/**
* Gets a component class that is provided or consumed by the current component. An error is thrown if there is no component matching the specified name. If the specified name is the name of the current component, the latter is returned.
*
* @param name The name of the component class to get.
*
* @returns A component class.
*
* @example
* ```
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Application.getComponent('Movie'); // => Movie
* Application.getComponent('Application'); // => Application
* ```
*
* @category Dependency Management
*/
static getComponent(name: string) {
const component = this.__getComponent(name);
if (component === undefined) {
throw new Error(
`Cannot get the component '${name}' from the component '${this.getComponentPath()}'`
);
}
return component;
}
/**
* Returns whether the current component provides or consumes another component.
*
* @param name The name of the component class to check.
*
* @returns A boolean.
*
* @example
* ```
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Application.hasComponent('Movie'); // => true
* Application.hasComponent('Application'); // => true
* Application.hasComponent('Film'); // => false
* ```
*
* @category Dependency Management
*/
static hasComponent(name: string) {
return this.__getComponent(name) !== undefined;
}
static __getComponent(name: string): typeof Component | undefined {
assertIsComponentName(name);
if (this.getComponentName() === name) {
return this;
}
let providedComponent = this.getProvidedComponent(name);
if (providedComponent !== undefined) {
return providedComponent;
}
const componentProvider = this.__getComponentProvider();
if (componentProvider !== undefined) {
return componentProvider.__getComponent(name);
}
return undefined;
}
/**
* Gets a component class or prototype of the specified type that is provided or consumed by the current component. An error is thrown if there is no component matching the specified type. If the specified type is the type of the current component, the latter is returned.
*
* @param type The type of the component class or prototype to get.
*
* @returns A component class or prototype.
*
* @example
* ```
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Application.getComponentOfType('typeof Movie'); // => Movie
* Application.getComponentOfType('Movie'); // => Movie.prototype
* Application.getComponentOfType('typeof Application'); // => Application
* Application.getComponentOfType('Application'); // => Application.prototype
* ```
*
* @category Dependency Management
*/
static getComponentOfType(type: string) {
const component = this.__getComponentOfType(type);
if (component === undefined) {
throw new Error(
`Cannot get the component of type '${type}' from the component '${this.getComponentPath()}'`
);
}
return component;
}
/**
* Returns whether the current component provides or consumes a component class or prototype matching the specified type.
*
* @param type The type of the component class or prototype to check.
*
* @returns A boolean.
*
* @example
* ```
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Application.hasComponentOfType('typeof Movie'); // => true
* Application.hasComponentOfType('Movie'); // => true
* Application.hasComponentOfType('typeof Application'); // => true
* Application.hasComponentOfType('Application'); // => true
* Application.hasComponentOfType('typeof Film'); // => false
* Application.hasComponentOfType('Film'); // => false
* ```
*
* @category Dependency Management
*/
static hasComponentOfType(type: string) {
return this.__getComponentOfType(type) !== undefined;
}
static __getComponentOfType(type: string) {
const isComponentClassType = assertIsComponentType(type) === 'componentClassType';
const componentName = isComponentClassType
? getComponentNameFromComponentClassType(type)
: getComponentNameFromComponentInstanceType(type);
const component = this.__getComponent(componentName);
if (component === undefined) {
return undefined;
}
return isComponentClassType ? component : component.prototype;
}
static traverseComponents(options: {filter?: (component: typeof Component) => boolean} = {}) {
const {filter} = options;
const component = this;
return {
*[Symbol.iterator](): Generator {
if (filter === undefined || filter(component)) {
yield component;
}
for (const providedComponent of component.getProvidedComponents({deep: true, filter})) {
yield providedComponent;
}
}
};
}
// --- Component provision ---
/**
* Gets a component that is provided by the current component. An error is thrown if there is no provided component with the specified name.
*
* @param name The name of the provided component to get.
*
* @returns A component class.
*
* @example
* ```
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* }
*
* Application.getProvidedComponent('Movie'); // => Movie
* ```
*
* @category Dependency Management
*/
static getProvidedComponent(name: string) {
assertIsComponentName(name);
const providedComponents = this.__getProvidedComponents();
let providedComponent = providedComponents[name];
if (providedComponent === undefined) {
return undefined;
}
if (!hasOwnProperty(providedComponents, name)) {
// Since the host component has been forked, the provided component must be forked as well
providedComponent = providedComponent.fork({componentProvider: this});
providedComponents[name] = providedComponent;
}
return providedComponent;
}
/**
* Specifies that the current component is providing another component so it can be easily accessed from the current component or from any component that is "consuming" it using the [`consumeComponent()`](https://layrjs.com/docs/v2/reference/component#consume-component-class-method) method or the [`@consume()`](https://layrjs.com/docs/v2/reference/component#consume-decorator) decorator.
*
* The provided component can later be accessed using a component accessor that was automatically set on the component provider.
*
* Typically, instead of using this method, you would rather use the [`@provide()`]((https://layrjs.com/docs/v2/reference/component#provide-decorator)) decorator.
*
* @param component The component class to provide.
*
* @example
* ```
* class Application extends Component {}
* class Movie extends Component {}
* Application.provideComponent(Movie);
*
* Application.Movie; // => `Movie` class
* ```
*
* @category Dependency Management
*/
static provideComponent(component: typeof Component) {
assertIsComponentClass(component);
const providedComponents = this.__getProvidedComponents();
const existingProvider = component.__getComponentProvider();
if (existingProvider !== undefined) {
if (existingProvider === this) {
return;
}
throw new Error(
`Cannot provide the component '${component.getComponentName()}' from '${this.getComponentName()}' because '${component.getComponentName()}' is already provided by '${existingProvider.getComponentName()}'`
);
}
const componentName = component.getComponentName();
const existingComponent = providedComponents[componentName];
if (existingComponent !== undefined && !component.isForkOf(existingComponent)) {
throw new Error(
`Cannot provide the component '${component.getComponentName()}' from '${this.getComponentName()}' because a component with the same name is already provided`
);
}
if (componentName in this) {
const descriptor = Object.getOwnPropertyDescriptor(this, componentName);
const value = descriptor?.value;
if (!(isComponentClass(value) && (value === component || value.isForkOf(component)))) {
throw new Error(
`Cannot provide the component '${component.getComponentName()}' from '${this.getComponentName()}' because there is an existing property with the same name`
);
}
}
component.__setComponentProvider(this);
providedComponents[componentName] = component;
Object.defineProperty(this, componentName, {
get(this: T) {
return this.getProvidedComponent(componentName);
},
set(this: T, component: typeof Component) {
// Set the value temporarily so @provide() can get it
Object.defineProperty(this, componentName, {
value: component,
configurable: true,
enumerable: true,
writable: true
});
}
});
}
/**
* Returns an iterator allowing to iterate over the components provided by the current component.
*
* @param [options.filter] A function used to filter the provided components to be returned. The function is invoked for each provided component with the provided component as first argument.
* @param [options.deep] A boolean specifying whether the method should get the provided components recursively (i.e., get the provided components of the provided components). Default: `false`.
*
* @returns A component iterator.
*
* @category Dependency Management
*/
static getProvidedComponents(
options: {deep?: boolean; filter?: (providedComponent: typeof Component) => boolean} = {}
) {
const {deep = false, filter} = options;
const component = this;
return {
*[Symbol.iterator](): Generator {
for (const name in component.__getProvidedComponents()) {
const providedComponent = component.getProvidedComponent(name)!;
if (filter !== undefined && !filter(providedComponent)) {
continue;
}
yield providedComponent;
if (deep) {
for (const nestedProvidedComponent of providedComponent.getProvidedComponents({
deep,
filter
})) {
yield nestedProvidedComponent;
}
}
}
}
};
}
/**
* Returns the provider of the component. If there is no component provider, returns the current component.
*
* @returns A component provider.
*
* @example
* ```
* class Application extends Component {}
* class Movie extends Component {}
* Application.provideComponent(Movie);
*
* Movie.getComponentProvider(); // => `Application` class
* Application.getComponentProvider(); // => `Application` class
* ```
*
* @category Dependency Management
*/
static getComponentProvider() {
const componentName = this.getComponentName();
let currentComponent = this;
while (true) {
const componentProvider = currentComponent.__getComponentProvider();
if (componentProvider === undefined) {
return currentComponent;
}
const providedComponent = componentProvider.getProvidedComponent(componentName);
if (providedComponent !== undefined) {
return componentProvider;
}
currentComponent = componentProvider;
}
}
static __componentProvider?: typeof Component;
static __getComponentProvider() {
return hasOwnProperty(this, '__componentProvider') ? this.__componentProvider : undefined;
}
static __setComponentProvider(componentProvider: typeof Component) {
Object.defineProperty(this, '__componentProvider', {value: componentProvider});
}
static __providedComponents: {[name: string]: typeof Component};
static __getProvidedComponents() {
if (this.__providedComponents === undefined) {
Object.defineProperty(this, '__providedComponents', {
value: Object.create(null)
});
} else if (!hasOwnProperty(this, '__providedComponents')) {
Object.defineProperty(this, '__providedComponents', {
value: Object.create(this.__providedComponents)
});
}
return this.__providedComponents;
}
// --- Component consumption ---
/**
* Gets a component that is consumed by the current component. An error is thrown if there is no consumed component with the specified name. Typically, instead of using this method, you would rather use the component accessor that has been automatically set for you.
*
* @param name The name of the consumed component to get.
*
* @returns A component class.
*
* @example
* ```
* // JS
*
* class Movie extends Component {
* ﹫consume() static Actor;
* }
*
* class Actor extends Component {}
*
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* ﹫provide() static Actor = Actor;
* }
*
* Movie.getConsumedComponent('Actor'); // => Actor
*
* // Typically, you would rather use the component accessor:
* Movie.Actor; // => Actor
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Component {
* ﹫consume() static Actor: typeof Actor;
* }
*
* class Actor extends Component {}
*
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* ﹫provide() static Actor = Actor;
* }
*
* Movie.getConsumedComponent('Actor'); // => Actor
*
* // Typically, you would rather use the component accessor:
* Movie.Actor; // => Actor
* ```
*
* @category Dependency Management
*/
static getConsumedComponent(name: string) {
assertIsComponentName(name);
const consumedComponents = this.__getConsumedComponents();
if (!consumedComponents.has(name)) {
return undefined;
}
const componentProvider = this.__getComponentProvider();
if (componentProvider === undefined) {
return undefined;
}
return componentProvider.__getComponent(name);
}
/**
* Specifies that the current component is consuming another component so it can be easily accessed using a component accessor.
*
* Typically, instead of using this method, you would rather use the [`@consume()`]((https://layrjs.com/docs/v2/reference/component#consume-decorator)) decorator.
*
* @param name The name of the component to consume.
*
* @example
* ```
* class Application extends Component {}
* class Movie extends Component {}
* Application.provideComponent(Movie);
* Movie.consumeComponent('Application');
*
* Movie.Application; // => `Application` class
* ```
*
* @category Dependency Management
*/
static consumeComponent(name: string) {
assertIsComponentName(name);
const consumedComponents = this.__getConsumedComponents(true);
if (consumedComponents.has(name)) {
return;
}
if (name in this) {
throw new Error(
`Cannot consume the component '${name}' from '${this.getComponentName()}' because there is an existing property with the same name`
);
}
consumedComponents.add(name);
Object.defineProperty(this, name, {
get(this: T) {
return this.getConsumedComponent(name);
},
set(this: T, _value: never) {
// A component consumer should not be set directly
}
});
}
/**
* Returns an iterator allowing to iterate over the components consumed by the current component.
*
* @param [options.filter] A function used to filter the consumed components to be returned. The function is invoked for each consumed component with the consumed component as first argument.
*
* @returns A component iterator.
*
* @category Dependency Management
*/
static getConsumedComponents(
options: {filter?: (consumedComponent: typeof Component) => boolean} = {}
) {
const {filter} = options;
const component = this;
return {
*[Symbol.iterator]() {
for (const name of component.__getConsumedComponents()) {
const consumedComponent = component.getConsumedComponent(name)!;
if (filter !== undefined && !filter(consumedComponent)) {
continue;
}
yield consumedComponent;
}
}
};
}
static __consumedComponents: Set;
static __getConsumedComponents(autoFork = false) {
if (this.__consumedComponents === undefined) {
Object.defineProperty(this, '__consumedComponents', {
value: new Set()
});
} else if (autoFork && !hasOwnProperty(this, '__consumedComponents')) {
Object.defineProperty(this, '__consumedComponents', {
value: new Set(this.__consumedComponents)
});
}
return this.__consumedComponents;
}
// === Cloning ===
static clone() {
return this;
}
/**
* Clones the component instance. All primitive attributes are copied, and embedded components are cloned recursively. Currently, identifiable components (i.e., components having an identifier attribute) cannot be cloned, but this might change in the future.
*
* @returns A clone of the component.
*
* @example
* ```
* movie.title = 'Inception';
*
* const movieClone = movie.clone();
* movieClone.title = 'Inception 2';
*
* movieClone.title; // => 'Inception 2'
* movie.title; // => 'Inception'
* ```
*
* @category Cloning
* @possiblyasync
*/
clone(this: T, options: CloneOptions = {}): T {
if (this.hasPrimaryIdentifierAttribute()) {
return this;
}
const clonedComponent = this.constructor.instantiate() as T;
clonedComponent.setIsNewMark(this.getIsNewMark());
for (const attribute of this.getAttributes({setAttributesOnly: true})) {
const name = attribute.getName();
const value = attribute.getValue();
const source = attribute.getValueSource();
const clonedValue = clone(value, options);
clonedComponent.getAttribute(name).setValue(clonedValue, {source});
}
return clonedComponent;
}
// === Forking ===
/**
* Creates a fork of the component class.
*
* @returns The component class fork.
*
* @example
* ```
* class Movie extends Component {}
*
* Movie.fork(); // => A fork of the `Movie` class
* ```
*
* @category Forking
*/
static fork(this: T, options: ForkOptions = {}): T {
const {componentProvider = this.__getComponentProvider()} = options;
const name = this.getComponentName();
// Use a little trick to make sure the generated subclass
// has the 'name' attribute set properly
// @ts-ignore
const {[name]: componentFork} = {[name]: class extends this {}};
if (componentFork.name !== name) {
// In case the code has been transpiled by Babel with @babel/plugin-transform-classes,
// the above trick doesn't work, so let's set the class name manually
Object.defineProperty(componentFork, 'name', {value: name});
}
if (componentProvider !== undefined) {
componentFork.__setComponentProvider(componentProvider);
}
return componentFork;
}
/**
* Creates a fork of the component instance. Note that the constructor of the resulting component will be a fork of the component class.
*
* @returns The component instance fork.
*
* @example
* ```
* class Movie extends Component {}
* const movie = new Movie();
*
* movie.fork(); // => A fork of `movie`
* movie.fork().constructor.isForkOf(Movie); // => true
* ```
*
* @category Forking
*/
fork(this: T, options: ForkOptions = {}) {
let {componentClass} = options;
if (componentClass === undefined) {
componentClass = this.constructor.fork();
} else {
assertIsComponentClass(componentClass);
}
const componentFork = Object.create(this) as T;
if (this.constructor !== componentClass) {
// Make 'componentFork' believe that it is an instance of 'Component'
// It can happen when a referenced component is forked
Object.defineProperty(componentFork, 'constructor', {
value: componentClass,
writable: true,
enumerable: false,
configurable: true
});
if (componentFork.hasPrimaryIdentifierAttribute() && componentFork.isAttached()) {
componentClass.getIdentityMap().addComponent(componentFork);
}
}
return componentFork;
}
/**
* Returns whether the component class is a fork of another component class.
*
* @returns A boolean.
*
* @example
* ```
* class Movie extends Component {}
* const MovieFork = Movie.fork();
*
* MovieFork.isForkOf(Movie); // => true
* Movie.isForkOf(MovieFork); // => false
* ```
*
* @category Forking
*/
static isForkOf(component: typeof Component) {
assertIsComponentClass(component);
return isPrototypeOf(component, this);
}
/**
* Returns whether the component instance is a fork of another component instance.
*
* @returns A boolean.
*
* @example
* ```
* class Movie extends Component {}
* const movie = new Movie();
* const movieFork = movie.fork();
*
* movieFork.isForkOf(movie); // => true
* movie.isForkOf(movieFork); // => false
* ```
*
* @category Forking
*/
isForkOf(component: Component) {
assertIsComponentInstance(component);
return isPrototypeOf(component, this);
}
static __ghost: typeof Component;
/**
* Gets the ghost of the component class. A ghost is like a fork, but it is unique. The first time you call this method, a fork is created, and then, all the successive calls return the same fork.
*
* @returns The ghost of the component class.
*
* @example
* ```
* class Movie extends Component {}
*
* Movie.getGhost() // => A fork of the `Movie` class
* Movie.getGhost() // => The same fork of the `Movie` class
* ```
*
* @category Forking
*/
static getGhost(this: T) {
let ghost = this.__ghost;
if (ghost === undefined) {
const componentProvider = this.getComponentProvider();
if (componentProvider === this) {
ghost = this.fork();
} else {
ghost = componentProvider.getGhost().getComponent(this.getComponentName());
}
Object.defineProperty(this, '__ghost', {value: ghost});
}
return ghost as T;
}
/**
* Gets the ghost of the component instance. A ghost is like a fork, but it is unique. The first time you call this method, a fork is created, and then, all the successive calls return the same fork. Only identifiable components (i.e., components having an identifier attribute) can be "ghosted".
*
* @returns The ghost of the component instance.
*
* @example
* ```
* // JS
*
* class Movie extends Component {
* ﹫primaryIdentifier() id;
* }
*
* const movie = new Movie();
*
* movie.getGhost() // => A fork of `movie`
* movie.getGhost() // => The same fork of `movie`
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Component {
* ﹫primaryIdentifier() id!: string;
* }
*
* const movie = new Movie();
*
* movie.getGhost() // => A fork of `movie`
* movie.getGhost() // => The same fork of `movie`
* ```
*
* @category Forking
*/
getGhost(this: T): T {
const identifiers = this.getIdentifiers();
const ghostClass = this.constructor.getGhost();
const ghostIdentityMap = ghostClass.getIdentityMap();
let ghostComponent = ghostIdentityMap.getComponent(identifiers);
if (ghostComponent === undefined) {
ghostComponent = this.fork({componentClass: ghostClass});
ghostIdentityMap.addComponent(ghostComponent);
}
return ghostComponent as T;
}
// === Merging ===
/**
* Merges the attributes of a component class fork into the current component class.
*
* @param componentFork The component class fork to merge.
*
* @returns The current component class.
*
* @example
* ```
* class Movie extends Component {
* ﹫attribute('string') static customName = 'Movie';
* }
*
* const MovieFork = Movie.fork();
* MovieFork.customName = 'Film';
*
* Movie.customName; // => 'Movie'
* Movie.merge(MovieFork);
* Movie.customName; // => 'Film'
* ```
*
* @category Merging
*/
static merge(
this: T,
componentFork: typeof Component,
options: MergeOptions & {attributeSelector?: AttributeSelector} = {}
) {
assertIsComponentClass(componentFork);
if (!isPrototypeOf(this, componentFork)) {
throw new Error('Cannot merge a component that is not a fork of the target component');
}
this.__mergeAttributes(componentFork, options);
return this;
}
/**
* Merges the attributes of a component instance fork into the current component instance.
*
* @param componentFork The component instance fork to merge.
*
* @returns The current component instance.
*
* @example
* ```
* const movie = new Movie({title: 'Inception'});
* const movieFork = movie.fork();
* movieFork.title = 'Inception 2';
*
* movie.title; // => 'Inception'
* movie.merge(movieFork);
* movie.title; // => 'Inception 2'
* ```
*
* @category Merging
*/
merge(
componentFork: Component,
options: MergeOptions & {attributeSelector?: AttributeSelector} = {}
) {
assertIsComponentInstance(componentFork);
if (!isPrototypeOf(this, componentFork)) {
throw new Error('Cannot merge a component that is not a fork of the target component');
}
this.__mergeAttributes(componentFork, options);
return this;
}
static get __mergeAttributes() {
return this.prototype.__mergeAttributes;
}
__mergeAttributes(
componentFork: typeof Component | Component,
{attributeSelector, ...otherOptions}: MergeOptions & {attributeSelector?: AttributeSelector}
) {
for (const attributeFork of componentFork.getAttributes({attributeSelector})) {
const name = attributeFork.getName();
const attribute = this.getAttribute(name);
if (!attributeFork.isSet()) {
if (attribute.isSet()) {
attribute.unsetValue();
}
continue;
}
const valueFork = attributeFork.getValue();
const value = attribute.getValue({throwIfUnset: false});
const mergedValue = merge(value, valueFork, otherOptions);
attribute.setValue(mergedValue, {source: attributeFork.getValueSource()});
}
}
// === Serialization ===
/**
* Serializes the component class to a plain object.
*
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be serialized (default: `true`, which means that all the attributes will be serialized).
* @param [options.attributeFilter] A (possibly async) function used to filter the attributes to be serialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.target] A string specifying the [target](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the serialization (default: `undefined`).
*
* @returns A plain object representing the serialized component class.
*
* @example
* ```
* class Movie extends Component {
* ﹫attribute('string') static customName = 'Film';
* }
*
* Movie.serialize(); // => {__component: 'typeof Movie', customName: 'Film'}
* ```
*
* @category Serialization
* @possiblyasync
*/
static serialize(options: SerializeOptions = {}) {
const {
attributeSelector = true,
serializedComponents = new Set(),
returnComponentReferences = false,
ignoreEmptyComponents = false,
includeComponentTypes = true,
includeReferencedComponents = false,
target,
...otherOptions
} = options;
const resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
setAttributesOnly: true,
target,
aggregationMode: 'intersection',
includeReferencedComponents
});
return this.__serialize({
...otherOptions,
attributeSelector: resolvedAttributeSelector,
serializedComponents,
returnComponentReferences,
ignoreEmptyComponents,
includeComponentTypes,
includeReferencedComponents,
target
});
}
static __serialize(options: SerializeOptions) {
const {
serializedComponents,
componentDependencies,
returnComponentReferences,
ignoreEmptyComponents,
includeComponentTypes,
includeReferencedComponents
} = options;
const serializedComponent: PlainObject = {};
if (includeComponentTypes) {
serializedComponent.__component = this.getComponentType();
}
const hasAlreadyBeenSerialized = serializedComponents!.has(this);
if (!hasAlreadyBeenSerialized) {
serializedComponents!.add(this);
if (componentDependencies !== undefined) {
for (const providedComponent of this.getProvidedComponents()) {
componentDependencies.add(providedComponent);
}
for (const consumedComponent of this.getConsumedComponents()) {
componentDependencies.add(consumedComponent);
}
}
}
if (hasAlreadyBeenSerialized || (returnComponentReferences && !includeReferencedComponents)) {
return serializedComponent;
}
return possiblyAsync(
this.__serializeAttributes(serializedComponent, options),
(attributeCount) =>
ignoreEmptyComponents && attributeCount === 0 ? undefined : serializedComponent
);
}
/**
* Serializes the component instance to a plain object.
*
* @param [options.attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be serialized (default: `true`, which means that all the attributes will be serialized).
* @param [options.attributeFilter] A (possibly async) function used to filter the attributes to be serialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.target] A string specifying the [target](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the serialization (default: `undefined`).
*
* @returns A plain object representing the serialized component instance.
*
* @example
* ```
* const movie = new Movie({title: 'Inception'});
*
* movie.serialize(); // => {__component: 'Movie', title: 'Inception'}
* ```
*
* @category Serialization
* @possiblyasync
*/
serialize(options: SerializeOptions = {}) {
const {
attributeSelector = true,
serializedComponents = new Set(),
returnComponentReferences = false,
ignoreEmptyComponents = false,
includeComponentTypes = true,
includeIsNewMarks = true,
includeReferencedComponents = false,
target,
...otherOptions
} = options;
const resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
setAttributesOnly: true,
target,
aggregationMode: 'intersection',
includeReferencedComponents
});
return this.__serialize({
...otherOptions,
attributeSelector: resolvedAttributeSelector,
serializedComponents,
returnComponentReferences,
ignoreEmptyComponents,
includeComponentTypes,
includeIsNewMarks,
includeReferencedComponents,
target
});
}
__serialize(options: SerializeOptions) {
const {
serializedComponents,
componentDependencies,
returnComponentReferences,
ignoreEmptyComponents,
includeComponentTypes,
includeIsNewMarks,
includeReferencedComponents
} = options;
const serializedComponent: PlainObject = {};
if (includeComponentTypes) {
serializedComponent.__component = this.getComponentType();
}
const isEmbedded = this.constructor.isEmbedded();
if (!isEmbedded) {
const hasAlreadyBeenSerialized = serializedComponents!.has(this);
if (!hasAlreadyBeenSerialized) {
if (componentDependencies !== undefined) {
componentDependencies.add(this.constructor);
for (const providedComponent of this.constructor.getProvidedComponents()) {
componentDependencies.add(providedComponent);
}
for (const consumedComponent of this.constructor.getConsumedComponents()) {
componentDependencies.add(consumedComponent);
}
}
}
if (hasAlreadyBeenSerialized || (returnComponentReferences && !includeReferencedComponents)) {
Object.assign(serializedComponent, this.getIdentifierDescriptor());
return serializedComponent;
}
serializedComponents!.add(this);
}
const isNew = this.getIsNewMark();
if (isNew && includeIsNewMarks) {
serializedComponent.__new = true;
}
return possiblyAsync(
this.__serializeAttributes(serializedComponent, options),
(attributeCount) =>
ignoreEmptyComponents && attributeCount <= this.__getMinimumAttributeCount()
? undefined
: serializedComponent
);
}
static get __serializeAttributes() {
return this.prototype.__serializeAttributes;
}
__serializeAttributes(
serializedComponent: PlainObject,
options: SerializeOptions
): PromiseLikeable {
let {attributeSelector, attributeFilter} = options;
let attributeCount = 0;
return possiblyAsync(
possiblyAsync.forEach(this.getAttributes({attributeSelector}), (attribute) => {
const attributeName = attribute.getName();
const subattributeSelector = getFromAttributeSelector(attributeSelector!, attributeName);
return possiblyAsync(
attributeFilter !== undefined ? attributeFilter.call(this, attribute) : true,
(isNotFilteredOut) => {
if (isNotFilteredOut) {
return possiblyAsync(
attribute.serialize({
...options,
attributeSelector: subattributeSelector,
returnComponentReferences: true
}),
(serializedAttributeValue) => {
serializedComponent[attributeName] = serializedAttributeValue;
attributeCount++;
}
);
}
}
);
}),
() => attributeCount
);
}
// === Deserialization ===
/**
* Deserializes the component class from the specified plain object. The deserialization operates "in place", which means that the current component class attributes are mutated.
*
* @param [object] The plain object to deserialize from.
* @param [options.attributeFilter] A (possibly async) function used to filter the attributes to be deserialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.source] A string specifying the [source](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the serialization (default: `'local'`).
*
* @returns The component class.
*
* @example
* ```
* class Movie extends Component {
* ﹫attribute('string') static customName = 'Movie';
* }
*
* Movie.customName; // => 'Movie'
* Movie.deserialize({customName: 'Film'});
* Movie.customName; // => 'Film'
* ```
*
* @category Deserialization
* @possiblyasync
*/
static deserialize(
this: T,
object: PlainObject = {},
options: DeserializeOptions = {}
): T | PromiseLike {
const {__component: componentType, ...attributes} = object;
const {deserializedComponents} = options;
if (componentType !== undefined) {
const expectedComponentType = this.getComponentType();
if (componentType !== expectedComponentType) {
throw new Error(
`An unexpected component type was encountered while deserializing an object (encountered type: '${componentType}', expected type: '${expectedComponentType}')`
);
}
}
if (deserializedComponents !== undefined) {
deserializedComponents.add(this);
}
return possiblyAsync(this.__deserializeAttributes(attributes, options), () => this);
}
/**
* Deserializes the component instance from the specified plain object. The deserialization operates "in place", which means that the current component instance attributes are mutated.
*
* @param [object] The plain object to deserialize from.
* @param [options.attributeFilter] A (possibly async) function used to filter the attributes to be deserialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.source] A string specifying the [source](https://layrjs.com/docs/v2/reference/attribute#value-source-type) of the serialization (default: `'local'`).
*
* @returns The current component instance.
*
* @example
* ```
* class Movie extends Component {
* ﹫attribute('string') title = '';
* }
*
* const movie = new Movie();
*
* movie.title; // => ''
* movie.deserialize({title: 'Inception'});
* movie.title; // => 'Inception'
* ```
*
* @category Deserialization
* @possiblyasync
*/
deserialize(
this: T,
object: PlainObject = {},
options: DeserializeOptions = {}
): T | PromiseLike {
const {deserializedComponents} = options;
const {__component: componentType, __new: isNew = false, ...attributes} = object;
if (componentType !== undefined) {
const expectedComponentType = this.getComponentType();
if (componentType !== expectedComponentType) {
throw new Error(
`An unexpected component type was encountered while deserializing an object (encountered type: '${componentType}', expected type: '${expectedComponentType}')`
);
}
}
if (isNew && !this.getIsNewMark()) {
throw new Error(
`Cannot mark as new an existing non-new component (${this.describeComponent()})`
);
}
this.setIsNewMark(isNew);
if (deserializedComponents !== undefined && !this.constructor.isEmbedded()) {
deserializedComponents.add(this);
}
return possiblyAsync(this.__deserializeAttributes(attributes, options), () => this);
}
static get __deserializeAttributes() {
return this.prototype.__deserializeAttributes;
}
__deserializeAttributes(
serializedAttributes: PlainObject,
options: DeserializeOptions
): void | PromiseLike {
const {attributeFilter} = options;
return possiblyAsync.forEach(
Object.entries(serializedAttributes),
([attributeName, serializedAttributeValue]: [string, unknown]) => {
const attribute = this.getAttribute(attributeName);
return possiblyAsync(
attributeFilter !== undefined ? attributeFilter.call(this, attribute) : true,
(isNotFilteredOut) => {
if (isNotFilteredOut) {
return attribute.deserialize(serializedAttributeValue, options);
}
}
);
}
);
}
// === Execution mode ===
static __executionMode: ExecutionMode;
static getExecutionMode() {
let currentComponent = this;
while (true) {
const executionMode = currentComponent.__executionMode;
if (executionMode !== undefined) {
return executionMode;
}
const componentProvider = currentComponent.getComponentProvider();
if (componentProvider === currentComponent) {
return 'foreground';
}
currentComponent = componentProvider;
}
}
static setExecutionMode(executionMode: ExecutionMode) {
Object.defineProperty(this, '__executionMode', {value: executionMode});
}
// === Introspection ===
static introspect({
_introspectedComponents = new Map()
}: {_introspectedComponents?: IntrospectedComponentMap} = {}) {
if (_introspectedComponents.has(this)) {
return _introspectedComponents.get(this);
}
let introspectedComponent: IntrospectedComponent | undefined;
const introspectedProperties = this.__introspectProperties();
const introspectedPrototypeProperties = this.prototype.__introspectProperties();
const introspectedProvidedComponents = this.__introspectProvidedComponents({
_introspectedComponents
});
if (
introspectedProperties.length > 0 ||
introspectedPrototypeProperties.length > 0 ||
introspectedProvidedComponents.length > 0
) {
introspectedComponent = {
name: this.getComponentName()
};
}
_introspectedComponents.set(this, introspectedComponent);
if (introspectedComponent === undefined) {
return undefined;
}
if (this.isEmbedded()) {
introspectedComponent.isEmbedded = true;
}
const introspectedMixins = this.__introspectMixins();
if (introspectedMixins.length > 0) {
introspectedComponent.mixins = introspectedMixins;
}
if (introspectedProperties.length > 0) {
introspectedComponent.properties = introspectedProperties;
}
if (introspectedPrototypeProperties.length > 0) {
introspectedComponent.prototype = {properties: introspectedPrototypeProperties};
}
if (introspectedProvidedComponents.length > 0) {
introspectedComponent.providedComponents = introspectedProvidedComponents;
}
const introspectedConsumedComponents = this.__introspectConsumedComponents({
_introspectedComponents
});
if (introspectedConsumedComponents.length > 0) {
introspectedComponent.consumedComponents = introspectedConsumedComponents;
}
return introspectedComponent;
}
static __introspectMixins() {
const introspectedMixins = new Array();
let currentClass = this;
while (isComponentClass(currentClass)) {
if (hasOwnProperty(currentClass, '__mixin')) {
const mixinName = (currentClass as any).__mixin;
if (!introspectedMixins.includes(mixinName)) {
introspectedMixins.unshift(mixinName);
}
}
currentClass = Object.getPrototypeOf(currentClass);
}
return introspectedMixins;
}
static get __introspectProperties() {
return this.prototype.__introspectProperties;
}
__introspectProperties() {
const introspectedProperties = [];
for (const property of this.getProperties({autoFork: false})) {
const introspectedProperty = property.introspect();
if (introspectedProperty !== undefined) {
introspectedProperties.push(introspectedProperty);
}
}
return introspectedProperties;
}
static __introspectProvidedComponents({
_introspectedComponents
}: {
_introspectedComponents: IntrospectedComponentMap;
}) {
const introspectedProvidedComponents = [];
for (const providedComponent of this.getProvidedComponents()) {
const introspectedProvidedComponent = providedComponent.introspect({_introspectedComponents});
if (introspectedProvidedComponent !== undefined) {
introspectedProvidedComponents.push(introspectedProvidedComponent);
}
}
return introspectedProvidedComponents;
}
static __introspectConsumedComponents({
_introspectedComponents
}: {
_introspectedComponents: IntrospectedComponentMap;
}) {
const introspectedConsumedComponents = [];
for (const consumedComponent of this.getConsumedComponents()) {
const introspectedConsumedComponent = consumedComponent.introspect({_introspectedComponents});
if (introspectedConsumedComponent !== undefined) {
introspectedConsumedComponents.push(consumedComponent.getComponentName());
}
}
return introspectedConsumedComponents;
}
static unintrospect(
introspectedComponent: IntrospectedComponent,
options: {mixins?: ComponentMixin[]; methodBuilder?: MethodBuilder} = {}
): typeof Component {
const {
name,
isEmbedded,
mixins: introspectedMixins,
properties: introspectedProperties,
prototype: {properties: introspectedPrototypeProperties} = {},
providedComponents: introspectedProvidedComponents,
consumedComponents: introspectedConsumedComponents
} = introspectedComponent;
const {mixins = [], methodBuilder} = options;
let UnintrospectedComponent = class extends Component {};
if (isEmbedded) {
UnintrospectedComponent.isEmbedded = function () {
return true;
};
}
if (introspectedMixins !== undefined) {
UnintrospectedComponent = UnintrospectedComponent.__unintrospectMixins(introspectedMixins, {
mixins
});
}
const propertyClassGetter = UnintrospectedComponent.getPropertyClass;
if (introspectedProperties !== undefined) {
UnintrospectedComponent.__unintrospectProperties(
introspectedProperties,
propertyClassGetter,
{methodBuilder}
);
}
if (introspectedPrototypeProperties !== undefined) {
UnintrospectedComponent.prototype.__unintrospectProperties(
introspectedPrototypeProperties,
propertyClassGetter,
{methodBuilder}
);
}
UnintrospectedComponent.setComponentName(name);
if (introspectedProvidedComponents !== undefined) {
UnintrospectedComponent.__unintrospectProvidedComponents(introspectedProvidedComponents, {
mixins,
methodBuilder
});
}
if (introspectedConsumedComponents !== undefined) {
UnintrospectedComponent.__unintrospectConsumedComponents(introspectedConsumedComponents);
}
UnintrospectedComponent.__setRemoteComponent(UnintrospectedComponent);
if (methodBuilder !== undefined) {
UnintrospectedComponent.__setRemoteMethodBuilder(methodBuilder);
}
return UnintrospectedComponent;
}
static __unintrospectMixins(introspectedMixins: string[], {mixins}: {mixins: ComponentMixin[]}) {
let UnintrospectedComponentWithMixins = this;
for (const mixinName of introspectedMixins) {
const Mixin = mixins.find((Mixin) => getFunctionName(Mixin) === mixinName);
if (Mixin === undefined) {
throw new Error(
`Couldn't find a component mixin named '${mixinName}'. Please make sure you specified it when creating your 'ComponentClient'.`
);
}
UnintrospectedComponentWithMixins = Mixin(UnintrospectedComponentWithMixins);
}
return UnintrospectedComponentWithMixins;
}
static get __unintrospectProperties() {
return this.prototype.__unintrospectProperties;
}
__unintrospectProperties(
introspectedProperties: IntrospectedProperty[],
propertyClassGetter: typeof Component['getPropertyClass'],
{methodBuilder}: {methodBuilder: MethodBuilder | undefined}
) {
for (const introspectedProperty of introspectedProperties) {
const {type} = introspectedProperty;
const PropertyClass = propertyClassGetter.call(ensureComponentClass(this), type);
const {name, options} = PropertyClass.unintrospect(introspectedProperty);
const property = this.setProperty(name, PropertyClass, options);
if (isAttributeInstance(property)) {
if (property.getExposure()?.set !== true) {
property.markAsControlled();
}
} else if (isMethodInstance(property)) {
if (
property.getExposure()?.call === true &&
methodBuilder !== undefined &&
!(name in this)
) {
Object.defineProperty(this, name, {
value: methodBuilder(name),
writable: true,
enumerable: false,
configurable: true
});
}
}
}
}
static __unintrospectProvidedComponents(
introspectedProvidedComponents: IntrospectedComponent[],
{
mixins,
methodBuilder
}: {mixins: ComponentMixin[] | undefined; methodBuilder: MethodBuilder | undefined}
) {
for (const introspectedProvidedComponent of introspectedProvidedComponents) {
this.provideComponent(
Component.unintrospect(introspectedProvidedComponent, {mixins, methodBuilder})
);
}
}
static __unintrospectConsumedComponents(introspectedConsumedComponents: string[]) {
for (const introspectedConsumedComponent of introspectedConsumedComponents) {
this.consumeComponent(introspectedConsumedComponent);
}
}
// === Remote component ===
static __remoteComponent: typeof Component | undefined;
static getRemoteComponent() {
return this.__remoteComponent;
}
getRemoteComponent() {
return this.constructor.getRemoteComponent()?.prototype;
}
static __setRemoteComponent(remoteComponent: typeof Component) {
Object.defineProperty(this, '__remoteComponent', {value: remoteComponent});
}
// === Remote methods ===
static get hasRemoteMethod() {
return this.prototype.hasRemoteMethod;
}
hasRemoteMethod(name: string) {
const remoteComponent = this.getRemoteComponent();
if (!remoteComponent?.hasMethod(name)) {
return false;
}
const remoteMethod = remoteComponent.getMethod(name, {autoFork: false});
return remoteMethod.getExposure()?.call === true;
}
static get callRemoteMethod() {
return this.prototype.callRemoteMethod;
}
callRemoteMethod(name: string, ...args: any[]): any {
const remoteMethodBuilder = ensureComponentClass(this).__remoteMethodBuilder;
if (remoteMethodBuilder === undefined) {
throw new Error(
`Cannot call the remote method '${name}' for a component that does not come from a component client (${this.describeComponent()})`
);
}
return remoteMethodBuilder(name).apply(this, args);
}
static __remoteMethodBuilder: MethodBuilder | undefined;
static __setRemoteMethodBuilder(methodBuilder: MethodBuilder) {
Object.defineProperty(this, '__remoteMethodBuilder', {value: methodBuilder});
}
// === Utilities ===
static isComponent(value: any): value is Component {
return isComponentInstance(value);
}
static get toObject() {
return this.prototype.toObject;
}
toObject(options: {minimize?: boolean} = {}) {
const {minimize = false} = options;
if (minimize) {
if (isComponentClass(this)) {
return {};
}
if (this.hasIdentifiers()) {
return this.getIdentifierDescriptor();
}
}
const object: PlainObject = {};
const handleValue = (value: unknown): unknown => {
if (isComponentClassOrInstance(value)) {
const component = value;
if (!ensureComponentClass(component).isEmbedded()) {
return component.toObject({minimize: true});
} else {
return component.toObject({minimize});
}
}
if (Array.isArray(value)) {
return value.map(handleValue);
}
return value;
};
for (const attribute of this.getAttributes({setAttributesOnly: true})) {
object[attribute.getName()] = handleValue(attribute.getValue());
}
return object;
}
static get describeComponent() {
return this.prototype.describeComponent;
}
describeComponent(options: {componentPrefix?: string} = {}) {
let {componentPrefix = ''} = options;
if (componentPrefix !== '') {
componentPrefix = `${componentPrefix} `;
}
return `${componentPrefix}component: '${ensureComponentClass(this).getComponentPath()}'`;
}
static describeComponentProperty(name: string) {
return `${this.getComponentPath()}.${name}`;
}
describeComponentProperty(name: string) {
return `${this.constructor.getComponentPath()}.prototype.${name}`;
}
}
// The following would be better defined inside the Component class
// but it leads to a TypeScript (4.3) compilation error in transient dependencies
Object.defineProperty(Component, Symbol.hasInstance, {
value: function (instance: any) {
// Since fork() can change the constructor of the instance forks,
// we must change the behavior of 'instanceof' so it can work as expected
return instance.constructor === this || isPrototypeOf(this, instance.constructor);
}
});
type CreatePropertyFilterOptions = {
attributesOnly?: boolean;
methodsOnly?: boolean;
} & CreatePropertyFilterOptionsForAttributes;
type CreatePropertyFilterOptionsForAttributes = {
attributeSelector?: AttributeSelector;
setAttributesOnly?: boolean;
};
function createPropertyFilter(
originalFilter?: PropertyFilterSync,
options: CreatePropertyFilterOptions = {}
) {
const {
attributesOnly = false,
attributeSelector = true,
setAttributesOnly = false,
methodsOnly = false
} = options;
const normalizedAttributeSelector = normalizeAttributeSelector(attributeSelector);
const filter = function (this: typeof Component | Component, property: Property) {
if (isAttributeInstance(property)) {
const attribute = property;
if (setAttributesOnly && !attribute.isSet()) {
return false;
}
const name = attribute.getName();
if (getFromAttributeSelector(normalizedAttributeSelector, name) === false) {
return false;
}
} else if (attributesOnly) {
return false;
}
if (isMethodInstance(property)) {
// NOOP
} else if (methodsOnly) {
return false;
}
if (originalFilter !== undefined) {
return originalFilter.call(this, property);
}
return true;
};
return filter;
}
================================================
FILE: packages/component/src/decorators.test.ts
================================================
import {Component} from './component';
import {
isAttributeInstance,
isPrimaryIdentifierAttributeInstance,
isSecondaryIdentifierAttributeInstance,
isStringValueTypeInstance,
isNumberValueTypeInstance,
isMethodInstance
} from './properties';
import {
attribute,
primaryIdentifier,
secondaryIdentifier,
method,
expose,
provide,
consume
} from './decorators';
describe('Decorators', () => {
test('@attribute()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@attribute() static token?: string;
@attribute() title? = '';
@attribute() country?: string;
}
let attr = Movie.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Movie);
expect(attr.getValue()).toBe(100);
expect(Movie.limit).toBe(100);
Movie.limit = 500;
expect(attr.getValue()).toBe(500);
expect(Movie.limit).toBe(500);
let descriptor = Object.getOwnPropertyDescriptor(Movie, 'limit')!;
expect(typeof descriptor.get).toBe('function');
expect(typeof descriptor.set).toBe('function');
attr = Movie.getAttribute('token');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('token');
expect(attr.getParent()).toBe(Movie);
expect(attr.getValue()).toBeUndefined();
expect(Movie.token).toBeUndefined();
let movie = new Movie();
attr = movie.getAttribute('title');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('title');
expect(attr.getParent()).toBe(movie);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(movie.title).toBe('');
movie.title = 'The Matrix';
expect(attr.getValue()).toBe('The Matrix');
expect(movie.title).toBe('The Matrix');
descriptor = Object.getOwnPropertyDescriptor(Movie.prototype, 'title')!;
expect(typeof descriptor.get).toBe('function');
expect(typeof descriptor.set).toBe('function');
expect(Object.getOwnPropertyDescriptor(movie, 'title')).toBe(undefined);
attr = movie.getAttribute('country');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('country');
expect(attr.getParent()).toBe(movie);
expect(attr.getDefault()).toBeUndefined();
expect(attr.evaluateDefault()).toBeUndefined();
expect(attr.getValue()).toBeUndefined();
expect(movie.country).toBeUndefined();
movie.country = 'USA';
expect(attr.getValue()).toBe('USA');
expect(movie.country).toBe('USA');
expect(Movie.hasAttribute('offset')).toBe(false);
expect(() => Movie.getAttribute('offset')).toThrow(
"The attribute 'offset' is missing (component: 'Movie')"
);
movie = new Movie({title: 'Inception', country: 'USA'});
expect(movie.title).toBe('Inception');
expect(movie.country).toBe('USA');
class Film extends Movie {
@attribute() static limit: number;
@attribute() static token = '';
@attribute() title!: string;
@attribute() country = '';
}
attr = Film.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Film);
expect(attr.getValue()).toBe(500);
expect(Film.limit).toBe(500);
Film.limit = 1000;
expect(attr.getValue()).toBe(1000);
expect(Film.limit).toBe(1000);
expect(Movie.limit).toBe(500);
attr = Film.getAttribute('token');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('token');
expect(attr.getParent()).toBe(Film);
expect(attr.getValue()).toBe('');
expect(Film.token).toBe('');
const film = new Film();
attr = film.getAttribute('title');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('title');
expect(attr.getParent()).toBe(film);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(film.title).toBe('');
film.title = 'Léon';
expect(attr.getValue()).toBe('Léon');
expect(film.title).toBe('Léon');
attr = film.getAttribute('country');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('country');
expect(attr.getParent()).toBe(film);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(film.country).toBe('');
// --- Using getters ---
class MotionPicture extends Component {
@attribute({getter: () => 100}) static limit: number;
@attribute({getter: () => 'Untitled'}) title!: string;
}
expect(MotionPicture.limit).toBe(100);
expect(MotionPicture.prototype.title).toBe('Untitled');
expect(() => {
class MotionPicture extends Component {
@attribute({getter: () => 100}) static limit: number = 30;
}
return MotionPicture;
}).toThrow(
"An attribute cannot have both a getter or setter and an initial value (attribute: 'MotionPicture.limit')"
);
expect(() => {
class MotionPicture extends Component {
@attribute({getter: () => 'Untitled'}) title: string = '';
}
return MotionPicture;
}).toThrow(
"An attribute cannot have both a getter or setter and a default value (attribute: 'MotionPicture.prototype.title')"
);
});
test('@primaryIdentifier()', async () => {
class Movie1 extends Component {
@primaryIdentifier() id!: string;
}
let idAttribute = Movie1.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie1.prototype);
expect(isStringValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(typeof idAttribute.getDefault()).toBe('function');
class Movie2 extends Component {
@primaryIdentifier('number') id!: number;
}
idAttribute = Movie2.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie2.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(idAttribute.getDefault()).toBeUndefined();
class Movie3 extends Component {
@primaryIdentifier('number') id = Math.random();
}
idAttribute = Movie3.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie3.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(typeof idAttribute.getDefault()).toBe('function');
const movie = new Movie3();
expect(typeof movie.id === 'number').toBe(true);
expect(() => {
class Movie extends Component {
@primaryIdentifier() static id: string;
}
return Movie;
}).toThrow(
"Couldn't find a property class while executing @primaryIdentifier() (component: 'Movie', property: 'id')"
);
expect(() => {
class Movie {
@primaryIdentifier() id!: string;
}
return Movie;
}).toThrow("@primaryIdentifier() must be used inside a component class (property: 'id')");
expect(() => {
class Movie extends Component {
@primaryIdentifier() id!: string;
@primaryIdentifier() slug!: string;
}
return Movie;
}).toThrow("The component 'Movie' already has a primary identifier attribute");
});
test('@secondaryIdentifier()', async () => {
class User extends Component {
@secondaryIdentifier() email!: string;
@secondaryIdentifier() username!: string;
}
const emailAttribute = User.prototype.getSecondaryIdentifierAttribute('email');
expect(isSecondaryIdentifierAttributeInstance(emailAttribute)).toBe(true);
expect(emailAttribute.getName()).toBe('email');
expect(emailAttribute.getParent()).toBe(User.prototype);
expect(isStringValueTypeInstance(emailAttribute.getValueType())).toBe(true);
expect(emailAttribute.getDefault()).toBeUndefined();
const usernameAttribute = User.prototype.getSecondaryIdentifierAttribute('username');
expect(isSecondaryIdentifierAttributeInstance(usernameAttribute)).toBe(true);
expect(usernameAttribute.getName()).toBe('username');
expect(usernameAttribute.getParent()).toBe(User.prototype);
expect(isStringValueTypeInstance(usernameAttribute.getValueType())).toBe(true);
expect(usernameAttribute.getDefault()).toBeUndefined();
});
test('@method()', async () => {
class Movie extends Component {
@method() static find() {}
@method() load() {}
}
expect(typeof Movie.find).toBe('function');
const movie = new Movie();
expect(typeof movie.load).toBe('function');
let meth = Movie.getMethod('find');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Movie);
meth = movie.getMethod('load');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('load');
expect(meth.getParent()).toBe(movie);
expect(Movie.hasMethod('delete')).toBe(false);
expect(() => Movie.getMethod('delete')).toThrow(
"The method 'delete' is missing (component: 'Movie')"
);
});
test('@expose()', async () => {
const testExposure = (componentProvider: () => typeof Component) => {
const component = componentProvider();
let prop = component.getProperty('limit');
expect(isAttributeInstance(prop)).toBe(true);
expect(prop.getName()).toBe('limit');
expect(prop.getExposure()).toStrictEqual({get: true});
prop = component.getProperty('find');
expect(isMethodInstance(prop)).toBe(true);
expect(prop.getName()).toBe('find');
expect(prop.getExposure()).toStrictEqual({call: true});
prop = component.prototype.getProperty('title');
expect(isAttributeInstance(prop)).toBe(true);
expect(prop.getName()).toBe('title');
expect(prop.getExposure()).toStrictEqual({get: true, set: true});
prop = component.prototype.getProperty('load');
expect(isMethodInstance(prop)).toBe(true);
expect(prop.getName()).toBe('load');
expect(prop.getExposure()).toStrictEqual({call: true});
};
testExposure(() => {
class Movie extends Component {
@expose({get: true}) @attribute() static limit: string;
@expose({call: true}) @method() static find() {}
@expose({get: true, set: true}) @attribute() title!: string;
@expose({call: true}) @method() load() {}
}
return Movie;
});
testExposure(() => {
@expose({
limit: {get: true},
find: {call: true},
prototype: {
title: {get: true, set: true},
load: {call: true}
}
})
class Movie extends Component {
@attribute() static limit: string;
@method() static find() {}
@attribute() title!: string;
@method() load() {}
}
return Movie;
});
testExposure(() => {
class Movie extends Component {
@attribute() static limit: string;
@method() static find() {}
@attribute() title!: string;
@method() load() {}
}
@expose({
limit: {get: true},
find: {call: true},
prototype: {
title: {get: true, set: true},
load: {call: true}
}
})
class ExposedMovie extends Movie {}
return ExposedMovie;
});
});
test('@provide()', async () => {
class Movie extends Component {}
class Backend extends Component {
@provide() static Movie = Movie;
}
expect(Backend.getProvidedComponent('Movie')).toBe(Movie);
((Backend, BackendMovie) => {
class Movie extends BackendMovie {}
class Frontend extends Backend {
@provide() static Movie = Movie;
}
expect(Frontend.getProvidedComponent('Movie')).toBe(Movie);
})(Backend, Movie);
// The backend should not be affected by the frontend
expect(Backend.getProvidedComponent('Movie')).toBe(Movie);
expect(() => {
class Movie extends Component {}
class Backend extends Component {
// @ts-expect-error
@provide() Movie = Movie;
}
return Backend;
}).toThrow(
"@provide() must be used inside a component class with as static attribute declaration (attribute: 'Movie')"
);
expect(() => {
class Movie {}
class Backend extends Component {
@provide() static Movie = Movie;
}
return Backend;
}).toThrow(
"@provide() must be used with an attribute declaration specifying a component class (attribute: 'Movie')"
);
});
test('@consume()', async () => {
class Movie extends Component {
@consume() static Director: typeof Director;
}
class Director extends Component {
@consume() static Movie: typeof Movie;
}
class Backend extends Component {
@provide() static Movie = Movie;
@provide() static Director = Director;
}
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
((Backend, BackendMovie, BackendDirector) => {
class Movie extends BackendMovie {
@consume() static Director: typeof Director;
}
class Director extends BackendDirector {
@consume() static Movie: typeof Movie;
}
class Frontend extends Backend {
@provide() static Movie = Movie;
@provide() static Director = Director;
}
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
return Frontend;
})(Backend, Movie, Director);
// The backend should not be affected by the frontend
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
expect(() => {
class Movie extends Component {
// @ts-expect-error
@consume() Director: typeof Director;
}
class Director extends Component {}
return Movie;
}).toThrow(
"@consume() must be used inside a component class with as static attribute declaration (attribute: 'Director')"
);
expect(() => {
class Director extends Component {}
class Movie extends Component {
@consume() static Director = Director;
}
return Movie;
}).toThrow(
"@consume() must be used with an attribute declaration which does not specify any value (attribute: 'Director')"
);
});
});
================================================
FILE: packages/component/src/decorators.ts
================================================
import {hasOwnProperty, getPropertyDescriptor} from 'core-helpers';
import {Component} from './component';
import {
Property,
Attribute,
AttributeOptions,
PrimaryIdentifierAttribute,
SecondaryIdentifierAttribute,
Method,
MethodOptions,
PropertyExposure
} from './properties';
import {isComponentClassOrInstance, isComponentClass, isComponentInstance} from './utilities';
import {
getConstructorSourceCode,
getAttributeInitializerFromConstructorSourceCode
} from './js-parser';
type AttributeDecoratorOptions = Omit;
/**
* Decorates an attribute of a component so it can be type checked at runtime, validated, serialized, observed, etc.
*
* @param [valueType] A string specifying the [type of values](https://layrjs.com/docs/v2/reference/value-type#supported-types) that can be stored in the attribute (default: `'any'`).
* @param [options] The options to create the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#constructor).
*
* @example
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {maxLength} = validators;
*
* class Movie extends Component {
* // Optional 'string' class attribute
* ﹫attribute('string?') static customName;
*
* // Required 'string' instance attribute
* ﹫attribute('string') title;
*
* // Optional 'string' instance attribute with a validator
* ﹫attribute('string?', {validators: [maxLength(100)]}) summary;
*
* // Required array of 'Actor' instance attribute with a default value
* ﹫attribute('Actor[]') actors = [];
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {maxLength} = validators;
*
* class Movie extends Component {
* // Optional 'string' class attribute
* ﹫attribute('string?') static customName?: string;
*
* // Required 'string' instance attribute
* ﹫attribute('string') title!: string;
*
* // Optional 'string' instance attribute with a validator
* ﹫attribute('string?', {validators: [maxLength(100)]}) summary?: string;
*
* // Required array of 'Actor' instance attribute with a default value
* ﹫attribute('Actor[]') actors: Actor[] = [];
* }
* ```
*
* @category Decorators
* @decorator
*/
export function attribute(
valueType?: string,
options?: AttributeDecoratorOptions
): PropertyDecorator;
export function attribute(options?: AttributeDecoratorOptions): PropertyDecorator;
export function attribute(
valueType?: string | AttributeDecoratorOptions,
options?: AttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([[isComponentClassOrInstance, Attribute]]),
'attribute',
valueType,
options
);
}
/**
* Decorates an attribute of a component as a [primary identifier attribute](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
*
* @param [valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options] The options to create the [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
*
* @example
* ```
* // JS
*
* import {Component, primaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // Auto-generated 'string' primary identifier attribute
* ﹫primaryIdentifier('string') id;
* }
* class Film extends Component {
* // Custom 'number' primary identifier attribute
* ﹫primaryIdentifier('number', {default() { return Math.random(); }}) id;
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component, primaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // Auto-generated 'string' primary identifier attribute
* ﹫primaryIdentifier('string') id!: string;
* }
* class Film extends Component {
* // Custom 'number' primary identifier attribute
* ﹫primaryIdentifier('number', {default() { return Math.random(); }}) id!: number;
* }
* ```
*
* @category Decorators
* @decorator
*/
export function primaryIdentifier(
valueType?: string,
options?: AttributeDecoratorOptions
): PropertyDecorator;
export function primaryIdentifier(options?: AttributeDecoratorOptions): PropertyDecorator;
export function primaryIdentifier(
valueType?: string | AttributeDecoratorOptions,
options?: AttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([[isComponentInstance, PrimaryIdentifierAttribute]]),
'primaryIdentifier',
valueType,
options
);
}
/**
* Decorates an attribute of a component as a [secondary identifier attribute](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute).
*
* @param [valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options] The options to create the [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute).
*
* @example
* ```
* // JS
*
* import {Component, secondaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // 'string' secondary identifier attribute
* ﹫secondaryIdentifier('string') slug;
*
* // 'number' secondary identifier attribute
* ﹫secondaryIdentifier('number') reference;
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component, secondaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // 'string' secondary identifier attribute
* ﹫secondaryIdentifier('string') slug!: string;
*
* // 'number' secondary identifier attribute
* ﹫secondaryIdentifier('number') reference!: number;
* }
* ```
*
* @category Decorators
* @decorator
*/
export function secondaryIdentifier(
valueType?: string,
options?: AttributeDecoratorOptions
): PropertyDecorator;
export function secondaryIdentifier(options?: AttributeDecoratorOptions): PropertyDecorator;
export function secondaryIdentifier(
valueType?: string | AttributeDecoratorOptions,
options?: AttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([[isComponentInstance, SecondaryIdentifierAttribute]]),
'secondaryIdentifier',
valueType,
options
);
}
export function createAttributeDecorator(
AttributeClassMap: PropertyClassMap,
decoratorName: string,
valueType?: string | AttributeDecoratorOptions,
options: AttributeDecoratorOptions = {}
) {
if (typeof valueType === 'string') {
options = {...options, valueType};
} else if (valueType !== undefined) {
options = valueType;
}
if ('value' in options || 'default' in options) {
throw new Error(`The options 'value' and 'default' are not authorized in @${decoratorName}()`);
}
let attributeOptions: AttributeOptions = options;
return function (
target: typeof Component | Component,
name: string,
descriptor?: PropertyDescriptor
) {
if (!isComponentClassOrInstance(target)) {
throw new Error(
`@${decoratorName}() must be used inside a component class (property: '${name}')`
);
}
if (isComponentClass(target)) {
const value =
!target.hasAttribute(name) || target.getAttribute(name).isSet()
? (target as any)[name]
: undefined;
attributeOptions = {value, ...attributeOptions};
} else {
const initializer = getAttributeInitializer(target, name, descriptor);
if (initializer !== undefined) {
attributeOptions = {default: initializer, ...attributeOptions};
}
}
const AttributeClass = getPropertyClass(AttributeClassMap, target, {
decoratorName,
propertyName: name
});
const attribute = target.setProperty(name, AttributeClass, attributeOptions) as Attribute;
const compiler = determineCompiler(descriptor);
if (compiler === 'typescript' && 'default' in attributeOptions) {
if (attribute._isDefaultSetInConstructor) {
throw new Error(
`Cannot set a default value to an attribute that already has an inherited default value (property: '${name}')`
);
}
attribute._isDefaultSetInConstructor = true;
}
if (compiler === 'babel-legacy') {
return getPropertyDescriptor(target, name) as void;
}
};
}
function getAttributeInitializer(
component: Component,
attributeName: string,
descriptor?: PropertyDescriptor & {initializer?: any}
) {
if (determineCompiler(descriptor) === 'babel-legacy') {
return typeof descriptor!.initializer === 'function' ? descriptor!.initializer : undefined;
}
if (!hasOwnProperty(component, '__constructorSourceCode')) {
const classSourceCode = component.constructor.toString();
const constructorSourceCode = getConstructorSourceCode(classSourceCode);
Object.defineProperty(component, '__constructorSourceCode', {value: constructorSourceCode});
}
const constructorSourceCode = component.__constructorSourceCode;
if (constructorSourceCode === undefined) {
return undefined;
}
return getAttributeInitializerFromConstructorSourceCode(constructorSourceCode, attributeName);
}
/**
* Decorates a method of a component so it can be exposed and called remotely.
*
* @param [options] The options to create the [`Method`](https://layrjs.com/docs/v2/reference/method#constructor).
*
* @example
* ```
* import {Component, method} from '﹫layr/component';
*
* class Movie extends Component {
* // Class method
* ﹫method() static getConfig() {
* // ...
* }
*
* // Instance method
* ﹫method() play() {
* // ...
* }
* }
* ```
*
* @category Decorators
* @decorator
*/
export function method(options: MethodOptions = {}) {
return createMethodDecorator(new Map([[isComponentClassOrInstance, Method]]), 'method', options);
}
export function createMethodDecorator(
MethodClassMap: PropertyClassMap,
decoratorName: string,
options: MethodOptions = {}
) {
return function (
target: typeof Component | Component,
name: string,
descriptor: PropertyDescriptor
) {
if (!isComponentClassOrInstance(target)) {
throw new Error(
`@${decoratorName}() must be used inside a component class (property: '${name}')`
);
}
if (!(typeof descriptor.value === 'function' && descriptor.enumerable === false)) {
throw new Error(
`@${decoratorName}() must be used with a method declaration (property: '${name}')`
);
}
const MethodClass = getPropertyClass(MethodClassMap, target, {
decoratorName,
propertyName: name
});
target.setProperty(name, MethodClass, options);
};
}
type PropertyClassMap = Map<(value: any) => boolean, typeof Property>;
function getPropertyClass(
propertyClassMap: PropertyClassMap,
target: typeof Component | Component,
{decoratorName, propertyName}: {decoratorName: string; propertyName: string}
) {
for (const [func, propertyClass] of propertyClassMap.entries()) {
if (func(target)) {
return propertyClass;
}
}
throw new Error(
`Couldn't find a property class while executing @${decoratorName}() (${target.describeComponent()}, property: '${propertyName}')`
);
}
type ClassExposure = {
[name: string]: PropertyExposure | {[name: string]: PropertyExposure};
};
/**
* Exposes some attributes or methods of a component so they can be consumed remotely.
*
* This decorator is usually placed before a component attribute or method, but it can also be placed before a component class. When placed before a component class, you can expose several attributes or methods at once, and even better, you can expose attributes or methods that are defined in a parent class.
*
* @param exposure An object specifying which operations should be exposed. When the decorator is placed before a component attribute or method, the object is of type [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type). When the decorator is placed before a component class, the shape of the object is `{[propertyName]: PropertyExposure, prototype: {[propertyName]: PropertyExposure}}`.
*
* @example
* ```
* // JS
*
* import {Component, expose, attribute, method} from '﹫layr/component';
*
* class Movie extends Component {
* // Class attribute exposing the 'get' operation only
* ﹫expose({get: true}) ﹫attribute('string?') static customName;
*
* // Instance attribute exposing the 'get' and 'set' operations
* ﹫expose({get: true, set: true}) ﹫attribute('string') title;
*
* // Class method exposure
* ﹫expose({call: true}) ﹫method() static getConfig() {
* // ...
* }
*
* // Instance method exposure
* ﹫expose({call: true}) ﹫method() play() {
* // ...
* }
* }
*
* // Exposing some class and instance methods that are defined in a parent class
* ﹫expose({find: {call: true}, prototype: {load: {call: true}}})
* class Actor extends Storable(Component) {
* // ...
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component, expose, attribute, method} from '﹫layr/component';
*
* class Movie extends Component {
* // Class attribute exposing the 'get' operation only
* ﹫expose({get: true}) ﹫attribute('string?') static customName?: string;
*
* // Instance attribute exposing the 'get' and 'set' operations
* ﹫expose({get: true, set: true}) ﹫attribute('string') title!: string;
*
* // Class method exposure
* ﹫expose({call: true}) ﹫method() static getConfig() {
* // ...
* }
*
* // Instance method exposure
* ﹫expose({call: true}) ﹫method() play() {
* // ...
* }
* }
*
* // Exposing some class and instance methods that are defined in a parent class
* ﹫expose({find: {call: true}, prototype: {load: {call: true}}})
* class Actor extends Storable(Component) {
* // ...
* }
* ```
*
* @category Decorators
* @decorator
*/
export function expose(exposure: ClassExposure): (target: typeof Component | Component) => void;
export function expose(
exposure: PropertyExposure
): (target: typeof Component | Component, name: string) => void;
export function expose(exposure: ClassExposure | PropertyExposure = {}) {
return function (target: typeof Component | Component, name?: string) {
if (name === undefined) {
// Class decorator
if (!isComponentClass(target)) {
throw new Error(
`@expose() must be used as a component class decorator or a component property decorator`
);
}
const _expose = (
target: typeof Component | Component,
exposures: {[name: string]: PropertyExposure}
) => {
for (const [name, exposure] of Object.entries(exposures)) {
target.getProperty(name).setExposure(exposure);
}
};
const {prototype: prototypeExposure, ...classExposure} = exposure as ClassExposure;
_expose(target, classExposure);
if (prototypeExposure !== undefined) {
_expose(target.prototype, prototypeExposure as {[name: string]: PropertyExposure});
}
return;
}
// Property decorator
if (!isComponentClassOrInstance(target)) {
throw new Error(
`@expose() must be as a component class decorator or a component property decorator (property: '${name}')`
);
}
if (
!target.hasProperty(name) ||
target.getProperty(name, {autoFork: false}).getParent() !== target
) {
throw new Error(
`@expose() must be used in combination with @attribute() or @method() (property: '${name}')`
);
}
target.getProperty(name).setExposure(exposure);
};
}
/**
* Provides a component so it can be easily accessed from the current component or from any component that is "consuming" it using the [`@consume()`](https://layrjs.com/docs/v2/reference/component#consume-decorator) decorator.
*
* @example
* ```
* // JS
*
* import {Component, provide, consume} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫consume() static Actor;
* }
*
* class Actor extends Component {}
*
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* ﹫provide() static Actor = Actor;
* }
*
* // Since `Actor` is provided by `Application`, it can be accessed from `Movie`
* Movie.Actor; // => Actor
* ```
*
* @example
* ```
* // TS
*
* import {Component, provide, consume} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫consume() static Actor: typeof Actor;
* }
*
* class Actor extends Component {}
*
* class Application extends Component {
* ﹫provide() static Movie = Movie;
* ﹫provide() static Actor = Actor;
* }
*
* // Since `Actor` is provided by `Application`, it can be accessed from `Movie`
* Movie.Actor; // => Actor
* ```
*
* @category Decorators
* @decorator
*/
export function provide() {
return function (target: typeof Component, name: string, descriptor?: PropertyDescriptor) {
if (!isComponentClass(target)) {
throw new Error(
`@provide() must be used inside a component class with as static attribute declaration (attribute: '${name}')`
);
}
const compiler = determineCompiler(descriptor);
const component = Object.getOwnPropertyDescriptor(target, name)?.value;
if (!isComponentClass(component)) {
throw new Error(
`@provide() must be used with an attribute declaration specifying a component class (attribute: '${name}')`
);
}
target.provideComponent(component);
if (compiler === 'babel-legacy') {
return getPropertyDescriptor(target, name) as void;
}
};
}
/**
* Consumes a component provided by the provider (or recursively, any provider's provider) of the current component so it can be easily accessed using a component accessor.
*
* @examplelink See [`@provide()`'s example](https://layrjs.com/docs/v2/reference/component#provide-decorator).
*
* @category Decorators
* @decorator
*/
export function consume() {
return function (target: typeof Component, name: string, descriptor?: PropertyDescriptor) {
if (!isComponentClass(target)) {
throw new Error(
`@consume() must be used inside a component class with as static attribute declaration (attribute: '${name}')`
);
}
const compiler = determineCompiler(descriptor);
if (hasOwnProperty(target, name)) {
const propertyValue = (target as any)[name];
if (propertyValue !== undefined) {
throw new Error(
`@consume() must be used with an attribute declaration which does not specify any value (attribute: '${name}')`
);
}
if (compiler === 'babel-legacy') {
delete (target as any)[name];
}
}
target.consumeComponent(name);
if (compiler === 'babel-legacy') {
return getPropertyDescriptor(target, name) as void;
}
};
}
export function determineCompiler(descriptor: PropertyDescriptor | undefined) {
if (typeof descriptor === 'object') {
// The class has been compiled by Babel using @babel/plugin-proposal-decorators in legacy mode
return 'babel-legacy';
} else {
// The class has been compiled by the TypeScript compiler
return 'typescript';
}
}
================================================
FILE: packages/component/src/deserialization.test.ts
================================================
import {Component} from './component';
import {EmbeddedComponent} from './embedded-component';
import {provide, attribute, primaryIdentifier} from './decorators';
import {deserialize} from './deserialization';
describe('Deserialization', () => {
test('Component classes', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@attribute() static offset: number;
}
class Application extends Component {
@provide() static Movie = Movie;
}
expect(Movie.limit).toBe(100);
expect(Movie.offset).toBeUndefined();
// --- Using the deserialize() function ---
let DeserializedMovie = deserialize(
{
__component: 'typeof Movie',
limit: {__undefined: true},
offset: 30
},
{rootComponent: Application}
);
expect(DeserializedMovie).toBe(Movie);
expect(Movie.limit).toBeUndefined();
expect(Movie.offset).toBe(30);
DeserializedMovie = deserialize(
{__component: 'typeof Movie', limit: 1000, offset: {__undefined: true}},
{rootComponent: Application}
);
expect(DeserializedMovie).toBe(Movie);
expect(Movie.limit).toBe(1000);
expect(Movie.offset).toBeUndefined();
DeserializedMovie = deserialize({__component: 'typeof Movie'}, {rootComponent: Application});
expect(DeserializedMovie).toBe(Movie);
expect(Movie.limit).toBe(1000);
expect(Movie.offset).toBeUndefined();
expect(() => deserialize({__component: 'typeof Film'}, {rootComponent: Application})).toThrow(
"Cannot get the component of type 'typeof Film' from the component 'Application'"
);
expect(() => deserialize({__component: 'typeof Movie'})).toThrow(
"Cannot deserialize a component when no 'rootComponent' is provided"
);
// --- Using the deserialize() function with the 'source' option ---
expect(Movie.limit).toBe(1000);
expect(Movie.getAttribute('limit').getValueSource()).toBe('local');
DeserializedMovie = deserialize(
{__component: 'typeof Movie', limit: 5000},
{source: 'server', rootComponent: Application}
);
expect(Movie.limit).toBe(5000);
expect(Movie.getAttribute('limit').getValueSource()).toBe('server');
DeserializedMovie = deserialize(
{__component: 'typeof Movie', limit: 5000},
{rootComponent: Application}
);
expect(Movie.limit).toBe(5000);
expect(Movie.getAttribute('limit').getValueSource()).toBe('local');
// --- Using Component.deserialize() method ---
DeserializedMovie = Movie.deserialize({limit: {__undefined: true}, offset: 100});
expect(DeserializedMovie).toBe(Movie);
expect(Movie.limit).toBeUndefined();
expect(Movie.offset).toBe(100);
expect(() => Movie.deserialize({__component: 'typeof Film'})).toThrow(
"An unexpected component type was encountered while deserializing an object (encountered type: 'typeof Film', expected type: 'typeof Movie')"
);
});
test('Component instances', async () => {
class Movie extends Component {
@attribute() title?: string;
@attribute() duration = 0;
}
class Application extends Component {
@provide() static Movie = Movie;
}
// --- Using the deserialize() function ---
let movie = deserialize(
{__component: 'Movie', title: 'Inception'},
{rootComponent: Application}
) as Movie;
expect(movie).toBeInstanceOf(Movie);
expect(movie).not.toBe(Movie.prototype);
expect(movie.isNew()).toBe(false);
expect(movie.title).toBe('Inception');
expect(movie.getAttribute('duration').isSet()).toBe(false);
movie = deserialize(
{__component: 'Movie', __new: true, title: 'Inception'},
{rootComponent: Application}
) as Movie;
expect(movie).toBeInstanceOf(Movie);
expect(movie).not.toBe(Movie.prototype);
expect(movie.isNew()).toBe(true);
expect(movie.title).toBe('Inception');
expect(movie.duration).toBe(0);
movie = deserialize(
{__component: 'Movie', __new: true, duration: {__undefined: true}},
{rootComponent: Application}
) as Movie;
expect(movie.title).toBeUndefined();
expect(movie.duration).toBeUndefined();
movie = deserialize(
{__component: 'Movie', __new: true, title: 'Inception', duration: 120},
{
rootComponent: Application,
attributeFilter(attribute) {
expect(this).toBeInstanceOf(Movie);
expect(attribute.getParent()).toBe(this);
return attribute.getName() === 'title';
}
}
) as Movie;
expect(movie.title).toBe('Inception');
expect(movie.duration).toBe(0);
expect(() => deserialize({__component: 'Film'}, {rootComponent: Application})).toThrow(
"Cannot get the component of type 'Film' from the component 'Application'"
);
// --- Using the deserialize() function with the 'source' option ---
movie = deserialize(
{__component: 'Movie', title: 'Inception'},
{rootComponent: Application}
) as Movie;
expect(movie.title).toBe('Inception');
expect(movie.getAttribute('title').getValueSource()).toBe('local');
expect(movie.getAttribute('duration').isSet()).toBe(false);
movie = deserialize(
{__component: 'Movie', title: 'Inception'},
{source: 'server', rootComponent: Application}
) as Movie;
expect(movie.title).toBe('Inception');
expect(movie.getAttribute('title').getValueSource()).toBe('server');
expect(movie.getAttribute('duration').isSet()).toBe(false);
movie = deserialize(
{__component: 'Movie', __new: true, title: 'Inception'},
{source: 'server', rootComponent: Application}
) as Movie;
expect(movie.title).toBe('Inception');
expect(movie.getAttribute('title').getValueSource()).toBe('server');
expect(movie.duration).toBe(0);
expect(movie.getAttribute('duration').getValueSource()).toBe('local');
// --- Using component.deserialize() method ---
movie = new Movie();
expect(movie.isNew()).toBe(true);
expect(movie.title).toBeUndefined();
expect(movie.duration).toBe(0);
const deserializedMovie = movie.deserialize({__new: true, title: 'Inception'});
expect(deserializedMovie).toBe(movie);
expect(movie.isNew()).toBe(true);
expect(movie.title).toBe('Inception');
expect(movie.duration).toBe(0);
movie.deserialize({__new: true, duration: 120});
expect(movie.isNew()).toBe(true);
expect(movie.title).toBe('Inception');
expect(movie.duration).toBe(120);
movie.deserialize({});
expect(movie).toBe(movie);
expect(movie.isNew()).toBe(false);
expect(movie.title).toBe('Inception');
expect(movie.duration).toBe(120);
expect(() => movie.deserialize({__component: 'Film'})).toThrow(
"An unexpected component type was encountered while deserializing an object (encountered type: 'Film', expected type: 'Movie')"
);
expect(() => movie.deserialize({__new: true})).toThrow(
"Cannot mark as new an existing non-new component (component: 'Application.Movie')"
);
});
test('Embedded component instances', async () => {
class Person extends EmbeddedComponent {
@attribute('string') fullName?: string;
}
class Movie extends Component {
@provide() static Person = Person;
@attribute() title?: string;
@attribute('Person?') director?: Person;
@attribute('Person[]?') actors?: Person[];
}
class Application extends Component {
@provide() static Movie = Movie;
}
const movie1 = deserialize(
{
__component: 'Movie',
title: 'Movie 1',
director: {__component: 'Person', fullName: 'Person 1'}
},
{rootComponent: Application}
) as Movie;
expect(movie1).toBeInstanceOf(Movie);
expect(movie1.title).toBe('Movie 1');
const movie1Director = movie1.director;
expect(movie1Director).toBeInstanceOf(Person);
expect(movie1Director?.fullName).toBe('Person 1');
movie1.deserialize({director: {__component: 'Person', fullName: 'Person 1 (modified)'}});
// The identity of movie1.director should be preserved
expect(movie1.director).toBe(movie1Director);
expect(movie1Director?.fullName).toBe('Person 1 (modified)');
const movie2 = deserialize(
{
__component: 'Movie',
title: 'Movie 2',
actors: [{__component: 'Person', fullName: 'Person 2'}]
},
{rootComponent: Application}
) as Movie;
expect(movie2).toBeInstanceOf(Movie);
expect(movie2.title).toBe('Movie 2');
const movie2Actor = movie2.actors![0];
expect(movie2Actor).toBeInstanceOf(Person);
expect(movie2Actor?.fullName).toBe('Person 2');
movie2.deserialize({actors: [{__component: 'Person', fullName: 'Person 2 (modified)'}]});
// The identity of movie2.actors[0] should NOT be preserved
expect(movie2.actors![0]).not.toBe(movie2Actor);
expect(movie2.actors![0].fullName).toBe('Person 2 (modified)');
});
test('Referenced component instances', async () => {
class Person extends Component {
@primaryIdentifier() id!: string;
@attribute('string') fullName?: string;
}
class Movie extends Component {
@provide() static Person = Person;
@primaryIdentifier() id!: string;
@attribute() title?: string;
@attribute('Person?') director?: Person;
}
class Application extends Component {
@provide() static Movie = Movie;
}
const person1 = new Person({id: 'person1', fullName: 'Person 1'});
const person2 = new Person({id: 'person2', fullName: 'Person 2'});
const movie1 = new Movie({id: 'movie1', title: 'Movie 1', director: person1});
expect(movie1.director).toBe(person1);
let deserializedMovie = deserialize(
{
__component: 'Movie',
id: 'movie1',
director: {__component: 'Person', id: 'person2'}
},
{rootComponent: Application}
) as Movie;
expect(deserializedMovie).toBe(movie1);
expect(movie1.director).toBe(person2);
deserializedMovie = deserialize(
{
__component: 'Movie',
id: 'movie1',
director: {__undefined: true}
},
{rootComponent: Application}
) as Movie;
expect(deserializedMovie).toBe(movie1);
expect(movie1.director).toBeUndefined();
});
test('Functions', async () => {
let serializedFunction: any = {
__function: 'function sum(a, b) { return a + b; }'
};
let func = deserialize(serializedFunction) as Function;
expect(typeof func).toBe('object');
expect(func).toEqual(serializedFunction);
func = deserialize(serializedFunction, {deserializeFunctions: true}) as Function;
expect(typeof func).toBe('function');
expect(Object.keys(func)).toEqual([]);
expect(func.name).toBe('sum');
expect(func(1, 2)).toBe(3);
serializedFunction.displayName = 'sum';
func = deserialize(serializedFunction) as Function;
expect(typeof func).toBe('object');
expect(func).toEqual(serializedFunction);
func = deserialize(serializedFunction, {deserializeFunctions: true}) as Function;
expect(typeof func).toBe('function');
expect(func.name).toBe('sum');
expect(Object.keys(func)).toEqual(['displayName']);
expect((func as any).displayName).toBe('sum');
expect(func(1, 2)).toBe(3);
});
});
================================================
FILE: packages/component/src/deserialization.ts
================================================
import {
deserialize as simpleDeserialize,
DeserializeOptions as SimpleDeserializeOptions,
DeserializeResult
} from 'simple-serialization';
import {possiblyAsync} from 'possibly-async';
import {PlainObject} from 'core-helpers';
import type {Component, ComponentSet} from './component';
import type {PropertyFilter, ValueSource} from './properties';
import {Validator, isSerializedValidator} from './validation/validator';
import {isComponentClass} from './utilities';
export type DeserializeOptions = SimpleDeserializeOptions & {
rootComponent?: typeof Component;
attributeFilter?: PropertyFilter;
deserializedComponents?: ComponentSet;
deserializeFunctions?: boolean;
source?: ValueSource;
};
/**
* Deserializes any type of serialized values including objects, arrays, dates, and components.
*
* @param value A serialized value.
* @param [options.rootComponent] The root component of your app.
* @param [options.attributeFilter] A (possibly async) function used to filter the component attributes to be deserialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.source] The source of the serialization (default: `'local'`).
*
* @returns The deserialized value.
*
* @example
* ```
* // JS
*
* import {Component, deserialize} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title;
* }
*
* const serializedData = {
* createdOn: {__date: "2020-07-18T23:43:33.778Z"},
* updatedOn: {__undefined: true},
* movie: {__component: 'Movie', title: 'Inception'}
* };
*
* const data = deserialize(serializedData, {rootComponent: Movie});
*
* data.createdOn; // => A Date instance
* data.updatedOn; // => undefined
* data.movie; // => A Movie instance
* ```
*
* @example
* ```
* // TS
*
* import {Component, deserialize} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫attribute('string') title!: string;
* }
*
* const serializedData = {
* createdOn: {__date: "2020-07-18T23:43:33.778Z"},
* updatedOn: {__undefined: true},
* movie: {__component: 'Movie', title: 'Inception'}
* };
*
* const data = deserialize(serializedData, {rootComponent: Movie});
*
* data.createdOn; // => A Date instance
* data.updatedOn; // => undefined
* data.movie; // => A Movie instance
* ```
*
* @category Deserialization
* @possiblyasync
*/
export function deserialize(
value: Value,
options?: DeserializeOptions
): DeserializeResult;
export function deserialize(value: any, options: DeserializeOptions = {}) {
const {
objectDeserializer: originalObjectDeserializer,
functionDeserializer: originalFunctionDeserializer,
rootComponent,
attributeFilter,
deserializedComponents,
deserializeFunctions = false,
source,
...otherOptions
} = options;
const objectDeserializer = function (object: PlainObject) {
if (originalObjectDeserializer !== undefined) {
const deserializedObject = originalObjectDeserializer(object);
if (deserializedObject !== undefined) {
return deserializedObject;
}
}
if (isSerializedValidator(object)) {
return Validator.recreate(object, deserialize);
}
const {
__component: componentType,
__new: isNew = false,
...attributes
}: {__component?: string; __new?: boolean} & Record = object;
if (componentType === undefined) {
return undefined;
}
if (rootComponent === undefined) {
throw new Error("Cannot deserialize a component when no 'rootComponent' is provided");
}
const componentClassOrPrototype = rootComponent.getComponentOfType(componentType);
if (isComponentClass(componentClassOrPrototype)) {
const componentClass = componentClassOrPrototype;
return componentClass.deserialize(attributes, options);
}
const componentPrototype = componentClassOrPrototype;
const componentClass = componentPrototype.constructor;
const identifiers = componentPrototype.__createIdentifierSelectorFromObject(attributes);
const component = componentClass.instantiate(identifiers, {source});
return possiblyAsync(component, (component) => {
component.setIsNewMark(isNew);
if (deserializedComponents !== undefined && !componentClass.isEmbedded()) {
deserializedComponents.add(component);
}
return possiblyAsync(component.__deserializeAttributes(attributes, options), () => {
if (isNew) {
for (const attribute of component.getAttributes()) {
if (!(attribute.isSet() || attribute.isControlled())) {
attribute.setValue(attribute.evaluateDefault());
}
}
}
return component;
});
});
};
let functionDeserializer: DeserializeOptions['functionDeserializer'];
if (deserializeFunctions) {
functionDeserializer = function (object) {
if (originalFunctionDeserializer !== undefined) {
const deserializedFunction = originalFunctionDeserializer(object);
if (deserializedFunction !== undefined) {
return deserializedFunction;
}
}
const {__function, ...serializedAttributes} = object;
if (__function === undefined) {
return undefined;
}
const functionCode = __function;
return possiblyAsync(
possiblyAsync.mapValues(serializedAttributes, (attributeValue) =>
simpleDeserialize(attributeValue, {
...otherOptions,
objectDeserializer,
functionDeserializer
})
),
(deserializedAttributes) => {
const deserializedFunction = deserializeFunction(functionCode);
Object.assign(deserializedFunction, deserializedAttributes);
return deserializedFunction;
}
);
};
}
return simpleDeserialize(value, {...otherOptions, objectDeserializer, functionDeserializer});
}
export function deserializeFunction(functionCode: string): Function {
return new Function(`return (${functionCode});`)();
// let evalCode = `(${functionCode});`;
// if (context !== undefined) {
// const contextKeys = Object.keys(context).join(', ');
// const contextCode = `const {${contextKeys}} = context;`;
// evalCode = `${contextCode} ${evalCode}`;
// }
// return eval(evalCode);
}
================================================
FILE: packages/component/src/embedded-component.ts
================================================
import {Component} from './component';
/**
* *Inherits from [`Component`](https://layrjs.com/docs/v2/reference/component).*
*
* The `EmbeddedComponent` class allows you to define a component that can be embedded into another component. This is useful when you have to deal with a rich data model composed of a hierarchy of properties that can be type checked at runtime and validated. If you don't need such control over some nested attributes, instead of using an embedded component, you can just use an attribute of type `object`.
*
* The `EmbeddedComponent` class inherits from the [`Component`](https://layrjs.com/docs/v2/reference/component) class, so you can define and consume an embedded component in the same way you would do with any component.
*
* However, since an embedded component is owned by its parent component, it doesn't behave like a regular component. Head over [here](https://layrjs.com/docs/v2/reference/component#nesting-components) for a broader explanation.
*
* #### Usage
*
* Just extend the `EmbeddedComponent` class to define a component that has the ability to be embedded.
*
* For example, a `MovieDetails` embedded component could be defined as follows:
*
* ```
* // JS
*
* // movie-details.js
*
* import {EmbeddedComponent} from '@layr/component';
*
* export class MovieDetails extends EmbeddedComponent {
* ﹫attribute('number?') duration;
* ﹫attribute('string?') aspectRatio;
* }
* ```
*
* ```
* // TS
*
* // movie-details.ts
*
* import {EmbeddedComponent} from '@layr/component';
*
* export class MovieDetails extends EmbeddedComponent {
* ﹫attribute('number?') duration?: number;
* ﹫attribute('string?') aspectRatio?: string;
* }
* ```
*
* Once you have defined an embedded component, you can embed it into any other component (a regular component or even another embedded component). For example, here is a `Movie` component that is embedding the `MovieDetails` component:
*
* ```
* // JS
*
* // movie.js
*
* import {Component} from '@layr/component';
*
* import {MovieDetails} from './movie-details';
*
* class Movie extends Component {
* ﹫provide() static MovieDetails = MovieDetails;
*
* ﹫attribute('string') title;
* ﹫attribute('MovieDetails') details;
* }
* ```
*
* ```
* // TS
*
* // movie.ts
*
* import {Component} from '@layr/component';
*
* import {MovieDetails} from './movie-details';
*
* class Movie extends Component {
* ﹫provide() static MovieDetails = MovieDetails;
*
* ﹫attribute('string') title!: string;
* ﹫attribute('MovieDetails') details!: MovieDetails;
* }
* ```
*
* > Note that you have to make the `MovieDetails` component accessible from the `Movie` component by using the [`@provide()`](https://layrjs.com/docs/v2/reference/component#provide-decorator) decorator. This way, the `MovieDetails` component can be later referred by its name when you define the `details` attribute using the [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) decorator.
*
* Finally, the `Movie` component can be instantiated like this:
*
* ```
* const movie = new Movie({
* title: 'Inception',
* details: new Movie.MovieDetails({duration: 120, aspectRatio: '16:9'})
* });
*
* movie.title; // => 'Inception'
* movie.details.duration; // => 120
* ```
*/
export class EmbeddedComponent extends Component {
// === Methods ===
/**
* See the methods that are inherited from the [`Component`](https://layrjs.com/docs/v2/reference/component#creation) class.
*
* @category Methods
*/
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
/**
* Always returns `true`.
*
* @category Embeddability
*/
static isEmbedded() {
return true;
}
}
================================================
FILE: packages/component/src/forking.test.ts
================================================
import {Component} from './component';
import {attribute, provide} from './decorators';
describe('Forking', () => {
test('Simple component', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@attribute() title!: string;
@attribute() tags!: string[];
@attribute() specs!: {duration?: number};
}
const MovieFork = Movie.fork();
expect(MovieFork.getComponentType()).toBe('typeof Movie');
expect(MovieFork.limit).toBe(100);
expect(MovieFork.isForkOf(Movie)).toBe(true);
expect(MovieFork.isForkOf(MovieFork)).toBe(false);
expect(Movie.isForkOf(MovieFork)).toBe(false);
MovieFork.limit = 500;
expect(MovieFork.limit).toBe(500);
expect(Movie.limit).toBe(100);
const GhostMovie = Movie.getGhost();
const SameGhostMovie = Movie.getGhost();
expect(GhostMovie.isForkOf(Movie)).toBe(true);
expect(SameGhostMovie).toBe(GhostMovie);
const movie = new Movie({title: 'Inception', tags: ['drama'], specs: {duration: 120}});
expect(movie).toBeInstanceOf(Component);
expect(movie).toBeInstanceOf(Movie);
let movieFork = movie.fork();
expect(movieFork).toBeInstanceOf(Component);
expect(movieFork).toBeInstanceOf(Movie);
expect(movieFork.getComponentType()).toBe('Movie');
expect(movieFork.title).toBe('Inception');
expect(movieFork.tags).toEqual(['drama']);
expect(movieFork.specs).toEqual({duration: 120});
expect(movieFork.isForkOf(movie)).toBe(true);
expect(movieFork.isForkOf(movieFork)).toBe(false);
expect(movie.isForkOf(movieFork)).toBe(false);
movieFork.title = 'Inception 2';
movieFork.tags.push('action');
movieFork.specs.duration = 125;
expect(movieFork.title).toBe('Inception 2');
expect(movieFork.tags).toEqual(['drama', 'action']);
expect(movieFork.specs).toEqual({duration: 125});
expect(movie.title).toBe('Inception');
expect(movie.tags).toEqual(['drama']);
expect(movie.specs).toEqual({duration: 120});
movieFork = movie.fork({componentClass: MovieFork});
expect(movieFork).toBeInstanceOf(Component);
expect(movieFork).toBeInstanceOf(Movie);
expect(movieFork).toBeInstanceOf(MovieFork);
expect(() => movie.getGhost()).toThrow(
"Cannot get the identifiers of a component that has no set identifier (component: 'Movie')"
);
});
test('Component provision', async () => {
class MovieDetails extends Component {}
class Movie extends Component {
@provide() static MovieDetails = MovieDetails;
}
class App extends Component {
@provide() static Movie = Movie;
}
const GhostApp = App.getGhost();
const SameGhostApp = App.getGhost();
expect(GhostApp.isForkOf(App)).toBe(true);
expect(SameGhostApp).toBe(GhostApp);
const GhostMovie = Movie.getGhost();
const SameGhostMovie = Movie.getGhost();
expect(GhostMovie.isForkOf(Movie)).toBe(true);
expect(SameGhostMovie).toBe(GhostMovie);
expect(GhostApp.Movie).toBe(GhostMovie);
const GhostMovieDetails = MovieDetails.getGhost();
const SameGhostMovieDetails = MovieDetails.getGhost();
expect(GhostMovieDetails.isForkOf(MovieDetails)).toBe(true);
expect(SameGhostMovieDetails).toBe(GhostMovieDetails);
expect(GhostMovie.MovieDetails).toBe(GhostMovieDetails);
expect(GhostApp.Movie.MovieDetails).toBe(GhostMovieDetails);
});
test('Referenced component', async () => {
class Director extends Component {
@attribute() name!: string;
}
class Movie extends Component {
@provide() static Director = Director;
@attribute() director!: Director;
}
const movie = new Movie({director: new Director({name: 'Christopher Nolan'})});
const movieFork = movie.fork();
expect(movieFork.director).not.toBe(movie.director);
expect(movieFork.director.name).toBe('Christopher Nolan');
expect(movieFork.director.constructor.isForkOf(Director)).toBe(true);
expect(movieFork.director.isForkOf(movie.director)).toBe(true);
movieFork.director.name = 'Christopher Nolan 2';
expect(movieFork.director.name).toBe('Christopher Nolan 2');
expect(movie.director.name).toBe('Christopher Nolan');
});
});
================================================
FILE: packages/component/src/forking.ts
================================================
import {fork as simpleFork, ForkOptions as SimpleForkOptions} from 'simple-forking';
import type {Component} from './component';
import {isComponentClass, isComponentInstance} from './utilities';
export type ForkOptions = SimpleForkOptions & {
componentProvider?: typeof Component;
componentClass?: typeof Component;
};
/**
* Fork any type of values including objects, arrays, and components (using Component's `fork()` [class method](https://layrjs.com/docs/v2/reference/component#fork-class-method) and [instance method](https://layrjs.com/docs/v2/reference/component#fork-instance-method)).
*
* @param value A value of any type.
*
* @returns A fork of the specified value.
*
* @example
* ```
* import {fork} from '﹫layr/component';
*
* const data = {
* token: 'xyz123',
* timestamp: 1596600889609,
* movie: new Movie({title: 'Inception'})
* };
*
* const dataFork = fork(data);
* Object.getPrototypeOf(dataFork); // => data
* dataFork.token; // => 'xyz123';
* dataFork.timestamp; // => 1596600889609
* dataFork.movie.isForkOf(data.movie); // => true
* ```
*
* @category Forking
*/
export function fork(value: any, options: ForkOptions = {}) {
const {objectForker: originalObjectForker, ...otherOptions} = options;
const objectForker = function (object: object): object | void {
if (originalObjectForker !== undefined) {
const objectFork = originalObjectForker(object);
if (objectFork !== undefined) {
return objectFork;
}
}
if (isComponentClass(object)) {
return object.fork(options);
}
if (isComponentInstance(object)) {
return object.fork(options);
}
};
return simpleFork(value, {...otherOptions, objectForker});
}
================================================
FILE: packages/component/src/identifiable-component.test.ts
================================================
import {Component} from './component';
import {
isIdentifierAttributeInstance,
isPrimaryIdentifierAttributeInstance,
isSecondaryIdentifierAttributeInstance
} from './properties';
import {attribute, primaryIdentifier, secondaryIdentifier, provide} from './decorators';
import {deserialize} from './deserialization';
describe('Identifiable component', () => {
test('new ()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier() username!: string;
}
const user = new User({email: 'hi@hello.com', username: 'hi'});
expect(user.id.length >= 25).toBe(true);
expect(user.email).toBe('hi@hello.com');
expect(user.username).toBe('hi');
expect(() => new User({id: user.id, email: 'user1@email.com', username: 'user1'})).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.id')"
);
expect(() => new User({email: 'hi@hello.com', username: 'user2'})).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.email')"
);
expect(() => new User({email: 'user3@email.com', username: 'hi'})).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.username')"
);
expect(() => new User()).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'User.prototype.email', expected type: 'string', received type: 'undefined')"
);
});
test('instantiate()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@attribute('string') name = '';
}
let user = User.instantiate({id: 'abc123'});
expect(user.id).toBe('abc123');
expect(user.getAttribute('email').isSet()).toBe(false);
expect(user.getAttribute('name').isSet()).toBe(false);
let sameUser = User.instantiate({id: 'abc123'});
expect(sameUser).toBe(user);
sameUser = User.instantiate('abc123');
expect(sameUser).toBe(user);
user = User.instantiate({email: 'hi@hello.com'});
expect(user.email).toBe('hi@hello.com');
expect(user.getAttribute('id').isSet()).toBe(false);
expect(user.getAttribute('name').isSet()).toBe(false);
sameUser = User.instantiate({email: 'hi@hello.com'});
expect(sameUser).toBe(user);
expect(() => User.instantiate()).toThrow(
"An identifier is required to instantiate an identifiable component, but received a value of type 'undefined' (component: 'User')"
);
expect(() => User.instantiate({})).toThrow(
"An identifier selector should be a string, a number, or a non-empty object, but received an empty object (component: 'User')"
);
expect(() => User.instantiate({name: 'john'})).toThrow(
"A property with the specified name was found, but it is not an identifier attribute (attribute: 'User.prototype.name')"
);
});
test('getIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@attribute('string') name = '';
}
let identifierAttribute = User.prototype.getIdentifierAttribute('id');
expect(isIdentifierAttributeInstance(identifierAttribute)).toBe(true);
expect(identifierAttribute.getName()).toBe('id');
expect(identifierAttribute.getParent()).toBe(User.prototype);
identifierAttribute = User.prototype.getIdentifierAttribute('email');
expect(isIdentifierAttributeInstance(identifierAttribute)).toBe(true);
expect(identifierAttribute.getName()).toBe('email');
expect(identifierAttribute.getParent()).toBe(User.prototype);
expect(() => User.prototype.getIdentifierAttribute('name')).toThrow(
"A property with the specified name was found, but it is not an identifier attribute (attribute: 'User.prototype.name')"
);
});
test('hasIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@attribute('string') name = '';
}
expect(User.prototype.hasIdentifierAttribute('id')).toBe(true);
expect(User.prototype.hasIdentifierAttribute('email')).toBe(true);
expect(User.prototype.hasIdentifierAttribute('username')).toBe(false);
expect(() => User.prototype.hasIdentifierAttribute('name')).toThrow(
"A property with the specified name was found, but it is not an identifier attribute (attribute: 'User.prototype.name')"
);
});
test('getPrimaryIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identifierAttribute = User.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(identifierAttribute)).toBe(true);
expect(identifierAttribute.getName()).toBe('id');
expect(identifierAttribute.getParent()).toBe(User.prototype);
class Movie extends Component {
@secondaryIdentifier() slug!: string;
}
expect(() => Movie.prototype.getPrimaryIdentifierAttribute()).toThrow(
"The component 'Movie' doesn't have a primary identifier attribute"
);
});
test('hasPrimaryIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
expect(User.prototype.hasPrimaryIdentifierAttribute()).toBe(true);
class Movie extends Component {
@secondaryIdentifier() slug!: string;
}
expect(Movie.prototype.hasPrimaryIdentifierAttribute()).toBe(false);
});
test('setPrimaryIdentifierAttribute()', async () => {
class User extends Component {}
expect(User.prototype.hasPrimaryIdentifierAttribute()).toBe(false);
const setPrimaryIdentifierAttributeResult = User.prototype.setPrimaryIdentifierAttribute('id');
expect(User.prototype.hasPrimaryIdentifierAttribute()).toBe(true);
const primaryIdentifierAttribute = User.prototype.getPrimaryIdentifierAttribute();
expect(primaryIdentifierAttribute).toBe(setPrimaryIdentifierAttributeResult);
expect(isPrimaryIdentifierAttributeInstance(primaryIdentifierAttribute)).toBe(true);
expect(primaryIdentifierAttribute.getName()).toBe('id');
expect(primaryIdentifierAttribute.getParent()).toBe(User.prototype);
expect(() => User.prototype.setPrimaryIdentifierAttribute('email')).toThrow(
"The component 'User' already has a primary identifier attribute"
);
});
test('getSecondaryIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
}
const identifierAttribute = User.prototype.getSecondaryIdentifierAttribute('email');
expect(isSecondaryIdentifierAttributeInstance(identifierAttribute)).toBe(true);
expect(identifierAttribute.getName()).toBe('email');
expect(identifierAttribute.getParent()).toBe(User.prototype);
expect(() => User.prototype.getSecondaryIdentifierAttribute('id')).toThrow(
"A property with the specified name was found, but it is not a secondary identifier attribute (attribute: 'User.prototype.id')"
);
});
test('hasSecondaryIdentifierAttribute()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@attribute('string') name = '';
}
expect(User.prototype.hasSecondaryIdentifierAttribute('email')).toBe(true);
expect(User.prototype.hasSecondaryIdentifierAttribute('username')).toBe(false);
expect(() => User.prototype.hasSecondaryIdentifierAttribute('id')).toThrow(
"A property with the specified name was found, but it is not a secondary identifier attribute (attribute: 'User.prototype.id')"
);
expect(() => User.prototype.hasSecondaryIdentifierAttribute('name')).toThrow(
"A property with the specified name was found, but it is not a secondary identifier attribute (attribute: 'User.prototype.name')"
);
});
test('setSecondaryIdentifierAttribute()', async () => {
class User extends Component {}
expect(User.prototype.hasSecondaryIdentifierAttribute('email')).toBe(false);
const setSecondaryIdentifierAttributeResult = User.prototype.setSecondaryIdentifierAttribute(
'email'
);
expect(User.prototype.hasSecondaryIdentifierAttribute('email')).toBe(true);
const secondaryIdentifierAttribute = User.prototype.getSecondaryIdentifierAttribute('email');
expect(secondaryIdentifierAttribute).toBe(setSecondaryIdentifierAttributeResult);
expect(isSecondaryIdentifierAttributeInstance(secondaryIdentifierAttribute)).toBe(true);
expect(secondaryIdentifierAttribute.getName()).toBe('email');
expect(secondaryIdentifierAttribute.getParent()).toBe(User.prototype);
expect(() => User.prototype.setSecondaryIdentifierAttribute('username')).not.toThrow();
});
test('getIdentifierAttributes()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier() username!: string;
@attribute('string') name = '';
}
const identifierAttributes = User.prototype.getIdentifierAttributes();
expect(typeof identifierAttributes[Symbol.iterator]).toBe('function');
expect(Array.from(identifierAttributes).map((property) => property.getName())).toEqual([
'id',
'email',
'username'
]);
});
test('getSecondaryIdentifierAttributes()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier() username!: string;
@attribute('string') name = '';
}
const secondaryIdentifierAttributes = User.prototype.getSecondaryIdentifierAttributes();
expect(typeof secondaryIdentifierAttributes[Symbol.iterator]).toBe('function');
expect(
Array.from(secondaryIdentifierAttributes).map((property) => property.getName())
).toEqual(['email', 'username']);
});
test('getIdentifiers()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
}
let user = User.fork().instantiate({id: 'abc123'});
expect(user.getIdentifiers()).toStrictEqual({id: 'abc123'});
user.email = 'hi@hello.com';
expect(user.getIdentifiers()).toStrictEqual({id: 'abc123', email: 'hi@hello.com'});
user = User.fork().instantiate({email: 'hi@hello.com'});
expect(user.getIdentifiers()).toStrictEqual({email: 'hi@hello.com'});
});
test('getIdentifierDescriptor()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
}
let user = User.fork().instantiate({id: 'abc123'});
expect(user.getIdentifierDescriptor()).toStrictEqual({id: 'abc123'});
user.email = 'hi@hello.com';
expect(user.getIdentifierDescriptor()).toStrictEqual({id: 'abc123'});
user = User.fork().instantiate({email: 'hi@hello.com'});
expect(user.getIdentifierDescriptor()).toStrictEqual({email: 'hi@hello.com'});
user.id = 'abc123';
expect(user.getIdentifierDescriptor()).toStrictEqual({id: 'abc123'});
});
test('normalizeIdentifierDescriptor()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier('number') reference!: number;
@attribute('string') name = '';
}
expect(User.normalizeIdentifierDescriptor('abc123')).toStrictEqual({id: 'abc123'});
expect(User.normalizeIdentifierDescriptor({id: 'abc123'})).toStrictEqual({id: 'abc123'});
expect(User.normalizeIdentifierDescriptor({email: 'hi@hello.com'})).toStrictEqual({
email: 'hi@hello.com'
});
expect(User.normalizeIdentifierDescriptor({reference: 123456})).toStrictEqual({
reference: 123456
});
// @ts-expect-error
expect(() => User.normalizeIdentifierDescriptor(undefined)).toThrow(
"An identifier descriptor should be a string, a number, or an object, but received a value of type 'undefined' (component: 'User')"
);
// @ts-expect-error
expect(() => User.normalizeIdentifierDescriptor(true)).toThrow(
"An identifier descriptor should be a string, a number, or an object, but received a value of type 'boolean' (component: 'User')"
);
// @ts-expect-error
expect(() => User.normalizeIdentifierDescriptor([])).toThrow(
"An identifier descriptor should be a string, a number, or an object, but received a value of type 'Array' (component: 'User')"
);
expect(() => User.normalizeIdentifierDescriptor({})).toThrow(
"An identifier descriptor should be a string, a number, or an object composed of one attribute, but received an object composed of 0 attributes (component: 'User', received object: {})"
);
expect(() => User.normalizeIdentifierDescriptor({id: 'abc123', email: 'hi@hello.com'})).toThrow(
'An identifier descriptor should be a string, a number, or an object composed of one attribute, but received an object composed of 2 attributes (component: \'User\', received object: {"id":"abc123","email":"hi@hello.com"})'
);
expect(() => User.normalizeIdentifierDescriptor(123456)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'User.prototype.id', expected type: 'string', received type: 'number')"
);
expect(() => User.normalizeIdentifierDescriptor({email: 123456})).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'User.prototype.email', expected type: 'string', received type: 'number')"
);
expect(() => User.normalizeIdentifierDescriptor({reference: 'abc123'})).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'User.prototype.reference', expected type: 'number', received type: 'string')"
);
// @ts-expect-error
expect(() => User.normalizeIdentifierDescriptor({email: undefined})).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'User.prototype.email', expected type: 'string', received type: 'undefined')"
);
expect(() => User.normalizeIdentifierDescriptor({name: 'john'})).toThrow(
"A property with the specified name was found, but it is not an identifier attribute (attribute: 'User.prototype.name')"
);
expect(() => User.normalizeIdentifierDescriptor({country: 'USA'})).toThrow(
"The identifier attribute 'country' is missing (component: 'User')"
);
});
test('describeIdentifierDescriptor()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier('number') reference!: number;
}
expect(User.describeIdentifierDescriptor('abc123')).toBe("id: 'abc123'");
expect(User.describeIdentifierDescriptor({id: 'abc123'})).toBe("id: 'abc123'");
expect(User.describeIdentifierDescriptor({email: 'hi@hello.com'})).toBe(
"email: 'hi@hello.com'"
);
expect(User.describeIdentifierDescriptor({reference: 123456})).toBe('reference: 123456');
});
test('resolveAttributeSelector()', async () => {
class Person extends Component {
@primaryIdentifier() id!: string;
@attribute('string') name = '';
}
class Movie extends Component {
@provide() static Person = Person;
@primaryIdentifier() id!: string;
@attribute('string') title = '';
@attribute('Person?') director?: Person;
}
expect(Movie.prototype.resolveAttributeSelector(true)).toStrictEqual({
id: true,
title: true,
director: {id: true}
});
expect(
Movie.prototype.resolveAttributeSelector(true, {includeReferencedComponents: true})
).toStrictEqual({
id: true,
title: true,
director: {id: true, name: true}
});
expect(Movie.prototype.resolveAttributeSelector(false)).toStrictEqual({});
expect(Movie.prototype.resolveAttributeSelector({})).toStrictEqual({id: true});
expect(Movie.prototype.resolveAttributeSelector({title: true})).toStrictEqual({
id: true,
title: true
});
expect(Movie.prototype.resolveAttributeSelector({director: true})).toStrictEqual({
id: true,
director: {id: true}
});
expect(
Movie.prototype.resolveAttributeSelector(
{director: true},
{includeReferencedComponents: true}
)
).toStrictEqual({
id: true,
director: {id: true, name: true}
});
expect(Movie.prototype.resolveAttributeSelector({director: false})).toStrictEqual({
id: true
});
expect(Movie.prototype.resolveAttributeSelector({director: {}})).toStrictEqual({
id: true,
director: {id: true}
});
expect(
Movie.prototype.resolveAttributeSelector({director: {}}, {includeReferencedComponents: true})
).toStrictEqual({
id: true,
director: {id: true}
});
});
test('generateId()', async () => {
class Movie extends Component {}
const id1 = Movie.generateId();
expect(typeof id1).toBe('string');
expect(id1.length >= 25).toBe(true);
const id2 = Movie.generateId();
expect(typeof id2).toBe('string');
expect(id2.length >= 25).toBe(true);
expect(id2).not.toBe(id1);
});
test('fork()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
}
const user = new User({id: 'abc123', email: 'hi@hello.com'});
expect(user.id).toBe('abc123');
expect(user.email).toBe('hi@hello.com');
let UserFork = User.fork();
const userFork = UserFork.getIdentityMap().getComponent({id: 'abc123'}) as User;
expect(userFork.constructor).toBe(UserFork);
expect(userFork).toBeInstanceOf(UserFork);
expect(userFork.isForkOf(user)).toBe(true);
expect(userFork).not.toBe(user);
expect(userFork.id).toBe('abc123');
expect(userFork.email).toBe('hi@hello.com');
// --- With a referenced identifiable component ---
class Article extends Component {
@provide() static User = User;
@primaryIdentifier() id!: string;
@attribute('string') title = '';
@attribute('User') author!: User;
}
const article = new Article({id: 'xyz456', title: 'Hello', author: user});
expect(article.id).toBe('xyz456');
expect(article.title).toBe('Hello');
const author = article.author;
expect(author).toBe(user);
const ArticleFork = Article.fork();
const articleFork = ArticleFork.getIdentityMap().getComponent({id: 'xyz456'}) as Article;
expect(articleFork.constructor).toBe(ArticleFork);
expect(articleFork).toBeInstanceOf(ArticleFork);
expect(articleFork.isForkOf(article)).toBe(true);
expect(articleFork).not.toBe(article);
expect(articleFork.id).toBe('xyz456');
expect(articleFork.title).toBe('Hello');
const authorFork = articleFork.author;
UserFork = ArticleFork.User;
expect(authorFork.constructor).toBe(UserFork);
expect(authorFork).toBeInstanceOf(UserFork);
expect(authorFork.isForkOf(author)).toBe(true);
expect(authorFork).not.toBe(author);
expect(authorFork.id).toBe('abc123');
expect(authorFork.email).toBe('hi@hello.com');
expect(UserFork.getIdentityMap().getComponent({id: 'abc123'})).toBe(authorFork);
// --- With a serialized referenced identifiable component ---
const deserializedArticle = deserialize(
{
__component: 'Article',
id: 'xyz789',
title: 'Hello 2',
author: {__component: 'User', id: 'abc123'}
},
{rootComponent: ArticleFork}
) as Article;
const deserializedAuthor = deserializedArticle.author;
expect(deserializedAuthor.constructor).toBe(UserFork);
expect(deserializedAuthor).toBeInstanceOf(UserFork);
expect(deserializedAuthor.isForkOf(author)).toBe(true);
expect(deserializedAuthor).not.toBe(author);
expect(deserializedAuthor.id).toBe('abc123');
expect(deserializedAuthor.email).toBe('hi@hello.com');
expect(deserializedAuthor).toBe(authorFork);
});
test('getGhost()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
class App extends Component {
@provide() static User = User;
}
const user = new User();
const ghostUser = user.getGhost();
expect(ghostUser.isForkOf(user)).toBe(true);
expect(ghostUser.constructor).toBe(App.getGhost().User);
const sameGhostUser = user.getGhost();
expect(sameGhostUser).toBe(ghostUser);
});
test('detach()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const user = User.instantiate({id: 'abc123'});
const sameUser = User.getIdentityMap().getComponent({id: 'abc123'});
expect(sameUser).toBe(user);
user.detach();
const otherUser = User.instantiate({id: 'abc123'});
const sameOtherUser = User.getIdentityMap().getComponent({id: 'abc123'});
expect(otherUser).not.toBe(user);
expect(sameOtherUser).toBe(otherUser);
User.detach();
const user2 = User.instantiate({id: 'xyz456'});
const otherUser2 = User.instantiate({id: 'xyz456'});
expect(otherUser2).not.toBe(user2);
});
test('toObject()', async () => {
class Director extends Component {
@primaryIdentifier() id!: string;
@attribute() name = '';
}
class Movie extends Component {
@provide() static Director = Director;
@primaryIdentifier() id!: string;
@attribute() title = '';
@attribute('Director') director!: Director;
}
let movie = new Movie({
id: 'm1',
title: 'Inception',
director: new Director({id: 'd1', name: 'Christopher Nolan'})
});
expect(movie.toObject()).toStrictEqual({id: 'm1', title: 'Inception', director: {id: 'd1'}});
expect(movie.toObject({minimize: true})).toStrictEqual({id: 'm1'});
});
});
================================================
FILE: packages/component/src/identity-map.test.ts
================================================
import {Component} from './component';
import {IdentityMap} from './identity-map';
import {primaryIdentifier, secondaryIdentifier} from './decorators';
describe('Identity map', () => {
test('new IdentityMap()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identityMap = new IdentityMap(User);
expect(identityMap.getParent()).toBe(User);
});
test('getComponent()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identityMap = new IdentityMap(User);
expect(identityMap.getComponent({id: 'abc123'})).toBeUndefined();
const user = new User({id: 'abc123'});
identityMap.addComponent(user);
expect(identityMap.getComponent({id: 'abc123'})).toBe(user);
expect(identityMap.getComponent('abc123')).toBe(user);
expect(identityMap.getComponent({id: 'xyz456'})).toBeUndefined();
});
test('addComponent()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identityMap = new IdentityMap(User);
const user = new User({id: 'abc123'});
identityMap.addComponent(user);
expect(identityMap.getComponent({id: 'abc123'})).toBe(user);
expect(() => identityMap.addComponent(user)).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.id')"
);
user.detach();
expect(() => identityMap.addComponent(user)).toThrow(
"Cannot add a detached component to the identity map (component: 'User')"
);
});
test('updateComponent()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
}
const identityMap = new IdentityMap(User);
const user = new User({id: 'abc123', email: 'hi@hello.com'});
identityMap.addComponent(user);
expect(identityMap.getComponent({email: 'hi@hello.com'})).toBe(user);
user.email = 'salut@bonjour.com';
identityMap.updateComponent(user, 'email', {
previousValue: 'hi@hello.com',
newValue: 'salut@bonjour.com'
});
expect(identityMap.getComponent({email: 'salut@bonjour.com'})).toBe(user);
expect(identityMap.getComponent({email: 'hi@hello.com'})).toBe(undefined);
const otherUser = new User({id: 'xyz456', email: 'hi@hello.com'});
identityMap.addComponent(otherUser);
expect(identityMap.getComponent({email: 'hi@hello.com'})).toBe(otherUser);
expect(() =>
identityMap.updateComponent(otherUser, 'email', {
previousValue: 'hi@hello.com',
newValue: 'salut@bonjour.com'
})
).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.email')"
);
// --- Forking ---
const UserFork = User.fork();
const identityMapFork = identityMap.fork(UserFork);
const userFork = identityMapFork.getComponent({email: 'salut@bonjour.com'}) as User;
expect(userFork.isForkOf(user)).toBe(true);
userFork.email = 'hi@hello.com';
identityMapFork.updateComponent(userFork, 'email', {
previousValue: 'salut@bonjour.com',
newValue: 'hi@hello.com'
});
expect(identityMapFork.getComponent({email: 'hi@hello.com'})).toBe(userFork);
expect(() => identityMapFork.getComponent({email: 'salut@bonjour.com'})).toThrow(
"A component with the same identifier already exists (attribute: 'User.prototype.id')"
);
});
test('removeComponent()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identityMap = new IdentityMap(User);
const user = new User({id: 'abc123'});
identityMap.addComponent(user);
expect(identityMap.getComponent({id: 'abc123'})).toBe(user);
identityMap.removeComponent(user);
expect(identityMap.getComponent({id: 'abc123'})).toBeUndefined();
identityMap.addComponent(user);
expect(identityMap.getComponent({id: 'abc123'})).toBe(user);
user.detach();
expect(() => identityMap.removeComponent(user)).toThrow(
"Cannot remove a detached component from the identity map (component: 'User')"
);
});
test('getComponents()', async () => {
class User extends Component {
@primaryIdentifier() id!: string;
}
const identityMap = new IdentityMap(User);
const user1 = new User({id: 'abc123'});
identityMap.addComponent(user1);
expect(Array.from(identityMap.getComponents())).toEqual([user1]);
const user2 = new User({id: 'xyz456'});
identityMap.addComponent(user2);
expect(Array.from(identityMap.getComponents())).toEqual([user1, user2]);
});
});
================================================
FILE: packages/component/src/identity-map.ts
================================================
import {hasOwnProperty} from 'core-helpers';
import type {Component, IdentifierSelector} from './component';
import type {IdentifierValue} from './properties';
/**
* A class to manage the instances of the [`Component`](https://layrjs.com/docs/v2/reference/component) classes that are identifiable.
*
* A component class is identifiable when its prototype has a [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
*
* When a component class is identifiable, the `IdentityMap` ensures that there can only be one component instance with a specific identifier. So if you try to create two components with the same identifier, you will get an error.
*
* #### Usage
*
* You shouldn't have to create an `IdentityMap` by yourself. Identity maps are created automatically for each [`Component`](https://layrjs.com/docs/v2/reference/component) class that are identifiable.
*
* **Example:**
*
* Here is a `Movie` component with an `id` primary identifier attribute:
*
* ```
* // JS
*
* import {Component, primaryIdentifier, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫primaryIdentifier() id;
* ﹫attribute('string') title;
* }
* ```
*
* ```
* // TS
*
* import {Component, primaryIdentifier, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫primaryIdentifier() id!: string;
* ﹫attribute('string') title!: string;
* }
* ```
*
* To get the `IdentityMap` of the `Movie` component, simply do:
*
* ```
* const identityMap = Movie.getIdentityMap();
* ```
*
* Currently, the `IdentifyMap` provides only one public method — [`getComponent()`](https://layrjs.com/docs/v2/reference/identity-map#get-component-instance-method) — that allows to retrieve a component instance from its identifier:
*
* ```
* const movie = new Movie({id: 'abc123', title: 'Inception'});
*
* identityMap.getComponent('abc123'); // => movie
* ```
*/
export class IdentityMap {
_parent: typeof Component;
constructor(parent: typeof Component) {
this._parent = parent;
}
getParent() {
return this._parent;
}
fork(newParent: typeof Component) {
const identityMapFork = Object.create(this) as IdentityMap;
identityMapFork._parent = newParent;
return identityMapFork;
}
// === Entities ===
/**
* Gets a component instance from one of its identifiers. If there are no components corresponding to the specified identifiers, returns `undefined`.
*
* @param identifiers A plain object specifying some identifiers. The shape of the object should be `{[identifierName]: identifierValue}`. Alternatively, you can specify a string or a number representing the value of the [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) of the component you want to get.
*
* @returns A [`Component`](https://layrjs.com/docs/v2/reference/component) instance or `undefined`.
*
* @example
* ```
* // JS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫primaryIdentifier() id;
* ﹫secondaryIdentifier() slug;
* }
*
* const movie = new Movie({id: 'abc123', slug: 'inception'});
*
* Movie.getIdentityMap().getComponent('abc123'); // => movie
* Movie.getIdentityMap().getComponent({id: 'abc123'}); // => movie
* Movie.getIdentityMap().getComponent({slug: 'inception'}); // => movie
* Movie.getIdentityMap().getComponent('xyx456'); // => undefined
* ```
*
* @example
* ```
* // TS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* ﹫primaryIdentifier() id!: string;
* ﹫secondaryIdentifier() slug!: string;
* }
*
* const movie = new Movie({id: 'abc123', slug: 'inception'});
*
* Movie.getIdentityMap().getComponent('abc123'); // => movie
* Movie.getIdentityMap().getComponent({id: 'abc123'}); // => movie
* Movie.getIdentityMap().getComponent({slug: 'inception'}); // => movie
* Movie.getIdentityMap().getComponent('xyx456'); // => undefined
* ```
*
* @category Methods
*/
getComponent(identifiers: IdentifierSelector = {}) {
const parent = this.getParent();
const normalizedIdentifiers = parent.normalizeIdentifierSelector(identifiers);
if (parent.isDetached()) {
return undefined;
}
for (const identifierAttribute of parent.prototype.getIdentifierAttributes()) {
const name = identifierAttribute.getName();
const value: IdentifierValue | undefined = normalizedIdentifiers[name];
if (value === undefined) {
continue;
}
const index = this._getIndex(name);
let component = index[value];
if (component === undefined) {
continue;
}
if (!hasOwnProperty(index, value)) {
// The component's class has been forked
component = component.fork({componentClass: parent});
}
return component;
}
return undefined;
}
addComponent(component: Component) {
if (component.isDetached()) {
throw new Error(
`Cannot add a detached component to the identity map (${component.describeComponent()})`
);
}
for (const identifierAttribute of component.getIdentifierAttributes({
setAttributesOnly: true
})) {
const name = identifierAttribute.getName();
const value = identifierAttribute.getValue() as IdentifierValue;
const index = this._getIndex(name);
if (hasOwnProperty(index, value)) {
throw new Error(
`A component with the same identifier already exists (${index[value]
.getAttribute(name)
.describe()})`
);
}
index[value] = component;
}
}
updateComponent(
component: Component,
attributeName: string,
{
previousValue,
newValue
}: {previousValue: IdentifierValue | undefined; newValue: IdentifierValue | undefined}
) {
if (component.isDetached()) {
return;
}
if (newValue === previousValue) {
return;
}
const index = this._getIndex(attributeName);
if (previousValue !== undefined) {
delete index[previousValue];
}
if (newValue !== undefined) {
if (hasOwnProperty(index, newValue)) {
throw new Error(
`A component with the same identifier already exists (${component
.getAttribute(attributeName)
.describe()})`
);
}
index[newValue] = component;
}
}
removeComponent(component: Component) {
if (component.isDetached()) {
throw new Error(
`Cannot remove a detached component from the identity map (${component.describeComponent()})`
);
}
for (const identifierAttribute of component.getIdentifierAttributes({
setAttributesOnly: true
})) {
const name = identifierAttribute.getName();
const value = identifierAttribute.getValue() as IdentifierValue;
const index = this._getIndex(name);
delete index[value];
}
}
getComponents() {
const identityMap = this;
return {
*[Symbol.iterator]() {
const yieldedComponents = new Set();
const indexes = identityMap._getIndexes();
for (const name in indexes) {
const index = identityMap._getIndex(name);
for (const value in index) {
const component = identityMap.getComponent({[name]: value});
if (component === undefined || yieldedComponents.has(component)) {
continue;
}
yield component;
yieldedComponents.add(component);
}
}
}
};
}
// === Indexes ===
_getIndex(name: string) {
const indexes = this._getIndexes();
if (!indexes[name]) {
indexes[name] = Object.create(null);
} else if (!hasOwnProperty(indexes, name)) {
indexes[name] = Object.create(indexes[name]);
}
return indexes[name];
}
_indexes!: {[name: string]: {[value: string]: Component}};
_getIndexes() {
if (!this._indexes) {
this._indexes = Object.create(null);
} else if (!hasOwnProperty(this, '_indexes')) {
this._indexes = Object.create(this._indexes);
}
return this._indexes;
}
}
================================================
FILE: packages/component/src/index.ts
================================================
export * from './cloning';
export * from './component';
export * from './decorators';
export * from './deserialization';
export * from './embedded-component';
export * from './forking';
export * from './identity-map';
export * from './merging';
export * from './properties';
export * from './sanitization';
export * from './serialization';
export * from './utilities';
export * from './validation';
================================================
FILE: packages/component/src/js-parser.ts
================================================
import escapeRegExp from 'lodash/escapeRegExp';
export function getConstructorSourceCode(classSourceCode: string) {
let index = classSourceCode.indexOf('constructor(');
if (index === -1) {
return undefined;
}
index = getIndexAfterTerminator(classSourceCode, [')'], index + 'constructor('.length);
index = classSourceCode.indexOf('{', index);
if (index === -1) {
throw new Error(`Failed to get a constructor's implementation in the specified source code`);
}
const endIndex = getIndexAfterTerminator(classSourceCode, ['}'], index + 1);
return classSourceCode.slice(index, endIndex);
}
export function getAttributeInitializerFromConstructorSourceCode(
constructorSourceCode: string,
attributeName: string
) {
const regexp = new RegExp(`this\\.${escapeRegExp(attributeName)}\\s*=\\s*`);
const attributeMatch = constructorSourceCode.match(regexp);
if (attributeMatch === null) {
return undefined;
}
const index = attributeMatch.index! + attributeMatch[0].length;
const endIndex = getIndexAfterTerminator(constructorSourceCode, [';', ',', '}'], index);
const initializerSourceCode = 'return ' + constructorSourceCode.slice(index, endIndex - 1);
let initializer: Function;
try {
initializer = new Function(initializerSourceCode);
} catch (error) {
console.error(
`An error occurred while getting the attribute initializer from a constructor source code (failed to create a function from \`${initializerSourceCode}\`)`
);
throw error;
}
Object.defineProperty(initializer, 'name', {
value: `${attributeName}Initializer`,
configurable: true
});
return initializer;
}
function getIndexAfterTerminator(
sourceCode: string,
terminators: string[],
initialIndex = 0
): number {
let index = initialIndex;
while (index < sourceCode.length) {
for (const terminator of terminators) {
if (sourceCode.startsWith(terminator, index)) {
return index + terminator.length;
}
}
if (sourceCode.startsWith('/*', index)) {
index = sourceCode.indexOf('*/', index + 2);
if (index === -1) {
throw new Error(`Couldn't find the comment terminator '*/' in the specified source code`);
}
index = index + 2;
continue;
}
if (sourceCode.startsWith('//', index)) {
index = sourceCode.indexOf('\n', index + 2);
if (index === -1) {
index = sourceCode.length;
} else {
index++;
}
continue;
}
const character = sourceCode[index];
if (character === "'") {
index = getIndexAfterStringTerminator(sourceCode, "'", index + 1);
continue;
}
if (character === '"') {
index = getIndexAfterStringTerminator(sourceCode, '"', index + 1);
continue;
}
if (character === '`') {
index = getIndexAfterStringTerminator(sourceCode, '`', index + 1);
continue;
}
if (character === '(') {
index = getIndexAfterTerminator(sourceCode, [')'], index + 1);
continue;
}
if (character === '{') {
index = getIndexAfterTerminator(sourceCode, ['}'], index + 1);
continue;
}
if (character === '[') {
index = getIndexAfterTerminator(sourceCode, [']'], index + 1);
continue;
}
index++;
}
throw new Error(
`Couldn't find a terminator in the specified source code (terminators: ${JSON.stringify(
terminators
)})`
);
}
function getIndexAfterStringTerminator(
sourceCode: string,
terminator: string,
initialIndex = 0
): number {
let index = initialIndex;
while (index < sourceCode.length) {
const character = sourceCode[index];
if (character === '\\') {
index++;
continue;
}
if (character === terminator) {
return index + 1;
}
if (sourceCode.startsWith('${', index)) {
index = getIndexAfterTerminator(sourceCode, ['}'], index + 2);
continue;
}
index++;
}
throw new Error(
`Couldn't find the string terminator ${JSON.stringify(terminator)} in the specified source code`
);
}
================================================
FILE: packages/component/src/js-tests/decorators.test.js
================================================
import {Component} from '../component';
import {
isAttributeInstance,
isPrimaryIdentifierAttributeInstance,
isSecondaryIdentifierAttributeInstance,
isStringValueTypeInstance,
isNumberValueTypeInstance,
isMethodInstance
} from '../properties';
import {
attribute,
primaryIdentifier,
secondaryIdentifier,
method,
expose,
provide,
consume
} from '../decorators';
describe('Decorators', () => {
test('@attribute()', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@attribute() static token;
@attribute() title = '';
@attribute() country;
}
let attr = Movie.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Movie);
expect(attr.getValue()).toBe(100);
expect(Movie.limit).toBe(100);
Movie.limit = 500;
expect(attr.getValue()).toBe(500);
expect(Movie.limit).toBe(500);
let descriptor = Object.getOwnPropertyDescriptor(Movie, 'limit');
expect(typeof descriptor.get).toBe('function');
expect(typeof descriptor.set).toBe('function');
attr = Movie.getAttribute('token');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('token');
expect(attr.getParent()).toBe(Movie);
expect(attr.getValue()).toBeUndefined();
expect(Movie.token).toBeUndefined();
let movie = new Movie();
attr = movie.getAttribute('title');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('title');
expect(attr.getParent()).toBe(movie);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(movie.title).toBe('');
movie.title = 'The Matrix';
expect(attr.getValue()).toBe('The Matrix');
expect(movie.title).toBe('The Matrix');
descriptor = Object.getOwnPropertyDescriptor(Movie.prototype, 'title');
expect(typeof descriptor.get).toBe('function');
expect(typeof descriptor.set).toBe('function');
expect(Object.getOwnPropertyDescriptor(movie, 'title')).toBe(undefined);
attr = movie.getAttribute('country');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('country');
expect(attr.getParent()).toBe(movie);
expect(attr.getDefault()).toBeUndefined();
expect(attr.evaluateDefault()).toBeUndefined();
expect(attr.getValue()).toBeUndefined();
expect(movie.country).toBeUndefined();
movie.country = 'USA';
expect(attr.getValue()).toBe('USA');
expect(movie.country).toBe('USA');
expect(Movie.hasAttribute('offset')).toBe(false);
expect(() => Movie.getAttribute('offset')).toThrow(
"The attribute 'offset' is missing (component: 'Movie')"
);
movie = new Movie({title: 'Inception', country: 'USA'});
expect(movie.title).toBe('Inception');
expect(movie.country).toBe('USA');
class Film extends Movie {
@attribute() static limit = 100; // With JS, we cannot inherit the value of static attributes
@attribute() static token = '';
@attribute() title;
@attribute() country = '';
}
attr = Film.getAttribute('limit');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('limit');
expect(attr.getParent()).toBe(Film);
expect(attr.getValue()).toBe(100);
expect(Film.limit).toBe(100);
Film.limit = 1000;
expect(attr.getValue()).toBe(1000);
expect(Film.limit).toBe(1000);
expect(Movie.limit).toBe(500);
attr = Film.getAttribute('token');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('token');
expect(attr.getParent()).toBe(Film);
expect(attr.getValue()).toBe('');
expect(Film.token).toBe('');
const film = new Film();
attr = film.getAttribute('title');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('title');
expect(attr.getParent()).toBe(film);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(film.title).toBe('');
film.title = 'Léon';
expect(attr.getValue()).toBe('Léon');
expect(film.title).toBe('Léon');
attr = film.getAttribute('country');
expect(isAttributeInstance(attr)).toBe(true);
expect(attr.getName()).toBe('country');
expect(attr.getParent()).toBe(film);
expect(typeof attr.getDefault()).toBe('function');
expect(attr.evaluateDefault()).toBe('');
expect(attr.getValue()).toBe('');
expect(film.country).toBe('');
// --- Using getters ---
class MotionPicture extends Component {
@attribute({getter: () => 100}) static limit;
@attribute({getter: () => 'Untitled'}) title;
}
expect(MotionPicture.limit).toBe(100);
expect(MotionPicture.prototype.title).toBe('Untitled');
expect(() => {
class MotionPicture extends Component {
@attribute({getter: () => 100}) static limit = 30;
}
return MotionPicture;
}).toThrow(
"An attribute cannot have both a getter or setter and an initial value (attribute: 'MotionPicture.limit')"
);
expect(() => {
class MotionPicture extends Component {
@attribute({getter: () => 'Untitled'}) title = '';
}
return MotionPicture;
}).toThrow(
"An attribute cannot have both a getter or setter and a default value (attribute: 'MotionPicture.prototype.title')"
);
});
test('@primaryIdentifier()', async () => {
class Movie1 extends Component {
@primaryIdentifier() id;
}
let idAttribute = Movie1.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie1.prototype);
expect(isStringValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(typeof idAttribute.getDefault()).toBe('function');
class Movie2 extends Component {
@primaryIdentifier('number') id;
}
idAttribute = Movie2.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie2.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(idAttribute.getDefault()).toBeUndefined();
class Movie3 extends Component {
@primaryIdentifier('number') id = Math.random();
}
idAttribute = Movie3.prototype.getPrimaryIdentifierAttribute();
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie3.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(typeof idAttribute.getDefault()).toBe('function');
const movie = new Movie3();
expect(typeof movie.id === 'number').toBe(true);
expect(() => {
class Movie extends Component {
@primaryIdentifier() static id;
}
return Movie;
}).toThrow(
"Couldn't find a property class while executing @primaryIdentifier() (component: 'Movie', property: 'id')"
);
expect(() => {
class Movie {
@primaryIdentifier() id;
}
return Movie;
}).toThrow("@primaryIdentifier() must be used inside a component class (property: 'id')");
expect(() => {
class Movie extends Component {
@primaryIdentifier() id;
@primaryIdentifier() slug;
}
return Movie;
}).toThrow("The component 'Movie' already has a primary identifier attribute");
});
test('@secondaryIdentifier()', async () => {
class User extends Component {
@secondaryIdentifier() email;
@secondaryIdentifier() username;
}
const emailAttribute = User.prototype.getSecondaryIdentifierAttribute('email');
expect(isSecondaryIdentifierAttributeInstance(emailAttribute)).toBe(true);
expect(emailAttribute.getName()).toBe('email');
expect(emailAttribute.getParent()).toBe(User.prototype);
expect(isStringValueTypeInstance(emailAttribute.getValueType())).toBe(true);
expect(emailAttribute.getDefault()).toBeUndefined();
const usernameAttribute = User.prototype.getSecondaryIdentifierAttribute('username');
expect(isSecondaryIdentifierAttributeInstance(usernameAttribute)).toBe(true);
expect(usernameAttribute.getName()).toBe('username');
expect(usernameAttribute.getParent()).toBe(User.prototype);
expect(isStringValueTypeInstance(usernameAttribute.getValueType())).toBe(true);
expect(usernameAttribute.getDefault()).toBeUndefined();
});
test('@method()', async () => {
class Movie extends Component {
@method() static find() {}
@method() load() {}
}
expect(typeof Movie.find).toBe('function');
const movie = new Movie();
expect(typeof movie.load).toBe('function');
let meth = Movie.getMethod('find');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('find');
expect(meth.getParent()).toBe(Movie);
meth = movie.getMethod('load');
expect(isMethodInstance(meth)).toBe(true);
expect(meth.getName()).toBe('load');
expect(meth.getParent()).toBe(movie);
expect(Movie.hasMethod('delete')).toBe(false);
expect(() => Movie.getMethod('delete')).toThrow(
"The method 'delete' is missing (component: 'Movie')"
);
});
test('@expose()', async () => {
const testExposure = (componentProvider) => {
const component = componentProvider();
let prop = component.getProperty('limit');
expect(isAttributeInstance(prop)).toBe(true);
expect(prop.getName()).toBe('limit');
expect(prop.getExposure()).toStrictEqual({get: true});
prop = component.getProperty('find');
expect(isMethodInstance(prop)).toBe(true);
expect(prop.getName()).toBe('find');
expect(prop.getExposure()).toStrictEqual({call: true});
prop = component.prototype.getProperty('title');
expect(isAttributeInstance(prop)).toBe(true);
expect(prop.getName()).toBe('title');
expect(prop.getExposure()).toStrictEqual({get: true, set: true});
prop = component.prototype.getProperty('load');
expect(isMethodInstance(prop)).toBe(true);
expect(prop.getName()).toBe('load');
expect(prop.getExposure()).toStrictEqual({call: true});
};
testExposure(() => {
class Movie extends Component {
@expose({get: true}) @attribute() static limit;
@expose({call: true}) @method() static find() {}
@expose({get: true, set: true}) @attribute() title;
@expose({call: true}) @method() load() {}
}
return Movie;
});
testExposure(() => {
@expose({
limit: {get: true},
find: {call: true},
prototype: {
title: {get: true, set: true},
load: {call: true}
}
})
class Movie extends Component {
@attribute() static limit;
@method() static find() {}
@attribute() title;
@method() load() {}
}
return Movie;
});
testExposure(() => {
class Movie extends Component {
@attribute() static limit;
@method() static find() {}
@attribute() title;
@method() load() {}
}
@expose({
limit: {get: true},
find: {call: true},
prototype: {
title: {get: true, set: true},
load: {call: true}
}
})
class ExposedMovie extends Movie {}
return ExposedMovie;
});
});
test('@provide()', async () => {
class Movie extends Component {}
class Backend extends Component {
@provide() static Movie = Movie;
}
expect(Backend.getProvidedComponent('Movie')).toBe(Movie);
((Backend, BackendMovie) => {
class Movie extends BackendMovie {}
class Frontend extends Backend {
@provide() static Movie = Movie;
}
expect(Frontend.getProvidedComponent('Movie')).toBe(Movie);
})(Backend, Movie);
// The backend should not be affected by the frontend
expect(Backend.getProvidedComponent('Movie')).toBe(Movie);
expect(() => {
class Movie extends Component {}
class Backend extends Component {
// @ts-expect-error
@provide() Movie = Movie;
}
return Backend;
}).toThrow(
"@provide() must be used inside a component class with as static attribute declaration (attribute: 'Movie')"
);
expect(() => {
class Movie {}
class Backend extends Component {
@provide() static Movie = Movie;
}
return Backend;
}).toThrow(
"@provide() must be used with an attribute declaration specifying a component class (attribute: 'Movie')"
);
});
test('@consume()', async () => {
class Movie extends Component {
@consume() static Director;
}
class Director extends Component {
@consume() static Movie;
}
class Backend extends Component {
@provide() static Movie = Movie;
@provide() static Director = Director;
}
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
((Backend, BackendMovie, BackendDirector) => {
class Movie extends BackendMovie {
@consume() static Director;
}
class Director extends BackendDirector {
@consume() static Movie;
}
class Frontend extends Backend {
@provide() static Movie = Movie;
@provide() static Director = Director;
}
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
return Frontend;
})(Backend, Movie, Director);
// The backend should not be affected by the frontend
expect(Movie.getConsumedComponent('Director')).toBe(Director);
expect(Movie.Director).toBe(Director);
expect(Director.getConsumedComponent('Movie')).toBe(Movie);
expect(Director.Movie).toBe(Movie);
expect(() => {
class Movie extends Component {
// @ts-expect-error
@consume() Director;
}
class Director extends Component {}
return Movie;
}).toThrow(
"@consume() must be used inside a component class with as static attribute declaration (attribute: 'Director')"
);
expect(() => {
class Director extends Component {}
class Movie extends Component {
@consume() static Director = Director;
}
return Movie;
}).toThrow(
"@consume() must be used with an attribute declaration which does not specify any value (attribute: 'Director')"
);
});
});
================================================
FILE: packages/component/src/js-tests/jsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2017",
"module": "CommonJS",
"checkJs": true,
"experimentalDecorators": true
}
}
================================================
FILE: packages/component/src/merging.test.ts
================================================
import {Component} from './component';
import {attribute, provide} from './decorators';
describe('Merging', () => {
test('Simple component', async () => {
class Movie extends Component {
@attribute() static limit = 100;
@attribute() title!: string;
@attribute() tags!: string[];
@attribute() specs!: {duration?: number};
}
const MovieFork = Movie.fork();
MovieFork.limit = 500;
expect(Movie.limit).toBe(100);
Movie.merge(MovieFork);
expect(Movie.limit).toBe(500);
const movie = new Movie({title: 'Inception', tags: ['drama'], specs: {duration: 120}});
const movieFork = movie.fork();
movieFork.title = 'Inception 2';
movieFork.tags.push('action');
movieFork.specs.duration = 125;
expect(movie.title).toBe('Inception');
expect(movie.tags).toEqual(['drama']);
expect(movie.specs).toEqual({duration: 120});
movie.merge(movieFork);
expect(movie.title).toBe('Inception 2');
expect(movie.tags).not.toBe(movieFork.tags);
expect(movie.tags).toEqual(['drama', 'action']);
expect(movie.specs).not.toBe(movieFork.specs);
expect(movie.specs).toEqual({duration: 125});
movieFork.getAttribute('title').unsetValue();
expect(movie.getAttribute('title').isSet()).toBe(true);
movie.merge(movieFork);
expect(movie.getAttribute('title').isSet()).toBe(false);
movieFork.title = 'Inception 3';
movieFork.tags = ['action', 'adventure', 'sci-fi'];
movie.merge(movieFork, {attributeSelector: {title: true}});
expect(movie.title).toBe('Inception 3');
expect(movie.tags).toEqual(['drama', 'action']);
});
test('Referenced component', async () => {
class Director extends Component {
@attribute() name!: string;
}
class Movie extends Component {
@provide() static Director = Director;
@attribute() director!: Director;
}
const movie = new Movie({director: new Director({name: 'Christopher Nolan'})});
const director = movie.director;
const movieFork = movie.fork();
movieFork.director.name = 'Christopher Nolan 2';
expect(movie.director.name).toBe('Christopher Nolan');
movie.merge(movieFork);
expect(movie.director.name).toBe('Christopher Nolan 2');
// Although merged, the director should have kept its identity
expect(movie.director).toBe(director);
});
});
================================================
FILE: packages/component/src/merging.ts
================================================
import {merge as simpleMerge, MergeOptions} from 'simple-forking';
import type {Component} from './component';
import {isComponentClass, isComponentInstance} from './utilities';
export {MergeOptions};
/**
* Deeply merge any type of forks including objects, arrays, and components (using Component's `merge()` [class method](https://layrjs.com/docs/v2/reference/component#merge-class-method) and [instance method](https://layrjs.com/docs/v2/reference/component#merge-instance-method)) into their original values.
*
* @param value An original value of any type.
* @param valueFork A fork of `value`.
*
* @returns The original value.
*
* @example
* ```
* import {fork, merge} from '﹫layr/component';
*
* const data = {
* token: 'xyz123',
* timestamp: 1596600889609,
* movie: new Movie({title: 'Inception'})
* };
*
* const dataFork = fork(data);
* dataFork.token = 'xyz456';
* dataFork.movie.title = 'Inception 2';
*
* data.token; // => 'xyz123'
* data.movie.title; // => 'Inception'
* merge(data, dataFork);
* data.token; // => 'xyz456'
* data.movie.title; // => 'Inception 2'
* ```
*
* @category Merging
*/
export function merge(value: any, valueFork: any, options: MergeOptions = {}) {
const {
objectMerger: originalObjectMerger,
objectCloner: originalObjectCloner,
...otherOptions
} = options;
const objectMerger = function (object: object, objectFork: object): object | void {
if (originalObjectMerger !== undefined) {
const mergedObject = originalObjectMerger(object, objectFork);
if (mergedObject !== undefined) {
return mergedObject;
}
}
if (isComponentClass(object)) {
return object.merge(objectFork as typeof Component, options);
}
if (isComponentInstance(object)) {
return object.merge(objectFork as Component, options);
}
};
const objectCloner = function (object: object): object | void {
if (originalObjectCloner !== undefined) {
const clonedObject = originalObjectCloner(object);
if (clonedObject !== undefined) {
return clonedObject;
}
}
if (isComponentClass(object)) {
return object.clone();
}
if (isComponentInstance(object)) {
return object.clone(options);
}
};
return simpleMerge(value, valueFork, {...otherOptions, objectMerger, objectCloner});
}
================================================
FILE: packages/component/src/properties/attribute-selector.test.ts
================================================
import {Component} from '../component';
import {Attribute} from './attribute';
import {
createAttributeSelectorFromNames,
createAttributeSelectorFromAttributes,
getFromAttributeSelector,
setWithinAttributeSelector,
cloneAttributeSelector,
attributeSelectorsAreEqual,
attributeSelectorIncludes,
mergeAttributeSelectors,
intersectAttributeSelectors,
removeFromAttributeSelector,
iterateOverAttributeSelector,
pickFromAttributeSelector,
traverseAttributeSelector,
trimAttributeSelector,
normalizeAttributeSelector
} from './attribute-selector';
import {attribute} from '../decorators';
describe('AttributeSelector', () => {
test('createAttributeSelectorFromNames()', () => {
expect(createAttributeSelectorFromNames([])).toStrictEqual({});
expect(createAttributeSelectorFromNames(['title'])).toStrictEqual({title: true});
expect(createAttributeSelectorFromNames(['title', 'country'])).toStrictEqual({
title: true,
country: true
});
});
test('createAttributeSelectorFromAttributes()', () => {
const createAttributes = (names: string[]) =>
names.map((name) => (({getName: () => name} as unknown) as Attribute));
expect(createAttributeSelectorFromAttributes(createAttributes([]))).toStrictEqual({});
expect(createAttributeSelectorFromAttributes(createAttributes(['title']))).toStrictEqual({
title: true
});
expect(
createAttributeSelectorFromAttributes(createAttributes(['title', 'country']))
).toStrictEqual({
title: true,
country: true
});
});
test('getFromAttributeSelector()', () => {
expect(getFromAttributeSelector(false, 'title')).toBe(false);
expect(getFromAttributeSelector(true, 'title')).toBe(true);
const attributeSelector = {title: true, director: {name: true}};
expect(getFromAttributeSelector(attributeSelector, 'title')).toBe(true);
expect(getFromAttributeSelector(attributeSelector, 'country')).toBe(false);
expect(getFromAttributeSelector(attributeSelector, 'director')).toStrictEqual({name: true});
});
test('setWithinAttributeSelector()', () => {
expect(setWithinAttributeSelector(false, 'title', false)).toBe(false);
expect(setWithinAttributeSelector(false, 'title', true)).toBe(false);
expect(setWithinAttributeSelector(true, 'title', false)).toBe(true);
expect(setWithinAttributeSelector(true, 'title', true)).toBe(true);
expect(setWithinAttributeSelector({}, 'title', false)).toStrictEqual({});
expect(setWithinAttributeSelector({}, 'title', true)).toStrictEqual({title: true});
expect(setWithinAttributeSelector({title: true}, 'title', false)).toStrictEqual({});
expect(setWithinAttributeSelector({title: true}, 'title', true)).toStrictEqual({title: true});
expect(setWithinAttributeSelector({title: true}, 'director', {})).toStrictEqual({
title: true,
director: {}
});
expect(setWithinAttributeSelector({title: true}, 'director', {name: true})).toStrictEqual({
title: true,
director: {name: true}
});
});
test('cloneAttributeSelector()', () => {
const attributeSelector = {title: true, director: {name: true}};
const clonedAttributeSelector = cloneAttributeSelector(attributeSelector);
expect(clonedAttributeSelector).not.toBe(attributeSelector);
expect(getFromAttributeSelector(clonedAttributeSelector, 'director')).not.toBe(
getFromAttributeSelector(attributeSelector, 'director')
);
expect(clonedAttributeSelector).toStrictEqual(attributeSelector);
});
test('attributeSelectorsAreEqual()', () => {
expect(attributeSelectorsAreEqual(false, false)).toBe(true);
expect(attributeSelectorsAreEqual(true, true)).toBe(true);
expect(attributeSelectorsAreEqual({}, {})).toBe(true);
expect(attributeSelectorsAreEqual({title: true}, {title: true})).toBe(true);
expect(
attributeSelectorsAreEqual({title: true, country: true}, {title: true, country: true})
).toBe(true);
expect(
attributeSelectorsAreEqual(
{title: true, director: {name: true}},
{title: true, director: {name: true}}
)
).toBe(true);
expect(attributeSelectorsAreEqual(false, true)).not.toBe(true);
expect(attributeSelectorsAreEqual(true, false)).not.toBe(true);
expect(attributeSelectorsAreEqual(false, {})).not.toBe(true);
expect(attributeSelectorsAreEqual({}, false)).not.toBe(true);
expect(attributeSelectorsAreEqual(true, {})).not.toBe(true);
expect(attributeSelectorsAreEqual({}, true)).not.toBe(true);
expect(attributeSelectorsAreEqual({title: true}, {})).not.toBe(true);
expect(attributeSelectorsAreEqual({}, {title: true})).not.toBe(true);
expect(attributeSelectorsAreEqual({title: true, country: true}, {title: true})).not.toBe(true);
expect(attributeSelectorsAreEqual({title: true}, {title: true, country: true})).not.toBe(true);
expect(
attributeSelectorsAreEqual(
{title: true, director: {name: true}},
{title: true, director: {country: true}}
)
).not.toBe(true);
});
test('attributeSelectorIncludes()', () => {
expect(attributeSelectorIncludes(false, false)).toBe(true);
expect(attributeSelectorIncludes(true, false)).toBe(true);
expect(attributeSelectorIncludes(true, true)).toBe(true);
expect(attributeSelectorIncludes(true, {})).toBe(true);
expect(attributeSelectorIncludes(true, {title: true})).toBe(true);
expect(attributeSelectorIncludes({}, false)).toBe(true);
expect(attributeSelectorIncludes({}, {})).toBe(true);
expect(attributeSelectorIncludes({title: true}, false)).toBe(true);
expect(attributeSelectorIncludes({title: true}, {})).toBe(true);
expect(attributeSelectorIncludes({title: true}, {title: true})).toBe(true);
expect(attributeSelectorIncludes({title: true, country: true}, {title: true})).toBe(true);
expect(attributeSelectorIncludes({title: true, director: {name: true}}, {title: true})).toBe(
true
);
expect(
attributeSelectorIncludes({title: true, director: {name: true}}, {title: true, director: {}})
).toBe(true);
expect(
attributeSelectorIncludes(
{title: true, director: {name: true}},
{title: true, director: {name: true}}
)
).toBe(true);
expect(attributeSelectorIncludes(false, true)).not.toBe(true);
expect(attributeSelectorIncludes(false, {})).not.toBe(true);
expect(attributeSelectorIncludes(false, {title: true})).not.toBe(true);
expect(attributeSelectorIncludes({}, true)).not.toBe(true);
expect(attributeSelectorIncludes({title: true}, true)).not.toBe(true);
expect(attributeSelectorIncludes({}, {title: true})).not.toBe(true);
expect(attributeSelectorIncludes({title: true}, {country: true})).not.toBe(true);
expect(attributeSelectorIncludes({title: true}, {title: true, country: true})).not.toBe(true);
expect(attributeSelectorIncludes({title: true}, {title: true, director: {}})).not.toBe(true);
expect(
attributeSelectorIncludes({title: true, director: {}}, {title: true, director: {name: true}})
).not.toBe(true);
expect(
attributeSelectorIncludes(
{title: true, director: {name: true}},
{title: true, director: {country: true}}
)
).not.toBe(true);
});
test('mergeAttributeSelectors()', () => {
expect(mergeAttributeSelectors(false, false)).toBe(false);
expect(mergeAttributeSelectors(false, true)).toBe(true);
expect(mergeAttributeSelectors(true, false)).toBe(true);
expect(mergeAttributeSelectors(true, true)).toBe(true);
expect(mergeAttributeSelectors(true, {})).toBe(true);
expect(mergeAttributeSelectors(true, {title: true})).toBe(true);
expect(mergeAttributeSelectors(false, {})).toStrictEqual({});
expect(mergeAttributeSelectors(false, {title: true})).toStrictEqual({title: true});
expect(mergeAttributeSelectors({}, false)).toStrictEqual({});
expect(mergeAttributeSelectors({}, true)).toBe(true);
expect(mergeAttributeSelectors({}, {})).toStrictEqual({});
expect(mergeAttributeSelectors({title: true}, false)).toStrictEqual({title: true});
expect(mergeAttributeSelectors({title: true}, {})).toStrictEqual({title: true});
expect(mergeAttributeSelectors({title: true}, {title: true})).toStrictEqual({title: true});
expect(mergeAttributeSelectors({title: true}, {country: true})).toStrictEqual({
title: true,
country: true
});
expect(
mergeAttributeSelectors({title: true, director: {name: true}}, {title: true, director: true})
).toStrictEqual({title: true, director: true});
expect(
mergeAttributeSelectors({title: true, director: true}, {title: true, director: {name: true}})
).toStrictEqual({title: true, director: true});
expect(
mergeAttributeSelectors(
{title: true, director: {name: true}},
{title: true, director: {country: true}}
)
).toStrictEqual({title: true, director: {name: true, country: true}});
});
test('intersectAttributeSelectors()', () => {
expect(intersectAttributeSelectors(false, false)).toBe(false);
expect(intersectAttributeSelectors(false, true)).toBe(false);
expect(intersectAttributeSelectors(true, false)).toBe(false);
expect(intersectAttributeSelectors(true, true)).toBe(true);
expect(intersectAttributeSelectors(true, {})).toStrictEqual({});
expect(intersectAttributeSelectors(true, {title: true})).toStrictEqual({title: true});
expect(intersectAttributeSelectors(false, {})).toBe(false);
expect(intersectAttributeSelectors(false, {title: true})).toBe(false);
expect(intersectAttributeSelectors({}, false)).toBe(false);
expect(intersectAttributeSelectors({}, true)).toStrictEqual({});
expect(intersectAttributeSelectors({}, {})).toStrictEqual({});
expect(intersectAttributeSelectors({title: true}, false)).toBe(false);
expect(intersectAttributeSelectors({title: true}, {})).toStrictEqual({});
expect(intersectAttributeSelectors({title: true}, {title: true})).toStrictEqual({title: true});
expect(intersectAttributeSelectors({title: true}, {country: true})).toStrictEqual({});
expect(
intersectAttributeSelectors(
{title: true, director: {name: true}},
{title: true, director: true}
)
).toStrictEqual({title: true, director: {name: true}});
expect(
intersectAttributeSelectors(
{title: true, director: true},
{title: true, director: {name: true}}
)
).toStrictEqual({title: true, director: {name: true}});
expect(
intersectAttributeSelectors(
{title: true, director: {name: true}},
{title: true, director: {country: true}}
)
).toStrictEqual({title: true, director: {}});
});
test('removeFromAttributeSelector()', () => {
expect(removeFromAttributeSelector(false, false)).toBe(false);
expect(removeFromAttributeSelector(false, true)).toBe(false);
expect(removeFromAttributeSelector(true, false)).toBe(true);
expect(removeFromAttributeSelector(true, true)).toBe(false);
expect(() => removeFromAttributeSelector(true, {})).toThrow(
"Cannot remove an 'object' attribute selector from a 'true' attribute selector"
);
expect(removeFromAttributeSelector(false, {})).toBe(false);
expect(removeFromAttributeSelector({}, false)).toStrictEqual({});
expect(removeFromAttributeSelector({}, true)).toBe(false);
expect(removeFromAttributeSelector({}, {})).toStrictEqual({});
expect(removeFromAttributeSelector({title: true}, {})).toStrictEqual({title: true});
expect(removeFromAttributeSelector({title: true}, {title: false})).toStrictEqual({title: true});
expect(removeFromAttributeSelector({title: true}, {title: true})).toStrictEqual({});
expect(removeFromAttributeSelector({title: true}, {country: true})).toStrictEqual({
title: true
});
expect(
removeFromAttributeSelector({title: true, director: {name: true}}, {director: true})
).toStrictEqual({
title: true
});
expect(() =>
removeFromAttributeSelector(
{title: true, director: true},
{title: true, director: {name: true}}
)
).toThrow("Cannot remove an 'object' attribute selector from a 'true' attribute selector");
expect(
removeFromAttributeSelector(
{title: true, director: {name: true}},
{title: true, director: {name: true}}
)
).toStrictEqual({director: {}});
});
test('iterateOverAttributeSelector()', () => {
expect(Array.from(iterateOverAttributeSelector({}))).toStrictEqual([]);
expect(Array.from(iterateOverAttributeSelector({title: undefined}))).toStrictEqual([]);
expect(Array.from(iterateOverAttributeSelector({title: false}))).toStrictEqual([]);
expect(Array.from(iterateOverAttributeSelector({title: true}))).toStrictEqual([
['title', true]
]);
expect(Array.from(iterateOverAttributeSelector({specs: {duration: true}}))).toStrictEqual([
['specs', {duration: true}]
]);
expect(
Array.from(iterateOverAttributeSelector({title: true, country: false, director: true}))
).toStrictEqual([
['title', true],
['director', true]
]);
});
test('pickFromAttributeSelector()', () => {
class Organization extends Component {
@attribute() name?: string;
@attribute() country?: string;
}
const organization = Organization.instantiate();
organization.name = 'Paradise Inc.';
const createdOn = new Date();
const person = {
id: 'abc123',
email: 'hi@hello.com',
emailIsConfirmed: true,
reference: 123,
tags: ['admin', 'creator'],
location: undefined,
organization,
friends: [
{__component: 'person', id: 'def456', reference: 456},
{__component: 'person', id: 'ghi789', reference: 789}
],
matrix: [
[
{name: 'a', value: 111},
{name: 'b', value: 222}
],
[
{name: 'c', value: 333},
{name: 'b', value: 444}
]
],
createdOn
};
expect(pickFromAttributeSelector(person, true)).toStrictEqual(person);
expect(pickFromAttributeSelector(person, {})).toStrictEqual({});
expect(
pickFromAttributeSelector(person, {
id: true,
emailIsConfirmed: true,
reference: true,
createdOn: true
})
).toStrictEqual({
id: 'abc123',
emailIsConfirmed: true,
reference: 123,
createdOn
});
expect(pickFromAttributeSelector(person, {tags: true})).toStrictEqual({
tags: ['admin', 'creator']
});
expect(pickFromAttributeSelector(person, {organization: true})).toStrictEqual({organization});
expect(pickFromAttributeSelector(person, {organization: {name: true}})).toStrictEqual({
organization: {name: 'Paradise Inc.'}
});
expect(pickFromAttributeSelector(person, {friends: true})).toStrictEqual({
friends: [
{__component: 'person', id: 'def456', reference: 456},
{__component: 'person', id: 'ghi789', reference: 789}
]
});
expect(pickFromAttributeSelector(person, {friends: {id: true}})).toStrictEqual({
friends: [{id: 'def456'}, {id: 'ghi789'}]
});
expect(
pickFromAttributeSelector(
person,
{friends: {reference: true}},
{includeAttributeNames: ['__component']}
)
).toStrictEqual({
friends: [
{__component: 'person', reference: 456},
{__component: 'person', reference: 789}
]
});
expect(pickFromAttributeSelector(person, {matrix: {value: true}})).toStrictEqual({
matrix: [
[{value: 111}, {value: 222}],
[{value: 333}, {value: 444}]
]
});
expect(pickFromAttributeSelector(undefined, {location: true})).toBeUndefined();
expect(pickFromAttributeSelector(person, {location: true})).toStrictEqual({
location: undefined
});
expect(pickFromAttributeSelector(person, {location: {country: true}})).toStrictEqual({
location: undefined
});
expect(() => pickFromAttributeSelector(null, {id: true})).toThrow(
"Cannot pick attributes from a value that is not a component, a plain object, or an array (value type: 'null')"
);
expect(() => pickFromAttributeSelector('abc123', {id: true})).toThrow(
"Cannot pick attributes from a value that is not a component, a plain object, or an array (value type: 'string')"
);
expect(() => pickFromAttributeSelector(createdOn, {id: true})).toThrow(
"Cannot pick attributes from a value that is not a component, a plain object, or an array (value type: 'Date')"
);
expect(() => pickFromAttributeSelector(person, {reference: {value: true}})).toThrow(
"Cannot pick attributes from a value that is not a component, a plain object, or an array (value type: 'number')"
);
expect(() => pickFromAttributeSelector(person, false)).toThrow(
"Cannot pick attributes from a value when the specified attribute selector is 'false'"
);
expect(() => pickFromAttributeSelector(person, {organization: {country: true}})).toThrow(
"Cannot get the value of an unset attribute (attribute: 'Organization.prototype.country')"
);
expect(() => pickFromAttributeSelector(person, {organization: {city: true}})).toThrow(
"The attribute 'city' is missing (component: 'Organization')"
);
});
test('traverseAttributeSelector()', () => {
class Organization extends Component {
@attribute() name?: string;
@attribute() country?: string;
}
const organization = Organization.instantiate();
organization.name = 'Paradise Inc.';
const createdOn = new Date();
const person = {
id: 'abc123',
email: 'hi@hello.com',
emailIsConfirmed: true,
reference: 123,
tags: ['admin', 'creator'],
location: undefined,
organization,
friends: [
{firstName: 'Bob', lastName: 'Sinclair'},
{firstName: 'John', lastName: 'Thomas'}
],
matrix: [
[
{name: 'a', value: 111},
{name: 'b', value: 222}
],
[
{name: 'c', value: 333},
{name: 'b', value: 444}
]
],
createdOn
};
const runTraverse = function (value: any, attributeSelector?: any, options?: any) {
const results: any[] = [];
traverseAttributeSelector(
value,
attributeSelector,
function (value, attributeSelector, {name, object, isArray}) {
results.push({
value,
attributeSelector,
name,
object,
...(isArray && {isArray: true})
});
},
options
);
return results;
};
expect(runTraverse(person, true)).toStrictEqual([
{value: person, attributeSelector: true, name: undefined, object: undefined}
]);
expect(runTraverse(person, false)).toStrictEqual([]);
expect(
runTraverse(person, {
id: true,
emailIsConfirmed: true,
reference: true,
createdOn: true
})
).toStrictEqual([
{value: 'abc123', attributeSelector: true, name: 'id', object: person},
{value: true, attributeSelector: true, name: 'emailIsConfirmed', object: person},
{value: 123, attributeSelector: true, name: 'reference', object: person},
{value: createdOn, attributeSelector: true, name: 'createdOn', object: person}
]);
expect(runTraverse(person, {tags: true})).toStrictEqual([
{value: ['admin', 'creator'], attributeSelector: true, name: 'tags', object: person}
]);
expect(runTraverse(person, {organization: true})).toStrictEqual([
{value: organization, attributeSelector: true, name: 'organization', object: person}
]);
expect(runTraverse(person, {organization: {name: true, country: true}})).toStrictEqual([
{value: 'Paradise Inc.', attributeSelector: true, name: 'name', object: person.organization}
]);
expect(runTraverse(person, {friends: true})).toStrictEqual([
{value: person.friends, attributeSelector: true, name: 'friends', object: person}
]);
expect(runTraverse(person, {friends: {firstName: true}})).toStrictEqual([
{value: 'Bob', attributeSelector: true, name: 'firstName', object: person.friends[0]},
{value: 'John', attributeSelector: true, name: 'firstName', object: person.friends[1]}
]);
expect(runTraverse(person, {matrix: {value: true}})).toStrictEqual([
{value: 111, attributeSelector: true, name: 'value', object: person.matrix[0][0]},
{value: 222, attributeSelector: true, name: 'value', object: person.matrix[0][1]},
{value: 333, attributeSelector: true, name: 'value', object: person.matrix[1][0]},
{value: 444, attributeSelector: true, name: 'value', object: person.matrix[1][1]}
]);
expect(runTraverse(undefined, {location: true})).toStrictEqual([
{value: undefined, attributeSelector: {location: true}, name: undefined, object: undefined}
]);
expect(runTraverse(person, {location: true})).toStrictEqual([
{value: undefined, attributeSelector: true, name: 'location', object: person}
]);
expect(runTraverse(person, {location: {country: true}})).toStrictEqual([
{
value: undefined,
attributeSelector: {country: true},
name: 'location',
object: person
}
]);
expect(
runTraverse(
person,
{organization: {name: true}},
{includeSubtrees: true, includeLeafs: false}
)
).toStrictEqual([
{
value: person.organization,
attributeSelector: {name: true},
name: 'organization',
object: person
}
]);
expect(
runTraverse(person, {organization: {name: true}}, {includeSubtrees: true, includeLeafs: true})
).toStrictEqual([
{
value: person.organization,
attributeSelector: {name: true},
name: 'organization',
object: person
},
{value: 'Paradise Inc.', attributeSelector: true, name: 'name', object: person.organization}
]);
expect(
runTraverse(
person,
{friends: {firstName: true}},
{includeSubtrees: true, includeLeafs: false}
)
).toStrictEqual([
{
value: person.friends[0],
attributeSelector: {firstName: true},
name: 'friends',
object: person,
isArray: true
},
{
value: person.friends[1],
attributeSelector: {firstName: true},
name: 'friends',
object: person,
isArray: true
}
]);
expect(runTraverse(person, true, {includeSubtrees: true, includeLeafs: false})).toStrictEqual(
[]
);
expect(() => runTraverse(null, {id: true})).toThrow(
"Cannot traverse attributes from a value that is not a component, a plain object, or an array (value type: 'null')"
);
expect(() => runTraverse('abc123', {id: true})).toThrow(
"Cannot traverse attributes from a value that is not a component, a plain object, or an array (value type: 'string')"
);
expect(() => runTraverse(createdOn, {id: true})).toThrow(
"Cannot traverse attributes from a value that is not a component, a plain object, or an array (value type: 'Date')"
);
expect(() => runTraverse(person, {reference: {value: true}})).toThrow(
"Cannot traverse attributes from a value that is not a component, a plain object, or an array (value type: 'number')"
);
expect(() => runTraverse(person, {organization: {city: true}})).toThrow(
"The attribute 'city' is missing (component: 'Organization')"
);
});
test('trimAttributeSelector()', () => {
expect(trimAttributeSelector(true)).toBe(true);
expect(trimAttributeSelector(false)).toBe(false);
expect(trimAttributeSelector({})).toBe(false);
expect(trimAttributeSelector({title: false})).toBe(false);
expect(trimAttributeSelector({director: {}})).toBe(false);
expect(trimAttributeSelector({director: {name: false}})).toBe(false);
expect(trimAttributeSelector({title: true})).toStrictEqual({title: true});
expect(trimAttributeSelector({title: true, director: {name: false}})).toStrictEqual({
title: true
});
expect(trimAttributeSelector({title: false, director: {name: true}})).toStrictEqual({
director: {name: true}
});
});
test('normalizeAttributeSelector()', () => {
expect(normalizeAttributeSelector(true)).toBe(true);
expect(normalizeAttributeSelector(false)).toBe(false);
expect(normalizeAttributeSelector(undefined)).toBe(false);
expect(normalizeAttributeSelector({})).toStrictEqual({});
expect(normalizeAttributeSelector({title: true, director: {name: true}})).toStrictEqual({
title: true,
director: {name: true}
});
class Movie {}
expect(() => normalizeAttributeSelector(null)).toThrow(
"Expected a valid attribute selector, but received a value of type 'null'"
);
expect(() => normalizeAttributeSelector(1)).toThrow(
"Expected a valid attribute selector, but received a value of type 'number'"
);
expect(() => normalizeAttributeSelector('a')).toThrow(
"Expected a valid attribute selector, but received a value of type 'string'"
);
expect(() => normalizeAttributeSelector(['a'])).toThrow(
"Expected a valid attribute selector, but received a value of type 'Array'"
);
expect(() => normalizeAttributeSelector(() => {})).toThrow(
"Expected a valid attribute selector, but received a value of type 'Function'"
);
expect(() => normalizeAttributeSelector(new Date())).toThrow(
"Expected a valid attribute selector, but received a value of type 'Date'"
);
expect(() => normalizeAttributeSelector(new Movie())).toThrow(
"Expected a valid attribute selector, but received a value of type 'Movie'"
);
});
});
================================================
FILE: packages/component/src/properties/attribute-selector.ts
================================================
import {
hasOwnProperty,
getTypeOf,
isPlainObject,
PlainObject,
assertIsFunction
} from 'core-helpers';
import omit from 'lodash/omit';
import cloneDeep from 'lodash/cloneDeep';
import isEmpty from 'lodash/isEmpty';
import type {Attribute} from './attribute';
import {isComponentClassOrInstance} from '../utilities';
export type AttributeSelector = boolean | PlainObject;
/**
* @typedef AttributeSelector
*
* An `AttributeSelector` allows you to select some attributes of a component.
*
* The simplest `AttributeSelector` is `true`, which means that all the attributes are selected.
* Another possible `AttributeSelector` is `false`, which means that no attributes are selected.
*
* To select some specific attributes, you can use a plain object where:
*
* * The keys are the name of the attributes you want to select.
* * The values are a boolean or a nested object to select some attributes of a nested component.
*
* **Examples:**
*
* ```
* // Selects all the attributes
* true
*
* // Excludes all the attributes
* false
*
* // Selects `title`
* {title: true}
*
* // Selects also `title` (`summary` is not selected)
* {title: true, summary: false}
*
* // Selects `title` and `summary`
* {title: true, summary: true}
*
* // Selects `title`, `movieDetails.duration`, and `movieDetails.aspectRatio`
* {
* title: true,
* movieDetails: {
* duration: true,
* aspectRatio: true
* }
* }
* ```
*/
/**
* Creates an `AttributeSelector` from the specified names.
*
* @param names An array of strings.
*
* @returns An `AttributeSelector`.
*
* @example
* ```
* createAttributeSelectorFromNames(['title', 'summary']);
* // => {title: true, summary: true}
* ```
*
* @category Functions
*/
export function createAttributeSelectorFromNames(names: string[]) {
const attributeSelector: AttributeSelector = {};
for (const name of names) {
attributeSelector[name] = true;
}
return attributeSelector;
}
/**
* Creates an `AttributeSelector` from an attribute iterator.
*
* @param attributes An [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) iterator.
*
* @returns An `AttributeSelector`.
*
* @example
* ```
* createAttributeSelectorFromAttributes(Movie.prototype.getAttributes());
* // => {title: true, summary: true, movieDetails: true}
* ```
*
* @category Functions
*/
export function createAttributeSelectorFromAttributes(attributes: Iterable) {
const attributeSelector: AttributeSelector = {};
for (const attribute of attributes) {
attributeSelector[attribute.getName()] = true;
}
return attributeSelector;
}
/**
* Gets an entry of an `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param name The name of the entry to get.
*
* @returns An `AttributeSelector`.
*
* @example
* ```
* getFromAttributeSelector(true, 'title');
* // => true
*
* getFromAttributeSelector(false, 'title');
* // => false
*
* getFromAttributeSelector({title: true}, 'title');
* // => true
*
* getFromAttributeSelector({title: true}, 'summary');
* // => false
*
* getFromAttributeSelector({movieDetails: {duration: true}}, 'movieDetails');
* // => {duration: true}
* ```
*
* @category Functions
*/
export function getFromAttributeSelector(
attributeSelector: AttributeSelector,
name: string
): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
if (typeof attributeSelector === 'boolean') {
return attributeSelector;
}
return normalizeAttributeSelector(attributeSelector[name]);
}
/**
* Returns an `AttributeSelector` where an entry of the specified `AttributeSelector` is set with another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param name The name of the entry to set.
* @param subattributeSelector Another `AttributeSelector`.
*
* @returns A new `AttributeSelector`.
*
* @example
* ```
* setWithinAttributeSelector({title: true}, 'summary', true);
* // => {title: true, summary: true}
*
* setWithinAttributeSelector({title: true}, 'summary', false);
* // => {title: true}
*
* setWithinAttributeSelector({title: true, summary: true}, 'summary', false);
* // => {title: true}
*
* setWithinAttributeSelector({title: true}, 'movieDetails', {duration: true});
* // => {title: true, movieDetails: {duration: true}}
* ```
*
* @category Functions
*/
export function setWithinAttributeSelector(
attributeSelector: AttributeSelector,
name: string,
subattributeSelector: AttributeSelector
): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
if (typeof attributeSelector === 'boolean') {
return attributeSelector;
}
subattributeSelector = normalizeAttributeSelector(subattributeSelector);
if (subattributeSelector === false) {
return omit(attributeSelector, name);
}
return {...attributeSelector, [name]: subattributeSelector};
}
/**
* Clones an `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
*
* @returns A new `AttributeSelector`.
*
* @example
* ```
* cloneAttributeSelector(true);
* // => true
*
* cloneAttributeSelector(false);
* // => false
*
* cloneAttributeSelector({title: true, movieDetails: {duration: true});
* // => {title: true, movieDetails: {duration: true}
* ```
*
* @category Functions
*/
export function cloneAttributeSelector(attributeSelector: AttributeSelector) {
return cloneDeep(attributeSelector);
}
/**
* Returns whether an `AttributeSelector` is equal to another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param otherAttributeSelector Another `AttributeSelector`.
*
* @returns A boolean.
*
* @example
* ```
* attributeSelectorsAreEqual({title: true}, {title: true});
* // => true
*
* attributeSelectorsAreEqual({title: true, summary: false}, {title: true});
* // => true
*
* attributeSelectorsAreEqual({title: true}, {summary: true});
* // => false
* ```
*
* @category Functions
*/
export function attributeSelectorsAreEqual(
attributeSelector: AttributeSelector,
otherAttributeSelector: AttributeSelector
) {
return (
attributeSelector === otherAttributeSelector ||
(attributeSelectorIncludes(attributeSelector, otherAttributeSelector) &&
attributeSelectorIncludes(otherAttributeSelector, attributeSelector))
);
}
/**
* Returns whether an `AttributeSelector` includes another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param otherAttributeSelector Another `AttributeSelector`.
*
* @returns A boolean.
*
* @example
* ```
* attributeSelectorIncludes({title: true}, {title: true});
* // => true
*
* attributeSelectorIncludes({title: true, summary: true}, {title: true});
* // => true
*
* attributeSelectorIncludes({title: true}, {summary: true});
* // => false
* ```
*
* @category Functions
*/
export function attributeSelectorIncludes(
attributeSelector: AttributeSelector,
otherAttributeSelector: AttributeSelector
) {
attributeSelector = normalizeAttributeSelector(attributeSelector);
otherAttributeSelector = normalizeAttributeSelector(otherAttributeSelector);
if (attributeSelector === otherAttributeSelector) {
return true;
}
if (typeof attributeSelector === 'boolean') {
return attributeSelector;
}
if (typeof otherAttributeSelector === 'boolean') {
return !otherAttributeSelector;
}
for (const [name, otherSubattributeSelector] of Object.entries(otherAttributeSelector)) {
const subattributeSelector = attributeSelector[name];
if (!attributeSelectorIncludes(subattributeSelector, otherSubattributeSelector)) {
return false;
}
}
return true;
}
/**
* Returns an `AttributeSelector` which is the result of merging an `AttributeSelector` with another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param otherAttributeSelector Another `AttributeSelector`.
*
* @returns A new `AttributeSelector`.
*
* @example
* ```
* mergeAttributeSelectors({title: true}, {title: true});
* // => {title: true}
*
* mergeAttributeSelectors({title: true}, {summary: true});
* // => {title: true, summary: true}
*
* mergeAttributeSelectors({title: true, summary: true}, {summary: false});
* // => {title: true}
* ```
*
* @category Functions
*/
export function mergeAttributeSelectors(
attributeSelector: AttributeSelector,
otherAttributeSelector: AttributeSelector
): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
otherAttributeSelector = normalizeAttributeSelector(otherAttributeSelector);
if (attributeSelector === true) {
return true;
}
if (attributeSelector === false) {
return otherAttributeSelector;
}
if (otherAttributeSelector === true) {
return true;
}
if (otherAttributeSelector === false) {
return attributeSelector;
}
for (const [name, otherSubattributeSelector] of Object.entries(otherAttributeSelector)) {
const subattributeSelector = (attributeSelector as PlainObject)[name];
attributeSelector = setWithinAttributeSelector(
attributeSelector,
name,
mergeAttributeSelectors(subattributeSelector, otherSubattributeSelector)
);
}
return attributeSelector;
}
/**
* Returns an `AttributeSelector` which is the result of the intersection of an `AttributeSelector` with another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param otherAttributeSelector Another `AttributeSelector`.
*
* @returns A new `AttributeSelector`.
*
* @example
* ```
* intersectAttributeSelectors({title: true, summary: true}, {title: true});
* // => {title: true}
*
* intersectAttributeSelectors({title: true}, {summary: true});
* // => {}
* ```
*
* @category Functions
*/
export function intersectAttributeSelectors(
attributeSelector: AttributeSelector,
otherAttributeSelector: AttributeSelector
): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
otherAttributeSelector = normalizeAttributeSelector(otherAttributeSelector);
if (attributeSelector === false || otherAttributeSelector === false) {
return false;
}
if (attributeSelector === true) {
return otherAttributeSelector;
}
if (otherAttributeSelector === true) {
return attributeSelector;
}
let intersectedAttributeSelector = {};
for (const [name, otherSubattributeSelector] of Object.entries(otherAttributeSelector)) {
const subattributeSelector = (attributeSelector as PlainObject)[name];
intersectedAttributeSelector = setWithinAttributeSelector(
intersectedAttributeSelector,
name,
intersectAttributeSelectors(subattributeSelector, otherSubattributeSelector)
);
}
return intersectedAttributeSelector;
}
/**
* Returns an `AttributeSelector` which is the result of removing an `AttributeSelector` from another `AttributeSelector`.
*
* @param attributeSelector An `AttributeSelector`.
* @param otherAttributeSelector Another `AttributeSelector`.
*
* @returns A new `AttributeSelector`.
*
* @example
* ```
* removeFromAttributeSelector({title: true, summary: true}, {summary: true});
* // => {title: true}
*
* removeFromAttributeSelector({title: true}, {title: true});
* // => {}
* ```
*
* @category Functions
*/
export function removeFromAttributeSelector(
attributeSelector: AttributeSelector,
otherAttributeSelector: AttributeSelector
): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
otherAttributeSelector = normalizeAttributeSelector(otherAttributeSelector);
if (otherAttributeSelector === true) {
return false;
}
if (otherAttributeSelector === false) {
return attributeSelector;
}
if (attributeSelector === true) {
throw new Error(
"Cannot remove an 'object' attribute selector from a 'true' attribute selector"
);
}
if (attributeSelector === false) {
return false;
}
for (const [name, otherSubattributeSelector] of Object.entries(otherAttributeSelector)) {
const subattributeSelector = (attributeSelector as PlainObject)[name];
attributeSelector = setWithinAttributeSelector(
attributeSelector,
name,
removeFromAttributeSelector(subattributeSelector, otherSubattributeSelector)
);
}
return attributeSelector;
}
export function iterateOverAttributeSelector(attributeSelector: AttributeSelector) {
return {
*[Symbol.iterator]() {
for (const [name, subattributeSelector] of Object.entries(attributeSelector)) {
const normalizedSubattributeSelector = normalizeAttributeSelector(subattributeSelector);
if (normalizedSubattributeSelector !== false) {
yield [name, normalizedSubattributeSelector] as [string, AttributeSelector];
}
}
}
};
}
type PickFromAttributeSelectorResult = Value extends Array
? Array>
: Value extends object
? object
: Value;
export function pickFromAttributeSelector(
value: Value,
attributeSelector: AttributeSelector,
options?: {includeAttributeNames?: string[]}
): PickFromAttributeSelectorResult;
export function pickFromAttributeSelector(
value: unknown,
attributeSelector: AttributeSelector,
options: {includeAttributeNames?: string[]} = {}
) {
attributeSelector = normalizeAttributeSelector(attributeSelector);
if (attributeSelector === false) {
throw new Error(
`Cannot pick attributes from a value when the specified attribute selector is 'false'`
);
}
const {includeAttributeNames = []} = options;
return _pick(value, attributeSelector, {includeAttributeNames});
}
function _pick(
value: unknown,
attributeSelector: AttributeSelector,
{includeAttributeNames}: {includeAttributeNames: string[]}
): unknown {
if (attributeSelector === true) {
return value;
}
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
const array = value;
return array.map((value) => _pick(value, attributeSelector, {includeAttributeNames}));
}
const isComponent = isComponentClassOrInstance(value);
if (!(isComponent || isPlainObject(value))) {
throw new Error(
`Cannot pick attributes from a value that is not a component, a plain object, or an array (value type: '${getTypeOf(
value
)}')`
);
}
const componentOrObject = value as PlainObject;
const result: PlainObject = {};
if (!isComponent) {
for (const name of includeAttributeNames) {
if (hasOwnProperty(componentOrObject, name)) {
result[name] = componentOrObject[name];
}
}
}
for (const [name, subattributeSelector] of iterateOverAttributeSelector(attributeSelector)) {
const value = isComponent
? componentOrObject.getAttribute(name).getValue()
: componentOrObject[name];
result[name] = _pick(value, subattributeSelector, {includeAttributeNames});
}
return result;
}
type TraverseIteratee = (
value: any,
attributeSelector: AttributeSelector,
context: TraverseContext
) => void;
type TraverseContext = {name?: string; object?: object; isArray?: boolean};
type TraverseOptions = {includeSubtrees?: boolean; includeLeafs?: boolean};
export function traverseAttributeSelector(
value: any,
attributeSelector: AttributeSelector,
iteratee: TraverseIteratee,
options: TraverseOptions = {}
) {
attributeSelector = normalizeAttributeSelector(attributeSelector);
assertIsFunction(iteratee);
const {includeSubtrees = false, includeLeafs = true} = options;
if (attributeSelector === false) {
return;
}
_traverse(value, attributeSelector, iteratee, {
includeSubtrees,
includeLeafs,
_context: {},
_isDeep: false
});
}
function _traverse(
value: any,
attributeSelector: AttributeSelector,
iteratee: TraverseIteratee,
{
includeSubtrees,
includeLeafs,
_context,
_isDeep
}: TraverseOptions & {_context: TraverseContext; _isDeep: boolean}
) {
if (attributeSelector === true || value === undefined) {
if (includeLeafs) {
iteratee(value, attributeSelector, _context);
}
return;
}
if (Array.isArray(value)) {
const array = value;
for (const value of array) {
_traverse(value, attributeSelector, iteratee, {
includeSubtrees,
includeLeafs,
_context: {..._context, isArray: true},
_isDeep
});
}
return;
}
const isComponent = isComponentClassOrInstance(value);
if (!(isComponent || isPlainObject(value))) {
throw new Error(
`Cannot traverse attributes from a value that is not a component, a plain object, or an array (value type: '${getTypeOf(
value
)}')`
);
}
const componentOrObject = value;
if (_isDeep && includeSubtrees) {
iteratee(componentOrObject, attributeSelector, _context);
}
for (const [name, subattributeSelector] of iterateOverAttributeSelector(attributeSelector)) {
if (isComponent && !componentOrObject.getAttribute(name).isSet()) {
continue;
}
const value = componentOrObject[name];
_traverse(value, subattributeSelector, iteratee, {
includeSubtrees,
includeLeafs,
_context: {name, object: componentOrObject},
_isDeep: true
});
}
}
export function trimAttributeSelector(attributeSelector: AttributeSelector): AttributeSelector {
attributeSelector = normalizeAttributeSelector(attributeSelector);
if (typeof attributeSelector === 'boolean') {
return attributeSelector;
}
for (const [name, subattributeSelector] of Object.entries(attributeSelector)) {
attributeSelector = setWithinAttributeSelector(
attributeSelector,
name,
trimAttributeSelector(subattributeSelector)
);
}
if (isEmpty(attributeSelector)) {
return false;
}
return attributeSelector;
}
export function normalizeAttributeSelector(attributeSelector: any): AttributeSelector {
if (attributeSelector === undefined) {
return false;
}
if (typeof attributeSelector === 'boolean') {
return attributeSelector;
}
if (isPlainObject(attributeSelector)) {
return attributeSelector;
}
throw new Error(
`Expected a valid attribute selector, but received a value of type '${getTypeOf(
attributeSelector
)}'`
);
}
================================================
FILE: packages/component/src/properties/attribute.test.ts
================================================
import type {ExtendedError} from '@layr/utilities';
import {Component} from '../component';
import {EmbeddedComponent} from '../embedded-component';
import {Attribute} from './attribute';
import {isNumberValueTypeInstance} from './value-types';
import {sanitizers} from '../sanitization';
import {validators} from '../validation';
describe('Attribute', () => {
test('Creation', async () => {
class Movie extends Component {}
const attribute = new Attribute('limit', Movie, {valueType: 'number'});
expect(Attribute.isAttribute(attribute)).toBe(true);
expect(attribute.getName()).toBe('limit');
expect(attribute.getParent()).toBe(Movie);
expect(isNumberValueTypeInstance(attribute.getValueType())).toBe(true);
});
test('Value', async () => {
class Movie extends Component {}
const movie = new Movie();
const attribute = new Attribute('title', movie, {valueType: 'string'});
expect(attribute.isSet()).toBe(false);
expect(() => attribute.getValue()).toThrow(
"Cannot get the value of an unset attribute (attribute: 'Movie.prototype.title')"
);
expect(attribute.getValue({throwIfUnset: false})).toBeUndefined();
attribute.setValue('Inception');
expect(attribute.isSet()).toBe(true);
expect(attribute.getValue()).toBe('Inception');
attribute.unsetValue();
expect(attribute.isSet()).toBe(false);
expect(() => attribute.setValue(123)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'Movie.prototype.title', expected type: 'string', received type: 'number')"
);
expect(() => attribute.setValue(undefined)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'Movie.prototype.title', expected type: 'string', received type: 'undefined')"
);
});
test('Value source', async () => {
class Movie extends Component {}
const movie = new Movie();
const attribute = new Attribute('title', movie, {valueType: 'string'});
attribute.setValue('Inception');
expect(attribute.getValueSource()).toBe('local');
attribute.setValue('Inception', {source: 'server'});
expect(attribute.getValueSource()).toBe('server');
});
test('Accessors', async () => {
class Movie extends Component {}
const movie = new Movie();
const attribute = new Attribute('title', movie, {
valueType: 'string',
getter() {
expect(this).toBe(movie);
return this._title;
},
setter(title) {
expect(this).toBe(movie);
this._title = title.substr(0, 1).toUpperCase() + title.substr(1);
}
});
expect(attribute.isSet()).toBe(true);
expect(attribute.getValue()).toBeUndefined();
attribute.setValue('inception');
expect(attribute.getValue()).toBe('Inception');
expect(
() =>
new Attribute('title', movie, {
setter(title) {
this._title = title;
}
})
).toThrow(
"An attribute cannot have a setter without a getter (attribute: 'Movie.prototype.title')"
);
});
test('Initial value', async () => {
class Movie extends Component {}
let attribute = new Attribute('limit', Movie, {valueType: 'number'});
expect(attribute.isSet()).toBe(false);
attribute = new Attribute('limit', Movie, {valueType: 'number', value: 100});
expect(attribute.isSet()).toBe(true);
expect(attribute.getValue()).toBe(100);
expect(
() =>
new Attribute('limit', Movie, {
valueType: 'number',
value: 100,
getter() {
return 100;
}
})
).toThrow(
"An attribute cannot have both a getter or setter and an initial value (attribute: 'Movie.limit')"
);
});
test('Default value', async () => {
class Movie extends Component {}
const movie = new Movie();
let attribute = new Attribute('duration', movie, {valueType: 'number?'});
expect(attribute.getDefault()).toBe(undefined);
expect(attribute.evaluateDefault()).toBe(undefined);
attribute = new Attribute('title', movie, {valueType: 'string', default: ''});
expect(attribute.getDefault()).toBe('');
expect(attribute.evaluateDefault()).toBe('');
attribute = new Attribute('title', movie, {valueType: 'string', default: () => 1 + 1});
expect(typeof attribute.getDefault()).toBe('function');
expect(attribute.evaluateDefault()).toBe(2);
attribute = new Attribute('movieClass', movie, {valueType: 'string', default: Movie});
expect(typeof attribute.getDefault()).toBe('function');
expect(attribute.evaluateDefault()).toBe(Movie);
expect(
() =>
new Attribute('title', movie, {
valueType: 'number?',
default: '',
getter() {
return '';
}
})
).toThrow(
"An attribute cannot have both a getter or setter and a default value (attribute: 'Movie.prototype.title')"
);
});
test('isControlled() and markAsControlled()', async () => {
class Movie extends Component {}
const attribute = new Attribute('title', Movie.prototype);
expect(attribute.isControlled()).toBe(false);
attribute.setValue('Inception');
expect(attribute.getValue()).toBe('Inception');
attribute.markAsControlled();
expect(attribute.isControlled()).toBe(true);
expect(() => attribute.setValue('Inception 2')).toThrow(
"Cannot set the value of a controlled attribute when the source is different than 'server' or 'store' (attribute: 'Movie.prototype.title', source: 'local')"
);
expect(attribute.getValue()).toBe('Inception');
});
test('Sanitization', async () => {
class Movie extends Component {}
const movie = new Movie();
const {trim, compact} = sanitizers;
const titleAttribute = new Attribute('title', movie, {
valueType: 'string',
sanitizers: [trim()]
});
titleAttribute.setValue('Inception');
expect(titleAttribute.getValue()).toBe('Inception');
titleAttribute.setValue(' Inception ');
expect(titleAttribute.getValue()).toBe('Inception');
const genresAttribute = new Attribute('genres', movie, {
valueType: 'string[]',
sanitizers: [compact()],
items: {sanitizers: [trim()]}
});
genresAttribute.setValue(['drama', 'action']);
expect(genresAttribute.getValue()).toStrictEqual(['drama', 'action']);
genresAttribute.setValue(['drama ', ' action']);
expect(genresAttribute.getValue()).toStrictEqual(['drama', 'action']);
genresAttribute.setValue(['drama ', '']);
expect(genresAttribute.getValue()).toStrictEqual(['drama']);
genresAttribute.setValue(['', ' ']);
expect(genresAttribute.getValue()).toStrictEqual([]);
});
test('Validation', async () => {
class Movie extends Component {}
const movie = new Movie();
let notEmpty = validators.notEmpty();
let attribute = new Attribute('title', movie, {valueType: 'string?', validators: [notEmpty]});
expect(() => attribute.runValidators()).toThrow(
"Cannot run the validators of an unset attribute (attribute: 'Movie.prototype.title')"
);
attribute.setValue('Inception');
expect(() => attribute.validate()).not.toThrow();
expect(attribute.isValid()).toBe(true);
expect(attribute.runValidators()).toEqual([]);
attribute.setValue('');
expect(() => attribute.validate()).toThrow(
"The following error(s) occurred while validating the attribute 'title': The validator `notEmpty()` failed (path: '')"
);
expect(attribute.isValid()).toBe(false);
expect(attribute.runValidators()).toEqual([{validator: notEmpty, path: ''}]);
attribute.setValue(undefined);
expect(() => attribute.validate()).toThrow(
"The following error(s) occurred while validating the attribute 'title': The validator `notEmpty()` failed (path: '')"
);
expect(attribute.isValid()).toBe(false);
expect(attribute.runValidators()).toEqual([{validator: notEmpty, path: ''}]);
// --- With a custom message ---
notEmpty = validators.notEmpty('The title cannot be empty.');
attribute = new Attribute('title', movie, {valueType: 'string?', validators: [notEmpty]});
attribute.setValue('Inception');
expect(() => attribute.validate()).not.toThrow();
attribute.setValue('');
let error: ExtendedError;
try {
attribute.validate();
} catch (err: any) {
error = err;
}
expect(error!.message).toBe(
"The following error(s) occurred while validating the attribute 'title': The title cannot be empty. (path: '')"
);
expect(error!.displayMessage).toBe('The title cannot be empty.');
});
test('Observability', async () => {
class Movie extends Component {}
const movie = new Movie();
const movieObserver = jest.fn();
movie.addObserver(movieObserver);
const titleAttribute = new Attribute('title', movie, {valueType: 'string'});
const titleObserver = jest.fn();
titleAttribute.addObserver(titleObserver);
expect(titleObserver).toHaveBeenCalledTimes(0);
expect(movieObserver).toHaveBeenCalledTimes(0);
titleAttribute.setValue('Inception');
expect(titleObserver).toHaveBeenCalledTimes(1);
expect(movieObserver).toHaveBeenCalledTimes(1);
titleAttribute.setValue('Inception 2');
expect(titleObserver).toHaveBeenCalledTimes(2);
expect(movieObserver).toHaveBeenCalledTimes(2);
titleAttribute.setValue('Inception 2');
// Assigning the same value should not call the observers
expect(titleObserver).toHaveBeenCalledTimes(2);
expect(movieObserver).toHaveBeenCalledTimes(2);
const tagsAttribute = new Attribute('title', movie, {valueType: 'string[]'});
const tagsObserver = jest.fn();
tagsAttribute.addObserver(tagsObserver);
expect(tagsObserver).toHaveBeenCalledTimes(0);
expect(movieObserver).toHaveBeenCalledTimes(2);
tagsAttribute.setValue(['drama', 'action']);
expect(tagsObserver).toHaveBeenCalledTimes(1);
expect(movieObserver).toHaveBeenCalledTimes(3);
const tagArray = tagsAttribute.getValue() as string[];
tagArray[0] = 'Drama';
expect(tagsObserver).toHaveBeenCalledTimes(2);
expect(movieObserver).toHaveBeenCalledTimes(4);
tagArray[0] = 'Drama';
// Assigning the same value should not call the observers
expect(tagsObserver).toHaveBeenCalledTimes(2);
expect(movieObserver).toHaveBeenCalledTimes(4);
tagsAttribute.setValue(['Drama', 'Action']);
expect(tagsObserver).toHaveBeenCalledTimes(3);
expect(movieObserver).toHaveBeenCalledTimes(5);
const newTagArray = tagsAttribute.getValue() as string[];
newTagArray[0] = 'drama';
expect(tagsObserver).toHaveBeenCalledTimes(4);
expect(movieObserver).toHaveBeenCalledTimes(6);
tagArray[0] = 'DRAMA';
// Modifying the previous array should not call the observers
expect(tagsObserver).toHaveBeenCalledTimes(4);
expect(movieObserver).toHaveBeenCalledTimes(6);
tagsAttribute.unsetValue();
expect(tagsObserver).toHaveBeenCalledTimes(5);
expect(movieObserver).toHaveBeenCalledTimes(7);
tagsAttribute.unsetValue();
// Calling unset again should not call the observers
expect(tagsObserver).toHaveBeenCalledTimes(5);
expect(movieObserver).toHaveBeenCalledTimes(7);
newTagArray[0] = 'drama';
// Modifying the detached array should not call the observers
expect(tagsObserver).toHaveBeenCalledTimes(5);
expect(movieObserver).toHaveBeenCalledTimes(7);
// --- With an embedded component ---
class UserDetails extends EmbeddedComponent {}
const userDetails = new UserDetails();
const countryAttribute = new Attribute('country', userDetails, {valueType: 'string'});
class User extends Component {}
User.provideComponent(UserDetails);
let user = new User();
let userObserver = jest.fn();
user.addObserver(userObserver);
const detailsAttribute = new Attribute('details', user, {valueType: 'UserDetails?'});
expect(userObserver).toHaveBeenCalledTimes(0);
detailsAttribute.setValue(userDetails);
expect(userObserver).toHaveBeenCalledTimes(1);
countryAttribute.setValue('Japan');
expect(userObserver).toHaveBeenCalledTimes(2);
detailsAttribute.setValue(undefined);
expect(userObserver).toHaveBeenCalledTimes(3);
countryAttribute.setValue('France');
expect(userObserver).toHaveBeenCalledTimes(3);
// --- With an array of embedded components ---
class Organization extends EmbeddedComponent {}
const organization = new Organization();
const organizationNameAttribute = new Attribute('name', organization, {valueType: 'string'});
User.provideComponent(Organization);
user = new User();
userObserver = jest.fn();
user.addObserver(userObserver);
const organizationsAttribute = new Attribute('organizations', user, {
valueType: 'Organization[]'
});
expect(userObserver).toHaveBeenCalledTimes(0);
organizationsAttribute.setValue([]);
expect(userObserver).toHaveBeenCalledTimes(1);
(organizationsAttribute.getValue() as Organization[]).push(organization);
expect(userObserver).toHaveBeenCalledTimes(2);
organizationNameAttribute.setValue('The Inc.');
expect(userObserver).toHaveBeenCalledTimes(3);
(organizationsAttribute.getValue() as Organization[]).pop();
expect(userObserver).toHaveBeenCalledTimes(5);
organizationNameAttribute.setValue('Nice Inc.');
expect(userObserver).toHaveBeenCalledTimes(5);
// --- With a referenced component ---
class Blog extends Component {}
const blog = new Blog();
const blogNameAttribute = new Attribute('name', blog, {valueType: 'string'});
class Article extends Component {}
Article.provideComponent(Blog);
let article = new Article();
let articleObserver = jest.fn();
article.addObserver(articleObserver);
const blogAttribute = new Attribute('blog', article, {valueType: 'Blog?'});
expect(articleObserver).toHaveBeenCalledTimes(0);
blogAttribute.setValue(blog);
expect(articleObserver).toHaveBeenCalledTimes(1);
blogNameAttribute.setValue('My Blog');
expect(articleObserver).toHaveBeenCalledTimes(1);
blogAttribute.setValue(undefined);
expect(articleObserver).toHaveBeenCalledTimes(2);
blogNameAttribute.setValue('The Blog');
expect(articleObserver).toHaveBeenCalledTimes(2);
// --- With an array of referenced components ---
class Comment extends Component {}
const comment = new Comment();
const commentTextAttribute = new Attribute('text', comment, {valueType: 'string'});
Article.provideComponent(Comment);
article = new Article();
articleObserver = jest.fn();
article.addObserver(articleObserver);
const commentsAttribute = new Attribute('comments', article, {valueType: 'Comment[]'});
expect(articleObserver).toHaveBeenCalledTimes(0);
commentsAttribute.setValue([]);
expect(articleObserver).toHaveBeenCalledTimes(1);
(commentsAttribute.getValue() as Comment[]).push(comment);
expect(articleObserver).toHaveBeenCalledTimes(2);
commentTextAttribute.setValue('Hello');
expect(articleObserver).toHaveBeenCalledTimes(2);
(commentsAttribute.getValue() as Comment[]).pop();
expect(articleObserver).toHaveBeenCalledTimes(4);
commentTextAttribute.setValue('Hey');
expect(articleObserver).toHaveBeenCalledTimes(4);
});
test('Forking', async () => {
class Movie extends Component {}
const movie = new Movie();
const attribute = new Attribute('title', movie, {valueType: 'string'});
attribute.setValue('Inception');
expect(attribute.getValue()).toBe('Inception');
const movieFork = Object.create(movie);
const attributeFork = attribute.fork(movieFork);
expect(attributeFork.getValue()).toBe('Inception');
attributeFork.setValue('Inception 2');
expect(attributeFork.getValue()).toBe('Inception 2');
expect(attribute.getValue()).toBe('Inception');
});
test('Introspection', async () => {
class Movie extends Component {}
expect(
new Attribute('limit', Movie, {valueType: 'number', exposure: {get: true}}).introspect()
).toStrictEqual({
name: 'limit',
type: 'Attribute',
exposure: {get: true},
valueType: 'number'
});
expect(
new Attribute('limit', Movie, {
valueType: 'number',
value: 100,
exposure: {set: true}
}).introspect()
).toStrictEqual({name: 'limit', type: 'Attribute', exposure: {set: true}, valueType: 'number'});
expect(
new Attribute('limit', Movie, {
valueType: 'number',
value: 100,
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'limit',
type: 'Attribute',
value: 100,
exposure: {get: true},
valueType: 'number'
});
const defaultTitle = function () {
return '';
};
expect(
new Attribute('title', Movie.prototype, {
valueType: 'string',
default: defaultTitle,
exposure: {get: true, set: true}
}).introspect()
).toStrictEqual({
name: 'title',
type: 'Attribute',
default: defaultTitle,
exposure: {get: true, set: true},
valueType: 'string'
});
// When 'set' is not exposed, the default value should not be exposed
expect(
new Attribute('title', Movie.prototype, {
valueType: 'string',
default: defaultTitle,
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'title',
type: 'Attribute',
exposure: {get: true},
valueType: 'string'
});
const notEmpty = validators.notEmpty();
expect(
new Attribute('title', Movie.prototype, {
valueType: 'string?',
validators: [notEmpty],
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'title',
type: 'Attribute',
valueType: 'string?',
exposure: {get: true},
validators: [notEmpty]
});
expect(
new Attribute('tags', Movie.prototype, {
valueType: 'string[]',
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'tags',
type: 'Attribute',
valueType: 'string[]',
exposure: {get: true}
});
expect(
new Attribute('tags', Movie.prototype, {
valueType: 'string[]',
items: {validators: [notEmpty]},
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'tags',
type: 'Attribute',
valueType: 'string[]',
items: {
validators: [notEmpty]
},
exposure: {get: true}
});
expect(
new Attribute('tags', Movie.prototype, {
valueType: 'string[][]',
items: {items: {validators: [notEmpty]}},
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'tags',
type: 'Attribute',
valueType: 'string[][]',
items: {
items: {
validators: [notEmpty]
}
},
exposure: {get: true}
});
});
test('Unintrospection', async () => {
class Movie extends Component {}
let {name, options} = Attribute.unintrospect({
name: 'limit',
type: 'Attribute',
valueType: 'number',
exposure: {get: true}
});
expect({name, options}).toEqual({
name: 'limit',
options: {valueType: 'number', exposure: {get: true}}
});
expect(() => new Attribute(name, Movie, options)).not.toThrow();
({name, options} = Attribute.unintrospect({
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
}));
expect({name, options}).toEqual({
name: 'limit',
options: {valueType: 'number', value: 100, exposure: {get: true}}
});
expect(() => new Attribute(name, Movie, options)).not.toThrow();
const defaultTitle = function () {
return '';
};
({name, options} = Attribute.unintrospect({
name: 'title',
type: 'Attribute',
valueType: 'string',
default: defaultTitle,
exposure: {get: true, set: true}
}));
expect({name, options}).toEqual({
name: 'title',
options: {valueType: 'string', default: defaultTitle, exposure: {get: true, set: true}}
});
expect(() => new Attribute(name, Movie.prototype, options)).not.toThrow();
const notEmptyValidator = validators.notEmpty();
({name, options} = Attribute.unintrospect({
name: 'title',
type: 'Attribute',
valueType: 'string?',
validators: [notEmptyValidator],
exposure: {get: true}
}));
expect(name).toBe('title');
expect(options.valueType).toBe('string?');
expect(options.validators).toEqual([notEmptyValidator]);
expect(options.exposure).toEqual({get: true});
expect(() => new Attribute(name, Movie.prototype, options)).not.toThrow();
({name, options} = Attribute.unintrospect({
name: 'tags',
type: 'Attribute',
valueType: 'string[]',
items: {
validators: [notEmptyValidator]
},
exposure: {get: true}
}));
expect(name).toBe('tags');
expect(options.valueType).toBe('string[]');
expect(options.validators).toBeUndefined();
expect(options.items!.validators).toEqual([notEmptyValidator]);
expect(options.exposure).toEqual({get: true});
expect(() => new Attribute(name, Movie.prototype, options)).not.toThrow();
({name, options} = Attribute.unintrospect({
name: 'tags',
type: 'Attribute',
valueType: 'string[][]',
items: {
items: {
validators: [notEmptyValidator]
}
},
exposure: {get: true}
}));
expect(name).toBe('tags');
expect(options.valueType).toBe('string[][]');
expect(options.validators).toBeUndefined();
expect(options.items!.validators).toBeUndefined();
expect(options.items!.items!.validators).toEqual([notEmptyValidator]);
expect(options.exposure).toEqual({get: true});
expect(() => new Attribute(name, Movie.prototype, options)).not.toThrow();
});
});
================================================
FILE: packages/component/src/properties/attribute.ts
================================================
import {hasOwnProperty} from 'core-helpers';
import {
Observable,
createObservable,
isObservable,
canBeObserved,
isEmbeddable,
ObserverPayload
} from '@layr/observable';
import {throwError} from '@layr/utilities';
import {possiblyAsync} from 'possibly-async';
import type {
Component,
TraverseAttributesIteratee,
TraverseAttributesOptions,
ResolveAttributeSelectorOptions
} from '../component';
import {Property, PropertyOptions, IntrospectedProperty, UnintrospectedProperty} from './property';
import {
ValueType,
IntrospectedValueType,
UnintrospectedValueType,
createValueType,
unintrospectValueType
} from './value-types';
import {fork} from '../forking';
import {AttributeSelector} from './attribute-selector';
import type {Sanitizer, SanitizerFunction} from '../sanitization';
import type {Validator, ValidatorFunction} from '../validation';
import {SerializeOptions} from '../serialization';
import {deserialize, DeserializeOptions} from '../deserialization';
import {isComponentClass, isComponentInstance, ensureComponentClass} from '../utilities';
export type AttributeOptions = PropertyOptions & {
valueType?: string;
value?: unknown;
default?: unknown;
sanitizers?: (Sanitizer | SanitizerFunction)[];
validators?: (Validator | ValidatorFunction)[];
items?: AttributeItemsOptions;
getter?: (this: any) => unknown;
setter?: (this: any, value: any) => void;
};
type AttributeItemsOptions = {
sanitizers?: (Sanitizer | SanitizerFunction)[];
validators?: (Validator | ValidatorFunction)[];
items?: AttributeItemsOptions;
};
export type ValueSource = 'server' | 'store' | 'local' | 'client';
export type IntrospectedAttribute = IntrospectedProperty & {
value?: unknown;
default?: unknown;
} & IntrospectedValueType;
export type UnintrospectedAttribute = UnintrospectedProperty & {
options: {
value?: unknown;
default?: unknown;
} & UnintrospectedValueType;
};
/**
* *Inherits from [`Property`](https://layrjs.com/docs/v2/reference/property) and [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class).*
*
* An `Attribute` represents an attribute of a [Component](https://layrjs.com/docs/v2/reference/component) class, prototype, or instance. It plays the role of a regular JavaScript object attribute, but brings some extra features such as runtime type checking, validation, serialization, or observability.
*
* #### Usage
*
* Typically, you create an `Attribute` and associate it to a component by using the [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) decorator.
*
* For example, here is how you would define a `Movie` class with some attributes:
*
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {minLength} = validators;
*
* class Movie extends Component {
* // Optional 'string' class attribute
* ﹫attribute('string?') static customName;
*
* // Required 'string' instance attribute
* ﹫attribute('string') title;
*
* // Optional 'string' instance attribute with a validator and a default value
* ﹫attribute('string?', {validators: [minLength(16)]}) summary = '';
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {minLength} = validators;
*
* class Movie extends Component {
* // Optional 'string' class attribute
* ﹫attribute('string?') static customName?: string;
*
* // Required 'string' instance attribute
* ﹫attribute('string') title!: string;
*
* // Optional 'string' instance attribute with a validator and a default value
* ﹫attribute('string?', {validators: [minLength(16)]}) summary? = '';
* }
* ```
*
* Then you can access the attributes like you would normally do with regular JavaScript objects:
*
* ```
* Movie.customName = 'Film';
* Movie.customName; // => 'Film'
*
* const movie = new Movie({title: 'Inception'});
* movie.title; // => 'Inception'
* movie.title = 'Inception 2';
* movie.title; // => 'Inception 2'
* movie.summary; // => '' (default value)
* ```
*
* And you can take profit of some extra features:
*
* ```
* // Runtime type checking
* movie.title = 123; // Error
* movie.title = undefined; // Error
*
* // Validation
* movie.summary = undefined;
* movie.isValid(); // => true (movie.summary is optional)
* movie.summary = 'A nice movie.';
* movie.isValid(); // => false (movie.summary is too short)
* movie.summary = 'An awesome movie.'
* movie.isValid(); // => true
*
* // Serialization
* movie.serialize();
* // => {__component: 'Movie', title: 'Inception 2', summary: 'An awesome movie.'}
* ```
*/
export class Attribute extends Observable(Property) {
/**
* Creates an instance of [`Attribute`](https://layrjs.com/docs/v2/reference/attribute). Typically, instead of using this constructor, you would rather use the [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The component class, prototype, or instance that owns the attribute.
* @param [options.valueType] A string specifying the [type of values](https://layrjs.com/docs/v2/reference/value-type#supported-types) the attribute can store (default: `'any'`).
* @param [options.value] The initial value of the attribute (usable for class attributes only).
* @param [options.default] The default value (or a function returning the default value) of the attribute (usable for instance attributes only).
* @param [options.sanitizers] An array of [sanitizers](https://layrjs.com/docs/v2/reference/sanitizer) for the value of the attribute.
* @param [options.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the value of the attribute.
* @param [options.items.sanitizers] An array of [sanitizers](https://layrjs.com/docs/v2/reference/sanitizer) for the items of an array attribute.
* @param [options.items.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the items of an array attribute.
* @param [options.getter] A getter function for getting the value of the attribute. Plays the same role as a regular [JavaScript getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get).
* @param [options.setter] A setter function for setting the value of the attribute. Plays the same role as a regular [JavaScript setter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set).
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the attribute should be exposed to remote access.
*
* @returns The [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance that was created.
*
* @example
* ```
* import {Component, Attribute} from '﹫layr/component';
*
* class Movie extends Component {}
*
* const title = new Attribute('title', Movie.prototype, {valueType: 'string'});
*
* title.getName(); // => 'title'
* title.getParent(); // => Movie.prototype
* title.getValueType().toString(); // => 'string'
* ```
*
* @category Creation
*/
constructor(name: string, parent: typeof Component | Component, options: AttributeOptions = {}) {
super(name, parent, options);
}
_initialize() {
this.addObserver(this._onChange.bind(this));
}
// === Options ===
_getter?: () => unknown;
_setter?: (value: any) => void;
setOptions(options: AttributeOptions = {}) {
const {
valueType,
value: initialValue,
default: defaultValue,
sanitizers,
validators,
items,
getter,
setter,
...otherOptions
} = options;
const hasInitialValue = 'value' in options;
const hasDefaultValue = 'default' in options;
super.setOptions(otherOptions);
this._valueType = createValueType(valueType, this, {sanitizers, validators, items});
if (getter !== undefined || setter !== undefined) {
if (initialValue !== undefined) {
throw new Error(
`An attribute cannot have both a getter or setter and an initial value (${this.describe()})`
);
}
if (defaultValue !== undefined) {
throw new Error(
`An attribute cannot have both a getter or setter and a default value (${this.describe()})`
);
}
if (getter !== undefined) {
this._getter = getter;
}
if (setter !== undefined) {
if (getter === undefined) {
throw new Error(
`An attribute cannot have a setter without a getter (${this.describe()})`
);
}
this._setter = setter;
}
this._isSet = true;
return;
}
if (hasInitialValue) {
this.setValue(initialValue);
}
if (hasDefaultValue) {
this._default = defaultValue;
}
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Value type ===
_valueType!: ValueType;
/**
* Returns the type of values the attribute can store.
*
* @returns A [ValueType](https://layrjs.com/docs/v2/reference/value-type) instance.
*
* @example
* ```
* const title = Movie.prototype.getAttribute('title');
* title.getValueType(); // => A ValueType instance
* title.getValueType().toString(); // => 'string'
* title.getValueType().isOptional(); // => false
* ```
*
* @category Value Type
*/
getValueType() {
return this._valueType;
}
// === Value ===
_value?: unknown;
_isSet?: boolean;
/**
* Returns the current value of the attribute.
*
* @param [options.throwIfUnset] A boolean specifying whether the method should throw an error if the value is not set (default: `true`). If `false` is specified and the value is not set, the method returns `undefined`.
*
* @returns A value of the type handled by the attribute.
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.getValue(); // => 'Inception'
* title.unsetValue();
* title.getValue(); // => Error
* title.getValue({throwIfUnset: false}); // => undefined
* ```
*
* @category Value
*/
getValue(options: {throwIfUnset?: boolean; autoFork?: boolean} = {}) {
const {throwIfUnset = true, autoFork = true} = options;
if (!this.isSet()) {
if (throwIfUnset) {
throw new Error(`Cannot get the value of an unset attribute (${this.describe()})`);
}
return undefined;
}
if (this._getter !== undefined) {
return this._getter.call(this.getParent());
}
if (autoFork && !hasOwnProperty(this, '_value')) {
const parent = this.getParent();
const value = this._value;
const componentClass = isComponentInstance(value)
? ensureComponentClass(parent).getComponent(value.constructor.getComponentName())
: undefined;
let valueFork = fork(value, {componentClass});
if (canBeObserved(valueFork)) {
if (!isObservable(valueFork)) {
valueFork = createObservable(valueFork);
}
if (isEmbeddable(valueFork)) {
valueFork.addObserver(this);
}
}
this._value = valueFork;
}
return this._value;
}
_ignoreNextSetValueCall?: boolean;
/**
* Sets the value of the attribute. If the type of the value doesn't match the expected type, an error is thrown.
*
* When the attribute's value changes, the observers of the attribute are automatically executed, and the observers of the parent component are executed as well.
*
* @param value The value to be set.
* @param [options.source] A string specifying the [source of the value](https://layrjs.com/docs/v2/reference/attribute#value-source-type) (default: `'local'`).
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.setValue('Inception 2');
* title.setValue(123); // => Error
* ```
*
* @category Value
*/
setValue(value: unknown, {source = 'local'}: {source?: ValueSource} = {}) {
if (hasOwnProperty(this, '_ignoreNextSetValueCall')) {
delete this._ignoreNextSetValueCall;
return {previousValue: undefined, newValue: undefined};
}
if (this.isControlled() && !(source === 'server' || source === 'store')) {
throw new Error(
`Cannot set the value of a controlled attribute when the source is different than 'server' or 'store' (${this.describe()}, source: '${source}')`
);
}
this.checkValue(value);
value = this.sanitizeValue(value);
if (this._setter !== undefined) {
this._setter.call(this.getParent(), value);
return {previousValue: undefined, newValue: undefined};
}
if (this._getter !== undefined) {
throw new Error(
`Cannot set the value of an attribute that has a getter but no setter (${this.describe()})`
);
}
if (canBeObserved(value) && !isObservable(value)) {
value = createObservable(value);
}
const previousValue = this.getValue({throwIfUnset: false});
this._value = value;
this._isSet = true;
const valueHasChanged = (value as any)?.valueOf() !== (previousValue as any)?.valueOf();
if (valueHasChanged) {
if (isObservable(previousValue) && isEmbeddable(previousValue)) {
previousValue.removeObserver(this);
}
if (isObservable(value) && isEmbeddable(value)) {
value.addObserver(this);
}
}
if (valueHasChanged || source !== this._source) {
this.callObservers({source});
}
return {previousValue, newValue: value};
}
/**
* Unsets the value of the attribute. If the value is already unset, nothing happens.
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.isSet(); // => true
* title.unsetValue();
* title.isSet(); // => false
* ```
*
* @category Value
*/
unsetValue() {
if (this._getter !== undefined) {
throw new Error(
`Cannot unset the value of an attribute that has a getter (${this.describe()})`
);
}
if (this._isSet !== true) {
return {previousValue: undefined};
}
const previousValue = this.getValue({throwIfUnset: false});
this._value = undefined;
this._isSet = false;
if (isObservable(previousValue) && isEmbeddable(previousValue)) {
previousValue.removeObserver(this);
}
this.callObservers({source: 'local'});
return {previousValue};
}
/**
* Returns whether the value of the attribute is set or not.
*
* @returns A boolean.
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.isSet(); // => true
* title.unsetValue();
* title.isSet(); // => false
* ```
*
* @category Value
*/
isSet() {
return this._isSet === true;
}
checkValue(value: unknown) {
return this.getValueType().checkValue(value, this);
}
sanitizeValue(value: unknown) {
return this.getValueType().sanitizeValue(value);
}
// === Value source ===
_source: ValueSource = 'local';
/**
* Returns the source of the value of the attribute.
*
* @returns A [`ValueSource`](https://layrjs.com/docs/v2/reference/attribute#value-source-type) string.
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.getValueSource(); // => 'local' (the value was set locally)
* ```
*
* @category Value Source
*/
getValueSource() {
return this._source;
}
/**
* Sets the source of the value of the attribute.
*
* @param source A [`ValueSource`](https://layrjs.com/docs/v2/reference/attribute#value-source-type) string.
*
* @example
* ```
* const title = movie.getAttribute('title');
* title.setValueSource('local'); // The value was set locally
* title.setValueSource('server'); // The value came from an upper layer
* title.setValueSource('client'); // The value came from a lower layer
* ```
*
* @category Value Source
*/
setValueSource(source: ValueSource) {
if (source !== this._source) {
this._source = source;
this.callObservers({source});
}
}
/**
* @typedef ValueSource
*
* A string representing the source of a value.
*
* Currently, four types of sources are supported:
*
* * `'server'`: The value comes from an upper layer.
* * `'store'`: The value comes from a store.
* * `'local'`: The value comes from the current layer.
* * `'client'`: The value comes from a lower layer.
*
* @category Value Source
*/
// === Default value ===
_default?: unknown;
/**
* Returns the default value of the attribute as specified when the attribute was created.
*
* @returns A value or a function returning a value.
*
* @example
* ```
* const summary = movie.getAttribute('summary');
* summary.getDefault(); // => function () { return ''; }
* ```
*
* @category Default Value
*/
getDefault() {
return this._default;
}
/**
* Evaluate the default value of the attribute. If the default value is a function, the function is called (with the attribute's parent as `this` context), and the result is returned. Otherwise, the default value is returned as is.
*
* @returns A value of any type.
*
* @example
* ```
* const summary = movie.getAttribute('summary');
* summary.evaluateDefault(); // ''
* ```
*
* @category Default Value
*/
evaluateDefault() {
let value = this._default;
if (typeof value === 'function' && !isComponentClass(value)) {
value = value.call(this.getParent());
}
return value;
}
_isDefaultSetInConstructor?: boolean;
_fixDecoration() {
if (this._isDefaultSetInConstructor) {
this._ignoreNextSetValueCall = true;
}
}
// === 'isControlled' mark
_isControlled?: boolean;
isControlled() {
return this._isControlled === true;
}
markAsControlled() {
Object.defineProperty(this, '_isControlled', {value: true});
}
// === Observers ===
_onChange(payload: ObserverPayload & {source?: ValueSource}) {
const {source = 'local'} = payload;
if (source !== this._source) {
this._source = source;
}
this.getParent().callObservers(payload);
}
// === Attribute traversal ===
_traverseAttributes(iteratee: TraverseAttributesIteratee, options: TraverseAttributesOptions) {
const {setAttributesOnly} = options;
const value = setAttributesOnly ? this.getValue() : undefined;
this.getValueType()._traverseAttributes(iteratee, this, value, options);
}
// === Attribute selectors ===
_resolveAttributeSelector(
normalizedAttributeSelector: AttributeSelector,
options: ResolveAttributeSelectorOptions
) {
const {setAttributesOnly} = options;
const value = setAttributesOnly ? this.getValue() : undefined;
return this.getValueType()._resolveAttributeSelector(
normalizedAttributeSelector,
this,
value,
options
);
}
// === Serialization ===
serialize(options: SerializeOptions = {}): unknown {
if (!this.isSet()) {
throw new Error(`Cannot serialize an unset attribute (${this.describe()})`);
}
return this.getValueType().serializeValue(this.getValue(), this, options);
}
// === Deserialization ===
deserialize(
serializedValue: unknown,
options: DeserializeOptions = {}
): void | PromiseLike {
if (this.isSet()) {
const value = this.getValue();
if (value !== undefined && this.getValueType().canDeserializeInPlace(this)) {
return (value as any).deserialize(serializedValue, options);
}
}
const rootComponent = ensureComponentClass(this.getParent());
return possiblyAsync(
deserialize(serializedValue, {...options, rootComponent}),
(deserializedValue) => {
this.setValue(deserializedValue, {source: options.source});
}
);
}
// === Validation ===
/**
* Validates the value of the attribute. If the value doesn't pass the validation, an error is thrown. The error is a JavaScript `Error` instance with a `failedValidators` custom attribute which contains the result of the [`runValidators()`](https://layrjs.com/docs/v2/reference/attribute#run-validators-instance-method) method.
*
* @param [attributeSelector] In case the value of the attribute is a component, your can pass an [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the component's attributes to be validated (default: `true`, which means that all the component's attributes will be validated).
*
* @example
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title;
* }
*
* const movie = new Movie({title: 'Inception'});
* const title = movie.getAttribute('title');
*
* title.getValue(); // => 'Inception'
* title.validate(); // All good!
* title.setValue('');
* title.validate(); // => Error {failedValidators: [{validator: ..., path: ''}]}
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty} = validators;
*
* class Movie extends Component {
* ﹫attribute('string', {validators: [notEmpty()]}) title!: string;
* }
*
* const movie = new Movie({title: 'Inception'});
* const title = movie.getAttribute('title');
*
* title.getValue(); // => 'Inception'
* title.validate(); // All good!
* title.setValue('');
* title.validate(); // => Error {failedValidators: [{validator: ..., path: ''}]}
* ```
*
* @category Validation
*/
validate(attributeSelector: AttributeSelector = true) {
const failedValidators = this.runValidators(attributeSelector);
if (failedValidators.length === 0) {
return;
}
const details = failedValidators
.map(({validator, path}) => `${validator.getMessage()} (path: '${path}')`)
.join(', ');
let displayMessage: string | undefined;
for (const {validator} of failedValidators) {
const message = validator.getMessage({generateIfMissing: false});
if (message !== undefined) {
displayMessage = message;
break;
}
}
throwError(
`The following error(s) occurred while validating the attribute '${this.getName()}': ${details}`,
{displayMessage, failedValidators}
);
}
/**
* Returns whether the value of the attribute is valid.
*
* @param [attributeSelector] In case the value of the attribute is a component, your can pass an [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the component's attributes to be validated (default: `true`, which means that all the component's attributes will be validated).
*
* @returns A boolean.
*
* @example
* ```
* // See the `title` definition in the `validate()` example
*
* title.getValue(); // => 'Inception'
* title.isValid(); // => true
* title.setValue('');
* title.isValid(); // => false
* ```
*
* @category Validation
*/
isValid(attributeSelector: AttributeSelector = true) {
const failedValidators = this.runValidators(attributeSelector);
return failedValidators.length === 0;
}
/**
* Runs the validators with the value of the attribute.
*
* @param [attributeSelector] In case the value of the attribute is a component, your can pass an [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the component's attributes to be validated (default: `true`, which means that all the component's attributes will be validated).
*
* @returns An array containing the validators that have failed. Each item is a plain object composed of a `validator` (a [`Validator`](https://layrjs.com/docs/v2/reference/validator) instance) and a `path` (a string representing the path of the attribute containing the validator that has failed).
*
* @example
* ```
* // See the `title` definition in the `validate()` example
*
* title.getValue(); // => 'Inception'
* title.runValidators(); // => []
* title.setValue('');
* title.runValidators(); // => [{validator: ..., path: ''}]
* ```
*
* @category Validation
*/
runValidators(attributeSelector: AttributeSelector = true) {
if (!this.isSet()) {
throw new Error(`Cannot run the validators of an unset attribute (${this.describe()})`);
}
const failedValidators = this.getValueType().runValidators(this.getValue(), attributeSelector);
return failedValidators;
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Introspection ===
introspect() {
const introspectedAttribute = super.introspect() as IntrospectedAttribute;
if (introspectedAttribute === undefined) {
return undefined;
}
const exposure = this.getExposure();
const getIsExposed = exposure !== undefined ? hasOwnProperty(exposure, 'get') : false;
const setIsExposed = exposure !== undefined ? hasOwnProperty(exposure, 'set') : false;
if (getIsExposed && this.isSet()) {
introspectedAttribute.value = this.getValue();
}
if (setIsExposed) {
const defaultValue = this.getDefault();
if (defaultValue !== undefined) {
introspectedAttribute.default = defaultValue;
}
}
Object.assign(introspectedAttribute, this.getValueType().introspect());
return introspectedAttribute;
}
static unintrospect(introspectedAttribute: IntrospectedAttribute) {
const {
value: initialValue,
default: defaultValue,
valueType,
validators,
items,
...introspectedProperty
} = introspectedAttribute;
const hasInitialValue = 'value' in introspectedAttribute;
const hasDefaultValue = 'default' in introspectedAttribute;
const {name, options} = super.unintrospect(introspectedProperty) as UnintrospectedAttribute;
if (hasInitialValue) {
options.value = initialValue;
}
if (hasDefaultValue) {
options.default = defaultValue;
}
Object.assign(options, unintrospectValueType({valueType, validators, items}));
return {name, options};
}
// === Utilities ===
static isAttribute(value: any): value is Attribute {
return isAttributeInstance(value);
}
describeType() {
return 'attribute';
}
}
/**
* Returns whether the specified value is an `Attribute` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isAttributeClass(value: any): value is typeof Attribute {
return typeof value?.isAttribute === 'function';
}
/**
* Returns whether the specified value is an `Attribute` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isAttributeInstance(value: any): value is Attribute {
return isAttributeClass(value?.constructor) === true;
}
================================================
FILE: packages/component/src/properties/identifier-attribute.test.ts
================================================
import {Component} from '../component';
import {IdentifierAttribute, isIdentifierAttributeInstance} from './identifier-attribute';
import {isStringValueTypeInstance, isNumberValueTypeInstance} from './value-types';
describe('IdentifierAttribute', () => {
test('Creation', async () => {
class Movie extends Component {}
let idAttribute = new IdentifierAttribute('id', Movie.prototype);
expect(isIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie.prototype);
expect(isStringValueTypeInstance(idAttribute.getValueType())).toBe(true);
idAttribute = new IdentifierAttribute('id', Movie.prototype, {valueType: 'number'});
expect(isIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(() => new IdentifierAttribute('id', Movie.prototype, {valueType: 'boolean'})).toThrow(
"The type of an identifier attribute must be 'string' or 'number' (attribute: 'Movie.prototype.id', specified type: 'boolean')"
);
expect(() => new IdentifierAttribute('id', Movie.prototype, {valueType: 'string?'})).toThrow(
"The value of an identifier attribute cannot be optional (attribute: 'Movie.prototype.id', specified type: 'string?')"
);
});
});
================================================
FILE: packages/component/src/properties/identifier-attribute.ts
================================================
import {hasOwnProperty} from 'core-helpers';
import type {Component} from '../component';
import {Attribute, AttributeOptions, ValueSource} from './attribute';
import {isComponentInstance} from '../utilities';
export type IdentifierValue = string | number;
/**
* *Inherits from [`Attribute`](https://layrjs.com/docs/v2/reference/attribute).*
*
* A base class from which [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) and [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) are constructed. Unless you build a custom identifier attribute class, you probably won't have to use this class directly.
*/
export class IdentifierAttribute extends Attribute {
constructor(name: string, parent: Component, options: AttributeOptions = {}) {
if (!isComponentInstance(parent)) {
throw new Error(
`Cannot create a primary identifier attribute with a parent that is not a component instance (property: '${name}')`
);
}
super(name, parent, options);
}
getParent() {
return super.getParent() as Component;
}
// === Options ===
setOptions(options: AttributeOptions = {}) {
const {valueType = 'string'} = options;
if (valueType.endsWith('?')) {
throw new Error(
`The value of an identifier attribute cannot be optional (${this.describe()}, specified type: '${valueType}')`
);
}
if (valueType !== 'string' && valueType !== 'number') {
throw new Error(
`The type of an identifier attribute must be 'string' or 'number' (${this.describe()}, specified type: '${valueType}')`
);
}
super.setOptions({...options, valueType});
}
// === Value ===
getValue(options: {throwIfUnset?: boolean; autoFork?: boolean} = {}) {
return super.getValue(options) as IdentifierValue | undefined;
}
setValue(value: IdentifierValue, {source = 'local'}: {source?: ValueSource} = {}) {
if (hasOwnProperty(this, '_ignoreNextSetValueCall')) {
delete this._ignoreNextSetValueCall;
return {previousValue: undefined, newValue: undefined};
}
const {previousValue, newValue} = super.setValue(value, {source}) as {
previousValue: IdentifierValue | undefined;
newValue: IdentifierValue | undefined;
};
const parent = this.getParent();
const identityMap = parent.constructor.getIdentityMap();
identityMap.updateComponent(parent, this.getName(), {previousValue, newValue});
return {previousValue, newValue};
}
unsetValue() {
if (!this.isSet()) {
return {previousValue: undefined};
}
const {previousValue} = super.unsetValue() as {previousValue: IdentifierValue | undefined};
const parent = this.getParent();
const identityMap = parent.constructor.getIdentityMap();
identityMap.updateComponent(parent, this.getName(), {previousValue, newValue: undefined});
return {previousValue};
}
// === Utilities ===
static isIdentifierAttribute(value: any): value is IdentifierAttribute {
return isIdentifierAttributeInstance(value);
}
}
/**
* Returns whether the specified value is an `IdentifierAttribute` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isIdentifierAttributeClass(value: any): value is typeof IdentifierAttribute {
return typeof value?.isIdentifierAttribute === 'function';
}
/**
* Returns whether the specified value is an `IdentifierAttribute` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isIdentifierAttributeInstance(value: any): value is IdentifierAttribute {
return isIdentifierAttributeClass(value?.constructor) === true;
}
================================================
FILE: packages/component/src/properties/index.ts
================================================
export * from './attribute';
export * from './attribute-selector';
export * from './identifier-attribute';
export * from './method';
export * from './primary-identifier-attribute';
export * from './property';
export * from './secondary-identifier-attribute';
export * from './value-types';
================================================
FILE: packages/component/src/properties/method.test.ts
================================================
import {MINUTE, HOUR, DAY} from '@layr/utilities';
import {Component} from '../component';
import {Method} from './method';
describe('Method', () => {
test('Creation', async () => {
class Movie extends Component {}
const method = new Method('find', Movie);
expect(Method.isMethod(method)).toBe(true);
expect(method.getName()).toBe('find');
expect(method.getParent()).toBe(Movie);
});
test('Scheduling', async () => {
class Movie extends Component {}
const findMethod = new Method('find', Movie);
expect(findMethod.getScheduling()).toBe(undefined);
const updateRatingsMethod = new Method('updateRatings', Movie, {schedule: {rate: 1 * HOUR}});
expect(updateRatingsMethod.getScheduling()).toEqual({rate: 1 * HOUR});
expect(() => {
new Method('play', Movie.prototype, {schedule: {rate: 1 * DAY}});
}).toThrow("Only static methods can be scheduled (method: 'Movie.prototype.play')");
});
test('Queueing', async () => {
class Movie extends Component {}
const findMethod = new Method('find', Movie);
expect(findMethod.getQueueing()).toBe(undefined);
const updateRatingsMethod = new Method('updateRatings', Movie, {queue: true});
expect(updateRatingsMethod.getQueueing()).toBe(true);
const generateThumbnailMethod = new Method('generateThumbnail', Movie.prototype, {queue: true});
expect(generateThumbnailMethod.getQueueing()).toBe(true);
});
test('Maximum duration', async () => {
class Movie extends Component {}
const findMethod = new Method('find', Movie);
expect(findMethod.getMaximumDuration()).toBe(undefined);
const updateRatingsMethod = new Method('updateRatings', Movie, {
queue: true,
maximumDuration: 1 * MINUTE
});
expect(updateRatingsMethod.getMaximumDuration()).toBe(1 * MINUTE);
});
});
================================================
FILE: packages/component/src/properties/method.ts
================================================
import type {Component} from '../component';
import {Property, PropertyOptions, IntrospectedProperty} from './property';
import {isComponentClass} from '../utilities';
export type IntrospectedMethod = IntrospectedProperty;
export type MethodOptions = PropertyOptions & {
schedule?: MethodScheduling;
queue?: MethodQueueing;
maximumDuration?: number;
};
export type MethodScheduling = {rate: number} | false;
export type MethodQueueing = boolean;
/**
* *Inherits from [`Property`](https://layrjs.com/docs/v2/reference/property).*
*
* A `Method` represents a method of a [Component](https://layrjs.com/docs/v2/reference/component) class, prototype, or instance. It plays the role of a regular JavaScript method, but brings some extra features such as remote invocation, scheduled execution, or queuing.
*
* #### Usage
*
* Typically, you define a `Method` using the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator.
*
* For example, here is how you would define a `Movie` class with some methods:
*
* ```
* import {Component, method} from '﹫layr/component';
*
* class Movie extends Component {
* // Class method
* ﹫method() static getConfig() {
* // ...
* }
*
* // Instance method
* ﹫method() play() {
* // ...
* }
* }
* ```
*
* Then you can call a method like you would normally do with regular JavaScript:
*
* ```
* Movie.getConfig();
*
* const movie = new Movie({title: 'Inception'});
* movie.play();
* ```
*
* So far, you may wonder what is the point of defining methods this way. By itself the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator, except for creating a `Method` instance under the hood, doesn't provide much benefit.
*
* The trick is that since you have a `Method`, you also have a [`Property`](https://layrjs.com/docs/v2/reference/property) (because `Method` inherits from `Property`), and properties can be exposed to remote access thanks to the [`@expose()`](https://layrjs.com/docs/v2/reference/component#expose-decorator) decorator.
*
* So here is how you would expose the `Movie` methods:
*
* ```
* import {Component, method} from '﹫layr/component';
*
* class Movie extends Component {
* // Exposed class method
* ﹫expose({call: true}) ﹫method() static getConfig() {
* // ...
* }
*
* // Exposed instance method
* ﹫expose({call: true}) ﹫method() play() {
* // ...
* }
* }
* ```
*
* Now that you have some exposed methods, you can call them remotely in the same way you would do locally:
*
* ```
* Movie.getConfig(); // Executed remotely
*
* const movie = new Movie({title: 'Inception'});
* movie.play(); // Executed remotely
* ```
*
* In addition, you can easily take advantage of some powerful features offered by [`Methods`](https://layrjs.com/docs/v2/reference/method). For example, here is how you would define a method that will be automatically executed every hour:
*
* ```
* class Application extends Component {
* ﹫method({schedule: {rate: 60 * 60 * 1000}}) static async runHourlyTask() {
* // Do something every hour...
* }
* }
* ```
*
* And here is how you would define a method that will be executed in background with a maximum duration of 5 minutes:
*
* ```
* class Email extends Component {
* ﹫method({queue: true, maximumDuration: 5 * 60 * 1000}) async send() {
* // Do something in background
* }
* }
* ```
*/
export class Method extends Property {
_methodBrand!: void;
/**
* Creates an instance of [`Method`](https://layrjs.com/docs/v2/reference/method). Typically, instead of using this constructor, you would rather use the [`@method()`](https://layrjs.com/docs/v2/reference/component#method-decorator) decorator.
*
* @param name The name of the method.
* @param parent The component class, prototype, or instance that owns the method.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the method should be exposed to remote calls.
* @param [options.schedule] A [`MethodScheduling`](https://layrjs.com/docs/v2/reference/method#method-scheduling-type) object specifying how the method should be scheduled for automatic execution. Note that only static methods can be scheduled.
* @param [options.queue] A boolean specifying whether the method should be executed in background.
* @param [options.maximumDuration] A number specifying the maximum duration of the method in milliseconds. Note that the actual duration of the method execution is not currently enforced. The purpose of this option is to help configuring the deployment of serverless functions. For example, in case of deployment to [AWS Lambda](https://aws.amazon.com/lambda/), this option will affect the `timeout` property of the generated Lambda function.
*
* @returns The [`Method`](https://layrjs.com/docs/v2/reference/method) instance that was created.
*
* @example
* ```
* import {Component, Method} from '﹫layr/component';
*
* class Movie extends Component {}
*
* const play = new Method('play', Movie.prototype, {exposure: {call: true}});
*
* play.getName(); // => 'play'
* play.getParent(); // => Movie.prototype
* play.getExposure(); // => {call: true}
* ```
*
* @category Creation
*/
constructor(name: string, parent: typeof Component | Component, options: MethodOptions = {}) {
super(name, parent, options);
}
// === Options ===
setOptions(options: MethodOptions = {}) {
const {schedule, queue, maximumDuration, ...otherOptions} = options;
super.setOptions(otherOptions);
if (schedule !== undefined) {
this.setScheduling(schedule);
}
if (queue !== undefined) {
this.setQueueing(queue);
}
if (maximumDuration !== undefined) {
this.setMaximumDuration(maximumDuration);
}
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Methods
*/
// === Scheduling ===
_scheduling?: MethodScheduling;
/**
* If the method is scheduled for automatic execution, returns a [`MethodScheduling`](https://layrjs.com/docs/v2/reference/method#method-scheduling-type) object. Otherwise, returns `undefined`.
*
* @returns A [`MethodScheduling`](https://layrjs.com/docs/v2/reference/method#method-scheduling-type) object or `undefined`.
*
* @example
* ```
* runHourlyTaskMethod.getScheduling(); // => {rate: 60 * 60 * 1000}
* regularMethod.getScheduling(); // => undefined
* ```
*
* @category Scheduling
*/
getScheduling() {
return this._scheduling;
}
/**
* Sets how the method should be scheduled for automatic execution. Note that only static methods can be scheduled.
*
* @param scheduling A [`MethodScheduling`](https://layrjs.com/docs/v2/reference/method#method-scheduling-type) object.
*
* @example
* ```
* runHourlyTaskMethod.setScheduling({rate: 60 * 60 * 1000});
* ```
*
* @category Scheduling
*/
setScheduling(scheduling: MethodScheduling | undefined) {
if (!isComponentClass(this.getParent())) {
throw new Error(`Only static methods can be scheduled (${this.describe()})`);
}
this._scheduling = scheduling;
}
/**
* @typedef MethodScheduling
*
* A `MethodScheduling` is a plain object specifying how a method is scheduled for automatic execution. The shape of the object is `{rate: number}` where `rate` is the execution frequency expressed in milliseconds.
*
* @example
* ```
* {rate: 60 * 1000} // Every minute
* {rate: 60 * 60 * 1000} // Every hour
* {rate: 24 * 60 * 60 * 1000} // Every day
* ```
*
* @category Scheduling
*/
// === Queueing ===
_queueing?: MethodQueueing;
/**
* Returns `true` if the method should be executed in background. Otherwise, returns `undefined`.
*
* @returns A boolean or `undefined`.
*
* @example
* ```
* backgroundMethod.getQueueing(); // => true
* regularMethod.getQueueing(); // => undefined
* ```
*
* @category Queueing
*/
getQueueing() {
return this._queueing;
}
/**
* Sets whether the method should be executed in background.
*
* @param queueing Pass `true` to specify that the method should be executed in background. Otherwise, you can pass `false` or `undefined`.
*
* @example
* ```
* backgroundMethod.setQueueing(true);
* ```
*
* @category Queueing
*/
setQueueing(queueing: MethodQueueing | undefined) {
this._queueing = queueing;
}
// === Maximum Duration ===
_maximumDuration?: number;
/**
* Returns a number representing the maximum duration of the method in milliseconds or `undefined` if the method has no maximum duration.
*
* @returns A number or `undefined`.
*
* @example
* ```
* backgroundMethod.getMaximumDuration(); // => 5 * 60 * 1000 (5 minutes)
* regularMethod.getMaximumDuration(); // => undefined
* ```
*
* @category Maximum Duration
*/
getMaximumDuration() {
return this._maximumDuration;
}
/**
* Sets the maximum duration of the method in milliseconds. Alternatively, you can pass `undefined` to indicate that the method has no maximum duration.
*
* @param maximumDuration A number or `undefined`.
*
* @example
* ```
* backgroundMethod.setMaximumDuration(5 * 60 * 1000); // 5 minutes
* ```
*
* @category Maximum Duration
*/
setMaximumDuration(maximumDuration: number | undefined) {
this._maximumDuration = maximumDuration;
}
// === Utilities ===
static isMethod(value: any): value is Method {
return isMethodInstance(value);
}
describeType() {
return 'method';
}
}
/**
* Returns whether the specified value is a `Method` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isMethodClass(value: any): value is typeof Method {
return typeof value?.isMethod === 'function';
}
/**
* Returns whether the specified value is a `Method` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isMethodInstance(value: any): value is Method {
return isMethodClass(value?.constructor) === true;
}
================================================
FILE: packages/component/src/properties/primary-identifier-attribute.test.ts
================================================
import {Component} from '../component';
import {
PrimaryIdentifierAttribute,
isPrimaryIdentifierAttributeInstance,
primaryIdentifierAttributeStringDefaultValue
} from './primary-identifier-attribute';
import {isStringValueTypeInstance, isNumberValueTypeInstance} from './value-types';
describe('PrimaryIdentifierAttribute', () => {
test('Creation', async () => {
class Movie extends Component {}
let idAttribute = new PrimaryIdentifierAttribute('id', Movie.prototype);
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie.prototype);
expect(isStringValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(idAttribute.getDefault()).toBe(primaryIdentifierAttributeStringDefaultValue);
idAttribute = new PrimaryIdentifierAttribute('id', Movie.prototype, {valueType: 'number'});
expect(isPrimaryIdentifierAttributeInstance(idAttribute)).toBe(true);
expect(idAttribute.getName()).toBe('id');
expect(idAttribute.getParent()).toBe(Movie.prototype);
expect(isNumberValueTypeInstance(idAttribute.getValueType())).toBe(true);
expect(idAttribute.getDefault()).toBeUndefined();
});
test('Value', async () => {
class Movie extends Component {}
const idAttribute = new PrimaryIdentifierAttribute('id', Movie.prototype);
expect(idAttribute.isSet()).toBe(false);
idAttribute.setValue('abc123');
expect(idAttribute.getValue()).toBe('abc123');
idAttribute.setValue('abc123');
expect(idAttribute.getValue()).toBe('abc123');
expect(() => idAttribute.setValue('xyz789')).toThrow(
"The value of a primary identifier attribute cannot be modified (attribute: 'Movie.prototype.id')"
);
});
test('Default value', async () => {
class Movie extends Component {}
const idAttribute = new PrimaryIdentifierAttribute('id', Movie.prototype);
const id = idAttribute.evaluateDefault() as string;
expect(typeof id).toBe('string');
expect(id.length >= 25).toBe(true);
});
test('Introspection', async () => {
class Movie extends Component {}
expect(
new PrimaryIdentifierAttribute('id', Movie.prototype, {
exposure: {get: true, set: true}
}).introspect()
).toStrictEqual({
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'string',
default: primaryIdentifierAttributeStringDefaultValue,
exposure: {get: true, set: true}
});
expect(
new PrimaryIdentifierAttribute('id', Movie.prototype, {
valueType: 'number',
exposure: {get: true, set: true}
}).introspect()
).toStrictEqual({
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'number',
exposure: {get: true, set: true}
});
});
test('Unintrospection', async () => {
expect(
PrimaryIdentifierAttribute.unintrospect({
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'string',
default: primaryIdentifierAttributeStringDefaultValue,
exposure: {get: true}
})
).toEqual({
name: 'id',
options: {
valueType: 'string',
default: primaryIdentifierAttributeStringDefaultValue,
exposure: {get: true}
}
});
expect(
PrimaryIdentifierAttribute.unintrospect({
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'number',
exposure: {get: true}
})
).toEqual({
name: 'id',
options: {
valueType: 'number',
exposure: {get: true}
}
});
});
});
================================================
FILE: packages/component/src/properties/primary-identifier-attribute.ts
================================================
import {hasOwnProperty} from 'core-helpers';
import type {Component} from '../component';
import type {AttributeOptions, ValueSource} from './attribute';
import {IdentifierAttribute, IdentifierValue} from './identifier-attribute';
import {isComponentInstance, ensureComponentClass} from '../utilities';
/**
* *Inherits from [`IdentifierAttribute`](https://layrjs.com/docs/v2/reference/identifier-attribute).*
*
* A `PrimaryIdentifierAttribute` is a special kind of attribute that uniquely identify a [Component](https://layrjs.com/docs/v2/reference/component) instance.
*
* A `Component` can have only one `PrimaryIdentifierAttribute`. To define a `Component` with more than one identifier, you can add some [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) in addition to the `PrimaryIdentifierAttribute`.
*
* Another characteristic of a `PrimaryIdentifierAttribute` is that its value is immutable (i.e., once set it cannot change). This ensures a stable identity of the components across the different layers of an app (e.g., frontend, backend, and database).
*
* When a `Component` has a `PrimaryIdentifierAttribute`, its instances are managed by an [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map) ensuring that there can only be one instance with a specific identifier.
*
* #### Usage
*
* Typically, you create a `PrimaryIdentifierAttribute` and associate it to a component prototype using the [`@primaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#primary-identifier-decorator) decorator.
*
* For example, here is how you would define a `Movie` class with an `id` primary identifier attribute:
*
* ```
* // JS
*
* import {Component, primaryIdentifier, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* // An auto-generated 'string' primary identifier attribute
* ﹫primaryIdentifier() id;
*
* // A regular attribute
* ﹫attribute('string') title;
* }
* ```
*
* ```
* // TS
*
* import {Component, primaryIdentifier, attribute} from '﹫layr/component';
*
* class Movie extends Component {
* // An auto-generated 'string' primary identifier attribute
* ﹫primaryIdentifier() id!: string;
*
* // A regular attribute
* ﹫attribute('string') title!: string;
* }
* ```
*
* Then, to create a `Movie` instance, you would do something like:
*
* ```
* const movie = new Movie({title: 'Inception'});
*
* movie.id; // => 'ck41vli1z00013h5xx1esffyn'
* movie.title; // => 'Inception'
* ```
*
* Note that we didn't have to specify a value for the `id` attribute; it was automatically generated (using the [`Component.generateId()`](https://layrjs.com/docs/v2/reference/component#generate-id-class-method) method under the hood).
*
* To create a `Movie` instance with an `id` of your choice, just do:
*
* ```
* const movie = new Movie({id: 'abc123', title: 'Inception'});
*
* movie.id; // => 'abc123'
* movie.title; // => 'Inception'
* ```
*
* As mentioned previously, when a component has a primary identifier attribute, all its instances are managed by an [`IdentityMap`](https://layrjs.com/docs/v2/reference/identity-map) ensuring that there is only one instance with a specific identifier.
*
* So, since we previously created a `Movie` with `'abc123'` as primary identifier, we cannot create another `Movie` with the same primary identifier:
*
* ```
* new Movie({id: 'abc123', title: 'Inception 2'}); // => Error
* ```
*
* `PrimaryIdentifierAttribute` values are usually of type `'string'` (the default), but you can also have values of type `'number'`:
*
* ```
* // JS
*
* import {Component, primaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // An auto-generated 'number' primary identifier attribute
* ﹫primaryIdentifier('number') id = Math.random();
* }
* ```
*
* ```
* // TS
*
* import {Component, primaryIdentifier} from '﹫layr/component';
*
* class Movie extends Component {
* // An auto-generated 'number' primary identifier attribute
* ﹫primaryIdentifier('number') id = Math.random();
* }
* ```
*/
export class PrimaryIdentifierAttribute extends IdentifierAttribute {
/**
* Creates an instance of [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute). Typically, instead of using this constructor, you would rather use the [`@primaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#primary-identifier-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The component prototype that owns the attribute.
* @param [options.valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options.default] A function returning the default value of the attribute (default when `valueType` is `'string'`: `function () { return this.constructor.generateId() }`).
* @param [options.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the value of the attribute.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the attribute should be exposed to remote access.
*
* @returns The [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) instance that was created.
*
* @example
* ```
* import {Component, PrimaryIdentifierAttribute} from '﹫layr/component';
*
* class Movie extends Component {}
*
* const id = new PrimaryIdentifierAttribute('id', Movie.prototype);
*
* id.getName(); // => 'id'
* id.getParent(); // => Movie.prototype
* id.getValueType().toString(); // => 'string'
* id.getDefaultValue(); // => function () { return this.constructor.generateId() }`
* ```
*
* @category Creation
*/
constructor(name: string, parent: Component, options: AttributeOptions = {}) {
if (
isComponentInstance(parent) &&
parent.hasPrimaryIdentifierAttribute() &&
parent.getPrimaryIdentifierAttribute().getName() !== name
) {
throw new Error(
`The component '${ensureComponentClass(
parent
).getComponentName()}' already has a primary identifier attribute`
);
}
super(name, parent, options);
}
// === Options ===
setOptions(options: AttributeOptions = {}) {
let {valueType = 'string', default: defaultValue} = options;
if (valueType === 'string' && defaultValue === undefined) {
defaultValue = primaryIdentifierAttributeStringDefaultValue;
}
super.setOptions({...options, default: defaultValue});
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Attribute Methods ===
/**
* See the methods that are inherited from the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#value-type) class.
*
* @category Attribute Methods
*/
// === Value ===
setValue(value: IdentifierValue, {source = 'local'}: {source?: ValueSource} = {}) {
if (hasOwnProperty(this, '_ignoreNextSetValueCall')) {
delete this._ignoreNextSetValueCall;
return {previousValue: undefined, newValue: undefined};
}
const previousValue = this.getValue({throwIfUnset: false, autoFork: false});
if (previousValue !== undefined && value !== previousValue) {
throw new Error(
`The value of a primary identifier attribute cannot be modified (${this.describe()})`
);
}
return super.setValue(value, {source});
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Utilities ===
static isPrimaryIdentifierAttribute(value: any): value is PrimaryIdentifierAttribute {
return isPrimaryIdentifierAttributeInstance(value);
}
}
/**
* Returns whether the specified value is a `PrimaryIdentifierAttribute` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isPrimaryIdentifierAttributeClass(
value: any
): value is typeof PrimaryIdentifierAttribute {
return typeof value?.isPrimaryIdentifierAttribute === 'function';
}
/**
* Returns whether the specified value is a `PrimaryIdentifierAttribute` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isPrimaryIdentifierAttributeInstance(
value: any
): value is PrimaryIdentifierAttribute {
return isPrimaryIdentifierAttributeClass(value?.constructor) === true;
}
export const primaryIdentifierAttributeStringDefaultValue = (function () {
// Makes the function anonymous to make it a bit lighter when serialized
return function (this: Component) {
return this.constructor.generateId();
};
})();
================================================
FILE: packages/component/src/properties/property.test.ts
================================================
import {Component} from '../component';
import {Property, PropertyOperationSetting} from './property';
describe('Property', () => {
test('Creation', async () => {
class Movie extends Component {}
// @ts-expect-error
expect(() => new Property()).toThrow(
"Expected a string, but received a value of type 'undefined'"
);
// @ts-expect-error
expect(() => new Property('title')).toThrow(
"Expected a component class or instance, but received a value of type 'undefined'"
);
// @ts-expect-error
expect(() => new Property('title', {})).toThrow(
"Expected a component class or instance, but received a value of type 'object'"
);
// @ts-expect-error
expect(() => new Property('title', Movie, {unknownOption: 123})).toThrow(
"Did not expect the option 'unknownOption' to exist"
);
const property = new Property('title', Movie.prototype);
expect(Property.isProperty(property)).toBe(true);
expect(property.getName()).toBe('title');
expect(property.getParent()).toBe(Movie.prototype);
});
test('Exposure', async () => {
class Movie extends Component {}
expect(new Property('find', Movie).getExposure()).toBeUndefined();
expect(new Property('find', Movie, {exposure: {}}).getExposure()).toBeUndefined();
expect(
new Property('find', Movie, {exposure: {call: undefined}}).getExposure()
).toBeUndefined();
expect(new Property('find', Movie, {exposure: {call: true}}).getExposure()).toStrictEqual({
call: true
});
expect(() => new Property('find', Movie, {exposure: {call: false}})).toThrow(
'The specified property operation setting (false) is invalid'
);
expect(() => new Property('find', Movie, {exposure: {call: 'admin'}})).toThrow(
'The specified property operation setting ("admin") is invalid'
);
class Film extends Component {
static normalizePropertyOperationSetting(
setting: PropertyOperationSetting,
{throwIfInvalid = true} = {}
) {
const normalizedSetting = super.normalizePropertyOperationSetting(setting, {
throwIfInvalid: false
});
if (normalizedSetting !== undefined) {
return normalizedSetting;
}
if (typeof setting === 'string') {
return setting;
}
if (throwIfInvalid) {
throw new Error(
`The specified property operation setting (${JSON.stringify(setting)}) is invalid`
);
}
return undefined;
}
}
expect(new Property('find', Film, {exposure: {call: true}}).getExposure()).toStrictEqual({
call: true
});
expect(new Property('find', Film, {exposure: {call: 'admin'}}).getExposure()).toStrictEqual({
call: 'admin'
});
expect(() => new Property('find', Film, {exposure: {call: false}})).toThrow(
'The specified property operation setting (false) is invalid'
);
});
test('Forking', async () => {
class Movie extends Component {}
const property = new Property('title', Movie.prototype);
expect(property.getName()).toBe('title');
expect(property.getParent()).toBe(Movie.prototype);
const movie = Object.create(Movie.prototype);
const propertyFork = property.fork(movie);
expect(propertyFork.getName()).toBe('title');
expect(propertyFork.getParent()).toBe(movie);
});
test('Introspection', async () => {
class Movie extends Component {
static normalizePropertyOperationSetting(
setting: PropertyOperationSetting,
{throwIfInvalid = true} = {}
) {
const normalizedSetting = super.normalizePropertyOperationSetting(setting, {
throwIfInvalid: false
});
if (normalizedSetting !== undefined) {
return normalizedSetting;
}
if (typeof setting === 'string') {
return setting;
}
if (throwIfInvalid) {
throw new Error(
`The specified property operation setting (${JSON.stringify(setting)}) is invalid`
);
}
return undefined;
}
}
expect(new Property('title', Movie.prototype).introspect()).toBeUndefined();
expect(
new Property('title', Movie.prototype, {exposure: {get: true}}).introspect()
).toStrictEqual({name: 'title', type: 'Property', exposure: {get: true}});
expect(
new Property('title', Movie.prototype, {exposure: {get: true, set: 'admin'}}).introspect()
).toStrictEqual({name: 'title', type: 'Property', exposure: {get: true, set: true}});
});
test('Unintrospection', async () => {
expect(
Property.unintrospect({name: 'title', type: 'Property', exposure: {get: true, set: true}})
).toStrictEqual({
name: 'title',
options: {exposure: {get: true, set: true}}
});
});
});
================================================
FILE: packages/component/src/properties/property.ts
================================================
import {possiblyAsync} from 'possibly-async';
import {assertIsString, assertNoUnknownOptions, PlainObject, getTypeOf} from 'core-helpers';
import mapValues from 'lodash/mapValues';
import type {Component} from '../component';
import {ensureComponentClass, assertIsComponentClassOrInstance} from '../utilities';
export type PropertyOptions = {
exposure?: PropertyExposure;
};
export type PropertyExposure = Partial>;
export type PropertyOperation = 'get' | 'set' | 'call';
export type PropertyOperationSetting = boolean | string | string[];
export type PropertyFilter = (property: any) => boolean | PromiseLike;
export type PropertyFilterSync = (property: any) => boolean;
export type PropertyFilterAsync = (property: any) => PromiseLike;
export type IntrospectedProperty = {
name: string;
type: string;
exposure?: IntrospectedExposure;
};
export type IntrospectedExposure = Partial>;
export type UnintrospectedProperty = {
name: string;
options: {
exposure?: UnintrospectedExposure;
};
};
export type UnintrospectedExposure = Partial>;
/**
* A base class from which classes such as [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) or [`Method`](https://layrjs.com/docs/v2/reference/method) are constructed. Unless you build a custom property class, you probably won't have to use this class directly.
*/
export class Property {
_name: string;
_parent: typeof Component | Component;
/**
* Creates an instance of [`Property`](https://layrjs.com/docs/v2/reference/property).
*
* @param name The name of the property.
* @param parent The component class, prototype, or instance that owns the property.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the property should be exposed to remote access.
*
* @returns The [`Property`](https://layrjs.com/docs/v2/reference/property) instance that was created.
*
* @example
* ```
* import {Component, Property} from '﹫layr/component';
*
* class Movie extends Component {}
*
* const titleProperty = new Property('title', Movie.prototype);
*
* titleProperty.getName(); // => 'title'
* titleProperty.getParent(); // => Movie.prototype
* ```
*
* @category Creation
*/
constructor(name: string, parent: typeof Component | Component, options: PropertyOptions = {}) {
assertIsString(name);
assertIsComponentClassOrInstance(parent);
this._name = name;
this._parent = parent;
this.setOptions(options);
this._initialize();
}
_initialize() {}
/**
* Returns the name of the property.
*
* @returns A string.
*
* @example
* ```
* titleProperty.getName(); // => 'title'
* ```
*
* @category Basic Methods
*/
getName() {
return this._name;
}
/**
* Returns the parent of the property.
*
* @returns A component class, prototype, or instance.
*
* @example
* ```
* titleProperty.getParent(); // => Movie.prototype
* ```
*
* @category Basic Methods
*/
getParent() {
return this._parent;
}
// === Options ===
setOptions(options: PropertyOptions = {}) {
const {exposure, ...unknownOptions} = options;
assertNoUnknownOptions(unknownOptions);
if (exposure !== undefined) {
this.setExposure(exposure);
}
}
// === Exposure ===
_exposure?: PropertyExposure;
/**
* Returns an object specifying how the property is exposed to remote access.
*
* @returns A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object.
*
* @example
* ```
* titleProperty.getExposure(); // => {get: true, set: true}
* ```
*
* @category Exposure
*/
getExposure() {
return this._exposure;
}
/**
* Sets how the property is exposed to remote access.
*
* @param [exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object.
*
* @example
* ```
* titleProperty.setExposure({get: true, set: true});
* ```
*
* @category Exposure
*/
setExposure(exposure = {}) {
this._exposure = this._normalizeExposure(exposure);
}
_normalizeExposure(exposure: PropertyExposure) {
let normalizedExposure: PlainObject | undefined;
for (const [operation, setting] of Object.entries(exposure)) {
if (setting === undefined) {
continue;
}
const normalizedSetting = ensureComponentClass(
this._parent
).normalizePropertyOperationSetting(setting);
if (normalizedSetting === undefined) {
continue;
}
if (normalizedExposure === undefined) {
normalizedExposure = {};
}
normalizedExposure[operation] = normalizedSetting;
}
return normalizedExposure as PropertyExposure | undefined;
}
/**
* Returns whether an operation is allowed on the property.
*
* @param operation A string representing an operation. Currently supported operations are 'get', 'set', and 'call'.
*
* @returns A boolean.
*
* @example
* ```
* titleProperty.operationIsAllowed('get'); // => true
* titleProperty.operationIsAllowed('call'); // => false
* ```
*
* @category Exposure
* @possiblyasync
*/
operationIsAllowed(operation: PropertyOperation) {
const setting = this._exposure?.[operation];
if (setting === undefined) {
return false;
}
return possiblyAsync(
this._parent.resolvePropertyOperationSetting(setting),
(resolvedSetting) => resolvedSetting === true
);
}
/**
* @typedef PropertyExposure
*
* A `PropertyExposure` is a plain object specifying how a property is exposed to remote access.
*
* The shape of the object is `{[operation]: permission}` where:
*
* - `operation` is a string representing the different types of operations (`'get'` and `'set'` for attributes, and `'call'` for methods).
* - `permission` is a boolean (or a string or array of strings if the [`WithRoles`](https://layrjs.com/docs/v2/reference/with-roles) mixin is used) specifying whether the operation is allowed or not.
*
* @example
* ```
* {get: true, set: true}
* {get: 'anyone', set: ['author', 'admin']}
* {call: true}
* {call: 'admin'}
* ```
*
* @category Exposure
*/
// === Forking ===
fork(this: T, parent: typeof Component | Component) {
const propertyFork = Object.create(this) as T;
propertyFork._parent = parent;
propertyFork._initialize();
return propertyFork;
}
// === Introspection ===
introspect() {
const introspectedExposure = this.introspectExposure();
if (introspectedExposure === undefined) {
return undefined;
}
return {
name: this.getName(),
type: getTypeOf(this),
exposure: introspectedExposure
} as IntrospectedProperty;
}
introspectExposure() {
const exposure = this.getExposure();
if (exposure === undefined) {
return undefined;
}
// We don't want to expose backend operation settings to the frontend
// So if there is a {call: 'admin'} exposure, we want to return {call: true}
const introspectedExposure = mapValues(exposure, () => true);
return introspectedExposure as IntrospectedExposure;
}
static unintrospect(introspectedProperty: IntrospectedProperty) {
const {name, type: _type, ...options} = introspectedProperty;
return {name, options} as UnintrospectedProperty;
}
// === Utilities ===
static isProperty(value: any): value is Property {
return isPropertyInstance(value);
}
describeType() {
return 'property';
}
describe() {
return `${this.describeType()}: '${this.getParent().describeComponentProperty(
this.getName()
)}'`;
}
}
export function isPropertyClass(value: any): value is typeof Property {
return typeof value?.isProperty === 'function';
}
export function isPropertyInstance(value: any): value is Property {
return isPropertyClass(value?.constructor) === true;
}
================================================
FILE: packages/component/src/properties/secondary-identifier-attribute.test.ts
================================================
import {Component} from '../component';
import {
SecondaryIdentifierAttribute,
isSecondaryIdentifierAttributeInstance
} from './secondary-identifier-attribute';
import {isStringValueTypeInstance} from './value-types';
describe('SecondaryIdentifierAttribute', () => {
test('Creation', async () => {
class Movie extends Component {}
const emailAttribute = new SecondaryIdentifierAttribute('email', Movie.prototype);
expect(isSecondaryIdentifierAttributeInstance(emailAttribute)).toBe(true);
expect(emailAttribute.getName()).toBe('email');
expect(emailAttribute.getParent()).toBe(Movie.prototype);
expect(isStringValueTypeInstance(emailAttribute.getValueType())).toBe(true);
});
test('Value', async () => {
class Movie extends Component {}
const emailAttribute = new SecondaryIdentifierAttribute('email', Movie.prototype);
expect(emailAttribute.isSet()).toBe(false);
emailAttribute.setValue('hi@hello.com');
expect(emailAttribute.getValue()).toBe('hi@hello.com');
// Contrary to a primary identifier attribute, the value of a secondary identifier
// attribute can be modified
emailAttribute.setValue('salut@bonjour.com');
expect(emailAttribute.getValue()).toBe('salut@bonjour.com');
});
test('Introspection', async () => {
class Movie extends Component {}
expect(
new SecondaryIdentifierAttribute('email', Movie.prototype, {
valueType: 'string',
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'email',
type: 'SecondaryIdentifierAttribute',
valueType: 'string',
exposure: {get: true}
});
});
test('Unintrospection', async () => {
expect(
SecondaryIdentifierAttribute.unintrospect({
name: 'email',
type: 'SecondaryIdentifierAttribute',
valueType: 'string',
exposure: {get: true}
})
).toEqual({
name: 'email',
options: {valueType: 'string', exposure: {get: true}}
});
});
});
================================================
FILE: packages/component/src/properties/secondary-identifier-attribute.ts
================================================
import type {Component} from '../component';
import type {AttributeOptions} from './attribute';
import {IdentifierAttribute} from './identifier-attribute';
/**
* *Inherits from [`IdentifierAttribute`](https://layrjs.com/docs/v2/reference/identifier-attribute).*
*
* A `SecondaryIdentifierAttribute` is a special kind of attribute that uniquely identify a [Component](https://layrjs.com/docs/v2/reference/component) instance.
*
* Contrary to a [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute), you can define more than one `SecondaryIdentifierAttribute` in a `Component`.
*
* Another difference with a `PrimaryIdentifierAttribute` is that a `SecondaryIdentifierAttribute` value is mutable (i.e., it can change over time).
*
* #### Usage
*
* Typically, you create a `SecondaryIdentifierAttribute` and associate it to a component prototype using the [`@secondaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#secondary-identifier-decorator) decorator.
*
* A common use case is a `User` component with an immutable primary identifier and a secondary identifier for the email address that can change over time:
*
* ```
* // JS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class User extends Component {
* ﹫primaryIdentifier() id;
* ﹫secondaryIdentifier() email;
* }
* ```
*
* ```
* // TS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class User extends Component {
* ﹫primaryIdentifier() id!: string;
* ﹫secondaryIdentifier() email!: string;
* }
* ```
*
* To create a `User` instance, you would do something like:
*
* ```
* const user = new User({email: 'someone@domain.tld'});
*
* user.id; // => 'ck41vli1z00013h5xx1esffyn'
* user.email; // => 'someone@domain.tld'
* ```
*
* Note that the primary identifier (`id`) was auto-generated, but we had to provide a value for the secondary identifier (`email`) because secondary identifiers cannot be `undefined` and they are not commonly auto-generated.
*
* Like previously mentioned, contrary to a primary identifier, the value of a secondary identifer can be changed:
*
* ```
* user.email = 'someone-else@domain.tld'; // Okay
* user.id = 'ck2zrb1xs00013g5to1uimigb'; // Error
* ```
*
* `SecondaryIdentifierAttribute` values are usually of type `'string'` (the default), but you can also have values of type `'number'`:
*
* ```
* // JS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class User extends Component {
* ﹫primaryIdentifier() id;
* ﹫secondaryIdentifier('number') reference;
* }
* ```
*
* ```
* // TS
*
* import {Component, primaryIdentifier, secondaryIdentifier} from '﹫layr/component';
*
* class User extends Component {
* ﹫primaryIdentifier() id!: string;
* ﹫secondaryIdentifier('number') reference!: number;
* }
* ```
*/
export class SecondaryIdentifierAttribute extends IdentifierAttribute {
_secondaryIdentifierAttributeBrand!: void;
/**
* Creates an instance of [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute). Typically, instead of using this constructor, you would rather use the [`@secondaryIdentifier()`](https://layrjs.com/docs/v2/reference/component#secondary-identifier-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The component prototype that owns the attribute.
* @param [options.valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options.default] A function returning the default value of the attribute.
* @param [options.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the value of the attribute.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the attribute should be exposed to remote access.
*
* @returns The [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) instance that was created.
*
* @example
* ```
* import {Component, SecondaryIdentifierAttribute} from '﹫layr/component';
*
* class User extends Component {}
*
* const email = new SecondaryIdentifierAttribute('email', User.prototype);
*
* email.getName(); // => 'email'
* email.getParent(); // => User.prototype
* email.getValueType().toString(); // => 'string'
* ```
*
* @category Creation
*/
constructor(name: string, parent: Component, options: AttributeOptions = {}) {
super(name, parent, options);
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Attribute Methods ===
/**
* See the methods that are inherited from the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#value-type) class.
*
* @category Attribute Methods
*/
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Utilities ===
static isSecondaryIdentifierAttribute(value: any): value is SecondaryIdentifierAttribute {
return isSecondaryIdentifierAttributeInstance(value);
}
}
/**
* Returns whether the specified value is a `SecondaryIdentifierAttribute` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isSecondaryIdentifierAttributeClass(
value: any
): value is typeof SecondaryIdentifierAttribute {
return typeof value?.isSecondaryIdentifierAttribute === 'function';
}
/**
* Returns whether the specified value is a `SecondaryIdentifierAttribute` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isSecondaryIdentifierAttributeInstance(
value: any
): value is SecondaryIdentifierAttribute {
return isSecondaryIdentifierAttributeClass(value?.constructor) === true;
}
================================================
FILE: packages/component/src/properties/value-types/any-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class AnyValueType extends ValueType {
isOptional() {
return true;
}
toString() {
return 'any';
}
_checkValue(_value: any, _attribute: Attribute) {
return true;
}
static isAnyValueType(value: any): value is AnyValueType {
return isAnyValueTypeInstance(value);
}
}
export function isAnyValueTypeInstance(value: any): value is AnyValueType {
return typeof value?.constructor?.isAnyValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/array-value-type.ts
================================================
import {possiblyAsync} from 'possibly-async';
import isEmpty from 'lodash/isEmpty';
import {ValueType, ValueTypeOptions} from './value-type';
import type {
TraverseAttributesIteratee,
TraverseAttributesOptions,
ResolveAttributeSelectorOptions
} from '../../component';
import type {Attribute} from '../attribute';
import {
AttributeSelector,
mergeAttributeSelectors,
intersectAttributeSelectors
} from '../attribute-selector';
import {SerializeOptions} from '../../serialization';
import {joinAttributePath} from '../../utilities';
export class ArrayValueType extends ValueType {
_itemType: ValueType;
constructor(itemType: ValueType, attribute: Attribute, options: ValueTypeOptions = {}) {
super(attribute, options);
this._itemType = itemType;
}
getItemType() {
return this._itemType;
}
toString() {
return `${this.getItemType().toString()}[]${super.toString()}`;
}
getScalarType() {
return this.getItemType().getScalarType();
}
checkValue(values: unknown[], attribute: Attribute) {
super.checkValue(values, attribute);
if (values === undefined) {
// `values` is undefined and isOptional() is true
return;
}
const itemType = this.getItemType();
for (const value of values) {
itemType.checkValue(value, attribute);
}
}
_checkValue(values: unknown, attribute: Attribute) {
return super._checkValue(values, attribute) ?? Array.isArray(values);
}
_traverseAttributes(
iteratee: TraverseAttributesIteratee,
attribute: Attribute,
items: unknown,
options: TraverseAttributesOptions
) {
const {setAttributesOnly} = options;
const itemType = this.getItemType();
if (!setAttributesOnly) {
itemType._traverseAttributes(iteratee, attribute, undefined, options);
return;
}
if (Array.isArray(items)) {
for (const item of items) {
itemType._traverseAttributes(iteratee, attribute, item, options);
}
}
}
_resolveAttributeSelector(
normalizedAttributeSelector: AttributeSelector,
attribute: Attribute,
items: unknown,
options: ResolveAttributeSelectorOptions
): AttributeSelector {
const {setAttributesOnly, aggregationMode} = options;
options = {...options, _skipUnchangedAttributes: false, _isArrayItem: true};
if (normalizedAttributeSelector === false) {
return false;
}
const itemType = this.getItemType();
if (!setAttributesOnly || !Array.isArray(items) || items.length === 0) {
return itemType._resolveAttributeSelector(
normalizedAttributeSelector,
attribute,
undefined,
options
);
}
let resolvedAttributeSelector: AttributeSelector | undefined = undefined;
const aggregate =
aggregationMode === 'union' ? mergeAttributeSelectors : intersectAttributeSelectors;
for (const item of items) {
const itemAttributeSelector = itemType._resolveAttributeSelector(
normalizedAttributeSelector,
attribute,
item,
options
);
if (resolvedAttributeSelector === undefined) {
resolvedAttributeSelector = itemAttributeSelector;
} else {
resolvedAttributeSelector = aggregate(resolvedAttributeSelector, itemAttributeSelector);
}
}
return resolvedAttributeSelector!;
}
sanitizeValue(values: any[] | undefined) {
if (values !== undefined) {
const itemType = this.getItemType();
values = values.map((value) => itemType.sanitizeValue(value));
}
return super.sanitizeValue(values);
}
runValidators(values: unknown[] | undefined, attributeSelector?: AttributeSelector) {
const failedValidators = super.runValidators(values, attributeSelector);
if (values !== undefined) {
const itemType = this.getItemType();
values.forEach((value, index) => {
const failedItemValidators = itemType.runValidators(value, attributeSelector);
for (const {validator, path} of failedItemValidators) {
failedValidators.push({validator, path: joinAttributePath([`[${index}]`, path])});
}
});
}
return failedValidators;
}
serializeValue(items: unknown, attribute: Attribute, options: SerializeOptions = {}) {
if (Array.isArray(items)) {
const itemType = this.getItemType();
return possiblyAsync.map(items, (item) => itemType.serializeValue(item, attribute, options));
}
return super.serializeValue(items, attribute, options);
}
introspect() {
const introspectedArrayType = super.introspect();
const introspectedItemType = this.getItemType().introspect();
delete introspectedItemType.valueType;
if (!isEmpty(introspectedItemType)) {
introspectedArrayType.items = introspectedItemType;
}
return introspectedArrayType;
}
static isArrayValueType(value: any): value is ArrayValueType {
return isArrayValueTypeInstance(value);
}
}
export function isArrayValueTypeInstance(value: any): value is ArrayValueType {
return typeof value?.constructor?.isArrayValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/boolean-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class BooleanValueType extends ValueType {
toString() {
return `boolean${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? typeof value === 'boolean';
}
static isBooleanValueType(value: any): value is BooleanValueType {
return isBooleanValueTypeInstance(value);
}
}
export function isBooleanValueTypeInstance(value: any): value is BooleanValueType {
return typeof value?.constructor?.isBooleanValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/component-value-type.ts
================================================
import {ValueType, ValueTypeOptions} from './value-type';
import type {
TraverseAttributesIteratee,
TraverseAttributesOptions,
ResolveAttributeSelectorOptions
} from '../../component';
import type {Attribute} from '../attribute';
import type {AttributeSelector} from '../attribute-selector';
import {SerializeOptions} from '../../serialization';
import {
isComponentClassOrInstance,
isComponentClass,
isComponentInstance,
ensureComponentClass,
assertIsComponentType
} from '../../utilities';
export class ComponentValueType extends ValueType {
_componentType: string;
constructor(componentType: string, attribute: Attribute, options: ValueTypeOptions = {}) {
super(attribute, options);
assertIsComponentType(componentType);
this._componentType = componentType;
}
getComponentType() {
return this._componentType;
}
getComponent(attribute: Attribute) {
return ensureComponentClass(attribute.getParent()).getComponentOfType(this.getComponentType());
}
toString() {
return `${this.getComponentType()}${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
const result = super._checkValue(value, attribute);
if (result !== undefined) {
return result;
}
const component = this.getComponent(attribute);
if (value === component) {
return true;
}
if (isComponentClass(value) && isComponentClass(component)) {
return value.isForkOf(component);
}
if (isComponentInstance(value) && isComponentInstance(component)) {
return value.constructor === component.constructor || value.isForkOf(component);
}
return false;
}
_traverseAttributes(
iteratee: TraverseAttributesIteratee,
attribute: Attribute,
component: unknown,
options: TraverseAttributesOptions
) {
const {setAttributesOnly} = options;
if (!setAttributesOnly) {
component = this.getComponent(attribute);
}
if (isComponentClassOrInstance(component)) {
component.__traverseAttributes(iteratee, options);
}
}
_resolveAttributeSelector(
normalizedAttributeSelector: AttributeSelector,
attribute: Attribute,
component: unknown,
options: ResolveAttributeSelectorOptions
): AttributeSelector {
const {setAttributesOnly} = options;
if (normalizedAttributeSelector === false) {
return false;
}
if (!setAttributesOnly) {
component = this.getComponent(attribute);
}
if (!isComponentClassOrInstance(component)) {
return {}; // `setAttributesOnly` is true and `component` is undefined
}
return component.__resolveAttributeSelector(normalizedAttributeSelector, options);
}
runValidators(value: unknown, attributeSelector?: AttributeSelector) {
const failedValidators = super.runValidators(value, attributeSelector);
if (isComponentClassOrInstance(value)) {
const componentFailedValidators = value.runValidators(attributeSelector);
failedValidators.push(...componentFailedValidators);
}
return failedValidators;
}
serializeValue(value: unknown, attribute: Attribute, options: SerializeOptions = {}) {
if (isComponentClassOrInstance(value)) {
return value.__serialize(options);
}
return super.serializeValue(value, attribute, options);
}
canDeserializeInPlace(attribute: Attribute) {
return ensureComponentClass(this.getComponent(attribute)).isEmbedded();
}
static isComponentValueType(value: any): value is ComponentValueType {
return isComponentValueTypeInstance(value);
}
}
export function isComponentValueTypeInstance(value: any): value is ComponentValueType {
return typeof value?.constructor?.isComponentValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/date-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class DateValueType extends ValueType {
toString() {
return `Date${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? value instanceof Date;
}
static isDateValueType(value: any): value is DateValueType {
return isDateValueTypeInstance(value);
}
}
export function isDateValueTypeInstance(value: any): value is DateValueType {
return typeof value?.constructor?.isDateValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/factory.test.ts
================================================
import {Component} from '../../component';
import {Attribute} from '../attribute';
import {createValueType} from './factory';
import {sanitizers} from '../../sanitization';
import {validators} from '../../validation';
import {isAnyValueTypeInstance} from './any-value-type';
import {isNumberValueTypeInstance} from './number-value-type';
import {isStringValueTypeInstance} from './string-value-type';
import {ArrayValueType, isArrayValueTypeInstance} from './array-value-type';
describe('Factory', () => {
class TestComponent extends Component {}
const attribute = new Attribute('testAttribute', TestComponent.prototype);
test('createValueType()', async () => {
let type = createValueType('string', attribute);
expect(isStringValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(false);
type = createValueType('string?', attribute);
expect(isStringValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(true);
type = createValueType('number[]', attribute);
expect(isArrayValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(false);
expect(isNumberValueTypeInstance((type as ArrayValueType).getItemType())).toBe(true);
expect((type as ArrayValueType).getItemType().isOptional()).toBe(false);
type = createValueType('number?[]?', attribute);
expect(isArrayValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(true);
expect(isNumberValueTypeInstance((type as ArrayValueType).getItemType())).toBe(true);
expect((type as ArrayValueType).getItemType().isOptional()).toBe(true);
type = createValueType('string', attribute);
expect(type.getSanitizers()).toEqual([]);
expect(type.getValidators()).toEqual([]);
const trim = sanitizers.trim();
const notEmpty = validators.notEmpty();
type = createValueType('string', attribute, {sanitizers: [trim], validators: [notEmpty]});
expect(type.getSanitizers()).toEqual([trim]);
expect(type.getValidators()).toEqual([notEmpty]);
const compact = sanitizers.compact();
type = createValueType('string[]', attribute, {
sanitizers: [compact],
validators: [notEmpty],
items: {sanitizers: [trim], validators: [notEmpty]}
});
expect(type.getSanitizers()).toEqual([compact]);
expect(type.getValidators()).toEqual([notEmpty]);
expect((type as ArrayValueType).getItemType().getSanitizers()).toEqual([trim]);
expect((type as ArrayValueType).getItemType().getValidators()).toEqual([notEmpty]);
type = createValueType('string[][]', attribute, {
items: {items: {sanitizers: [trim], validators: [notEmpty]}}
});
expect(
((type as ArrayValueType).getItemType() as ArrayValueType).getItemType().getSanitizers()
).toEqual([trim]);
expect(
((type as ArrayValueType).getItemType() as ArrayValueType).getItemType().getValidators()
).toEqual([notEmpty]);
type = createValueType(undefined, attribute);
expect(isAnyValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(true);
type = createValueType('', attribute);
expect(isAnyValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(true);
type = createValueType('?', attribute);
expect(isAnyValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(true);
type = createValueType('[]', attribute);
expect(isArrayValueTypeInstance(type)).toBe(true);
expect(type.isOptional()).toBe(false);
expect(isAnyValueTypeInstance((type as ArrayValueType).getItemType())).toBe(true);
expect((type as ArrayValueType).getItemType().isOptional()).toBe(true);
expect(() => createValueType('date', attribute)).toThrow(
"The specified type is invalid (attribute: 'TestComponent.prototype.testAttribute', type: 'date')"
);
expect(() => createValueType('movie', attribute)).toThrow(
"The specified type is invalid (attribute: 'TestComponent.prototype.testAttribute', type: 'movie')"
);
expect(() => createValueType('date?', attribute)).toThrow(
"The specified type is invalid (attribute: 'TestComponent.prototype.testAttribute', type: 'date?')"
);
expect(() => createValueType('[movie]', attribute)).toThrow(
"The specified type is invalid (attribute: 'TestComponent.prototype.testAttribute', type: '[movie]')"
);
});
test('Sanitization', async () => {
const trim = sanitizers.trim();
const compact = sanitizers.compact();
let type = createValueType('string', attribute, {sanitizers: [trim]});
expect(type.sanitizeValue('hello')).toBe('hello');
expect(type.sanitizeValue(' hello ')).toBe('hello');
expect(type.sanitizeValue(undefined)).toBe(undefined);
type = createValueType('string[]', attribute, {
sanitizers: [compact],
items: {sanitizers: [trim]}
});
expect(type.sanitizeValue(['hello'])).toStrictEqual(['hello']);
expect(type.sanitizeValue(['hello', ' '])).toStrictEqual(['hello']);
expect(type.sanitizeValue([' '])).toStrictEqual([]);
expect(type.sanitizeValue(undefined)).toBe(undefined);
});
test('Validation', async () => {
const notEmpty = validators.notEmpty();
let type = createValueType('string', attribute, {validators: [notEmpty]});
expect(type.runValidators('Inception')).toEqual([]);
expect(type.runValidators('')).toEqual([{validator: notEmpty, path: ''}]);
expect(type.runValidators(undefined)).toEqual([{validator: notEmpty, path: ''}]);
type = createValueType('string[]', attribute, {
validators: [notEmpty],
items: {validators: [notEmpty]}
});
expect(type.runValidators(['Inception'])).toEqual([]);
expect(type.runValidators([])).toEqual([{validator: notEmpty, path: ''}]);
expect(type.runValidators(undefined)).toEqual([{validator: notEmpty, path: ''}]);
expect(type.runValidators(['Inception', ''])).toEqual([{validator: notEmpty, path: '[1]'}]);
expect(type.runValidators(['Inception', undefined])).toEqual([
{validator: notEmpty, path: '[1]'}
]);
type = createValueType('string[][]', attribute, {items: {items: {validators: [notEmpty]}}});
expect(type.runValidators([['Inception', '']])).toEqual([
{validator: notEmpty, path: '[0][1]'}
]);
});
});
================================================
FILE: packages/component/src/properties/value-types/factory.ts
================================================
import type {Attribute} from '../attribute';
import type {ValueType, IntrospectedValueType} from './value-type';
import {AnyValueType} from './any-value-type';
import {BooleanValueType} from './boolean-value-type';
import {NumberValueType} from './number-value-type';
import {StringValueType} from './string-value-type';
import {ObjectValueType} from './object-value-type';
import {DateValueType} from './date-value-type';
import {RegExpValueType} from './regexp-value-type';
import {ArrayValueType} from './array-value-type';
import {ComponentValueType} from './component-value-type';
import {Sanitizer, SanitizerFunction} from '../../sanitization';
import {Validator, ValidatorFunction} from '../../validation';
import {isComponentType} from '../../utilities';
const VALUE_TYPE_MAP = new Map(
Object.entries({
any: AnyValueType,
boolean: BooleanValueType,
number: NumberValueType,
string: StringValueType,
object: ObjectValueType,
Date: DateValueType,
RegExp: RegExpValueType
})
);
export type UnintrospectedValueType = {
valueType?: string;
validators?: Validator[];
items?: UnintrospectedValueType;
};
type CreateValueTypeOptions = {
sanitizers?: (Sanitizer | SanitizerFunction)[];
validators?: (Validator | ValidatorFunction)[];
items?: CreateValueTypeOptions;
};
export function createValueType(
specifier: string | undefined,
attribute: Attribute,
options: CreateValueTypeOptions = {}
): ValueType {
const {sanitizers, validators = [], items} = options;
let type = specifier ? specifier : 'any';
let isOptional: boolean;
if (type.endsWith('?')) {
isOptional = true;
type = type.slice(0, -1);
if (type === '') {
type = 'any';
}
} else {
isOptional = false;
}
if (type.endsWith('[]')) {
const itemSpecifier = type.slice(0, -2);
const itemType = createValueType(itemSpecifier, attribute, {...items});
return new ArrayValueType(itemType, attribute, {isOptional, sanitizers, validators});
}
if (items !== undefined) {
throw new Error(
`The 'items' option cannot be specified for a type that is not an array (${attribute.describe()}, type: '${specifier}')`
);
}
const ValueTypeClass = VALUE_TYPE_MAP.get(type);
if (ValueTypeClass !== undefined) {
return new ValueTypeClass(attribute, {isOptional, sanitizers, validators});
}
if (!isComponentType(type)) {
throw new Error(
`The specified type is invalid (${attribute.describe()}, type: '${specifier}')`
);
}
return new ComponentValueType(type, attribute, {isOptional, sanitizers, validators});
}
export function unintrospectValueType({
valueType,
validators,
items: introspectedItems
}: IntrospectedValueType) {
let unintrospectedItems: UnintrospectedValueType | undefined;
if (introspectedItems !== undefined) {
unintrospectedItems = unintrospectValueType(introspectedItems);
}
const unintrospectedValueType: UnintrospectedValueType = {};
if (valueType !== undefined) {
unintrospectedValueType.valueType = valueType;
}
if (validators !== undefined) {
unintrospectedValueType.validators = validators;
}
if (unintrospectedItems !== undefined) {
unintrospectedValueType.items = unintrospectedItems;
}
return unintrospectedValueType;
}
================================================
FILE: packages/component/src/properties/value-types/index.ts
================================================
export * from './any-value-type';
export * from './array-value-type';
export * from './boolean-value-type';
export * from './component-value-type';
export * from './date-value-type';
export * from './factory';
export * from './number-value-type';
export * from './object-value-type';
export * from './regexp-value-type';
export * from './string-value-type';
export * from './value-type';
================================================
FILE: packages/component/src/properties/value-types/number-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class NumberValueType extends ValueType {
toString() {
return `number${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? typeof value === 'number';
}
static isNumberValueType(value: any): value is NumberValueType {
return isNumberValueTypeInstance(value);
}
}
export function isNumberValueTypeInstance(value: any): value is NumberValueType {
return typeof value?.constructor?.isNumberValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/object-value-type.ts
================================================
import isPlainObject from 'lodash/isPlainObject';
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class ObjectValueType extends ValueType {
toString() {
return `object${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? isPlainObject(value);
}
static isObjectValueType(value: any): value is ObjectValueType {
return isObjectValueTypeInstance(value);
}
}
export function isObjectValueTypeInstance(value: any): value is ObjectValueType {
return typeof value?.constructor?.isObjectValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/regexp-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class RegExpValueType extends ValueType {
toString() {
return `RegExp${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? value instanceof RegExp;
}
static isRegExpValueType(value: any): value is RegExpValueType {
return isRegExpValueTypeInstance(value);
}
}
export function isRegExpValueTypeInstance(value: any): value is RegExpValueType {
return typeof value?.constructor?.isRegExpValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/string-value-type.ts
================================================
import {ValueType} from './value-type';
import type {Attribute} from '../attribute';
export class StringValueType extends ValueType {
toString() {
return `string${super.toString()}`;
}
_checkValue(value: unknown, attribute: Attribute) {
return super._checkValue(value, attribute) ?? typeof value === 'string';
}
static isStringValueType(value: any): value is StringValueType {
return isStringValueTypeInstance(value);
}
}
export function isStringValueTypeInstance(value: any): value is StringValueType {
return typeof value?.constructor?.isStringValueType === 'function';
}
================================================
FILE: packages/component/src/properties/value-types/value-type.test.ts
================================================
import {Component} from '../../component';
import {Attribute} from '../attribute';
import {provide} from '../../decorators';
import {AnyValueType, isAnyValueTypeInstance} from './any-value-type';
import {BooleanValueType, isBooleanValueTypeInstance} from './boolean-value-type';
import {NumberValueType, isNumberValueTypeInstance} from './number-value-type';
import {StringValueType, isStringValueTypeInstance} from './string-value-type';
import {ObjectValueType, isObjectValueTypeInstance} from './object-value-type';
import {DateValueType, isDateValueTypeInstance} from './date-value-type';
import {RegExpValueType, isRegExpValueTypeInstance} from './regexp-value-type';
import {ArrayValueType, isArrayValueTypeInstance} from './array-value-type';
import {ComponentValueType, isComponentValueTypeInstance} from './component-value-type';
describe('ValueType', () => {
class TestComponent extends Component {}
const attribute = new Attribute('testAttribute', TestComponent.prototype);
test('AnyValueType', async () => {
let type = new AnyValueType(attribute);
expect(isAnyValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('any');
expect(() => type.checkValue(true, attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).not.toThrow();
expect(() => type.checkValue({}, attribute)).not.toThrow();
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
// The 'isOptional' option should no effects for the 'any' type
type = new AnyValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('any');
expect(() => type.checkValue(true, attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).not.toThrow();
expect(() => type.checkValue({}, attribute)).not.toThrow();
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('BooleanValueType', async () => {
let type = new BooleanValueType(attribute);
expect(isBooleanValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('boolean');
expect(() => type.checkValue(true, attribute)).not.toThrow();
expect(() => type.checkValue(false, attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'boolean', received type: 'number')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'boolean', received type: 'undefined')"
);
type = new BooleanValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('boolean?');
expect(() => type.checkValue(true, attribute)).not.toThrow();
expect(() => type.checkValue(false, attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'boolean?', received type: 'number')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('NumberValueType', async () => {
let type = new NumberValueType(attribute);
expect(isNumberValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('number');
expect(() => type.checkValue(1, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'undefined')"
);
type = new NumberValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('number?');
expect(() => type.checkValue(1, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number?', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('StringValueType', async () => {
let type = new StringValueType(attribute);
expect(isStringValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('string');
expect(() => type.checkValue('a', attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'string', received type: 'number')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'string', received type: 'undefined')"
);
type = new StringValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('string?');
expect(() => type.checkValue('a', attribute)).not.toThrow();
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'string?', received type: 'number')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('ObjectValueType', async () => {
let type = new ObjectValueType(attribute);
expect(isObjectValueTypeInstance(type)).toBe(true);
class Movie extends Component {}
const movie = new Movie();
expect(type.toString()).toBe('object');
expect(() => type.checkValue({}, attribute)).not.toThrow();
expect(() => type.checkValue({title: 'Inception'}, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'object', received type: 'string')"
);
expect(() => type.checkValue(movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'object', received type: 'Movie')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'object', received type: 'undefined')"
);
type = new ObjectValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('object?');
expect(() => type.checkValue({}, attribute)).not.toThrow();
expect(() => type.checkValue({title: 'Inception'}, attribute)).not.toThrow();
expect(() => type.checkValue(movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'object?', received type: 'Movie')"
);
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'object?', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('DateValueType', async () => {
let type = new DateValueType(attribute);
expect(isDateValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('Date');
expect(() => type.checkValue(new Date(), attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'Date', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'Date', received type: 'undefined')"
);
type = new DateValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('Date?');
expect(() => type.checkValue(new Date(), attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'Date?', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('RegExpValueType', async () => {
let type = new RegExpValueType(attribute);
expect(isRegExpValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('RegExp');
expect(() => type.checkValue(/abc/, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'RegExp', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'RegExp', received type: 'undefined')"
);
type = new RegExpValueType(attribute, {isOptional: true});
expect(type.toString()).toBe('RegExp?');
expect(() => type.checkValue(/abc/, attribute)).not.toThrow();
expect(() => type.checkValue('a', attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'RegExp?', received type: 'string')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
test('ArrayValueType', async () => {
let itemType = new NumberValueType(attribute);
let type = new ArrayValueType(itemType, attribute);
expect(isArrayValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('number[]');
expect(() => type.checkValue([], attribute)).not.toThrow();
expect(() => type.checkValue([1], attribute)).not.toThrow();
// @ts-expect-error
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number[]', received type: 'number')"
);
expect(() => type.checkValue(['a'], attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'string')"
);
// @ts-expect-error
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number[]', received type: 'undefined')"
);
expect(() => type.checkValue([undefined], attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'undefined')"
);
expect(() => type.checkValue([1, undefined], attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'undefined')"
);
expect(() => type.checkValue([undefined, 1], attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number', received type: 'undefined')"
);
itemType = new NumberValueType(attribute, {isOptional: true});
type = new ArrayValueType(itemType, attribute, {isOptional: true});
expect(type.toString()).toBe('number?[]?');
expect(() => type.checkValue([], attribute)).not.toThrow();
expect(() => type.checkValue([1], attribute)).not.toThrow();
// @ts-expect-error
expect(() => type.checkValue(1, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number?[]?', received type: 'number')"
);
expect(() => type.checkValue(['a'], attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'TestComponent.prototype.testAttribute', expected type: 'number?', received type: 'string')"
);
// @ts-expect-error
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
expect(() => type.checkValue([undefined], attribute)).not.toThrow();
expect(() => type.checkValue([1, undefined], attribute)).not.toThrow();
expect(() => type.checkValue([undefined, 1], attribute)).not.toThrow();
});
test('ComponentValueType', async () => {
class Movie extends Component {}
class Actor extends Component {}
class App extends Component {
@provide() static Movie = Movie;
@provide() static Actor = Actor;
}
const attribute = new Attribute('testAttribute', App);
const movie = new Movie();
const actor = new Actor();
// Component class value types
let type = new ComponentValueType('typeof Movie', attribute);
expect(isComponentValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('typeof Movie');
expect(() => type.checkValue(Movie, attribute)).not.toThrow();
expect(() => type.checkValue(Actor, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie', received type: 'typeof Actor')"
);
expect(() => type.checkValue(movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie', received type: 'Movie')"
);
expect(() => type.checkValue({}, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie', received type: 'object')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie', received type: 'undefined')"
);
type = new ComponentValueType('typeof Movie', attribute, {isOptional: true});
expect(type.toString()).toBe('typeof Movie?');
expect(() => type.checkValue(Movie, attribute)).not.toThrow();
expect(() => type.checkValue(Actor, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie?', received type: 'typeof Actor')"
);
expect(() => type.checkValue(movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie?', received type: 'Movie')"
);
expect(() => type.checkValue({}, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'typeof Movie?', received type: 'object')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
// Component instance value types
type = new ComponentValueType('Movie', attribute);
expect(isComponentValueTypeInstance(type)).toBe(true);
expect(type.toString()).toBe('Movie');
expect(() => type.checkValue(movie, attribute)).not.toThrow();
expect(() => type.checkValue(actor, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie', received type: 'Actor')"
);
expect(() => type.checkValue(Movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie', received type: 'typeof Movie')"
);
expect(() => type.checkValue({}, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie', received type: 'object')"
);
expect(() => type.checkValue(undefined, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie', received type: 'undefined')"
);
type = new ComponentValueType('Movie', attribute, {isOptional: true});
expect(type.toString()).toBe('Movie?');
expect(() => type.checkValue(movie, attribute)).not.toThrow();
expect(() => type.checkValue(actor, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie?', received type: 'Actor')"
);
expect(() => type.checkValue(Movie, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie?', received type: 'typeof Movie')"
);
expect(() => type.checkValue({}, attribute)).toThrow(
"Cannot assign a value of an unexpected type (attribute: 'App.testAttribute', expected type: 'Movie?', received type: 'object')"
);
expect(() => type.checkValue(undefined, attribute)).not.toThrow();
});
});
================================================
FILE: packages/component/src/properties/value-types/value-type.ts
================================================
import {getTypeOf} from 'core-helpers';
import type {
TraverseAttributesIteratee,
TraverseAttributesOptions,
ResolveAttributeSelectorOptions
} from '../../component';
import type {Attribute} from '../attribute';
import type {AttributeSelector} from '../attribute-selector';
import {Sanitizer, SanitizerFunction, runSanitizers, normalizeSanitizer} from '../../sanitization';
import {Validator, ValidatorFunction, runValidators, normalizeValidator} from '../../validation';
import {serialize, SerializeOptions} from '../../serialization';
export type IntrospectedValueType = {
valueType?: string;
validators?: Validator[];
items?: IntrospectedValueType;
};
export type ValueTypeOptions = {
isOptional?: boolean;
sanitizers?: (Sanitizer | SanitizerFunction)[];
validators?: (Validator | ValidatorFunction)[];
};
/**
* A class to handle the various types of values supported by Layr.
*
* #### Usage
*
* You shouldn't have to create a `ValueType` instance directly. Instead, when you define an attribute (using a decorator such as [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator)), you can specify a string representing a type of value, and a `ValueType` will be automatically created for you.
*
* **Example:**
*
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {integer, greaterThan} = validators;
*
* class Movie extends Component {
* // Required 'string' attribute
* ﹫attribute('string') title;
*
* // Required 'number' attribute with some validators
* ﹫attribute('number', {validators: [integer(), greaterThan(0)]}) reference;
*
* // Optional 'string' attribute
* ﹫attribute('string?') summary;
*
* // Required 'Director' attribute
* ﹫attribute('Director') director;
*
* // Required array of 'Actor' attribute with a default value
* ﹫attribute('Actor[]') actors = [];
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {integer, greaterThan} = validators;
*
* class Movie extends Component {
* // Required 'string' attribute
* ﹫attribute('string') title!: string;
*
* // Required 'number' attribute with some validators
* ﹫attribute('number', {validators: [integer(), greaterThan(0)]}) reference!: number;
*
* // Optional 'string' attribute
* ﹫attribute('string?') summary?: string;
*
* // Required 'Director' attribute
* ﹫attribute('Director') director!: Director;
*
* // Required array of 'Actor' attribute with a default value
* ﹫attribute('Actor[]') actors: Actor[] = [];
* }
* ```
*
* In case you want to access the `ValueType` instances that were created under the hood, you can do the following:
*
* ```
* const movie = new Movie({ ... });
*
* let valueType = movie.getAttribute('title').getValueType();
* valueType.toString(); // => 'string'
*
* valueType = movie.getAttribute('reference').getValueType();
* valueType.toString(); // => 'number'
* valueType.getValidators(); // => [integerValidator, greaterThanValidator]
*
* valueType = movie.getAttribute('summary').getValueType();
* valueType.toString(); // => 'string?'
* valueType.isOptional(); // => true
*
* valueType = movie.getAttribute('director').getValueType();
* valueType.toString(); // => 'Director'
*
* valueType = movie.getAttribute('actors').getValueType();
* valueType.toString(); // => 'Actor[]'
* const itemValueType = valueType.getItemType(); // => A ValueType representing the type of the items inside the array
* itemValueType.toString(); // => Actor
* ```
*
* #### Supported Types
*
* Layr supports a number of types that can be represented by a string in a way that is very similar to the way you specify basic types in [TypeScript](https://www.typescriptlang.org/).
*
* ##### Scalars
*
* To specify a scalar type, simply specify a string representing it:
*
* * `'boolean'`: A boolean.
* * `'number'`: A floating-point number.
* * `'string'`: A string.
*
* ##### Arrays
*
* To specify an array type, add `'[]'` after any other type:
*
* * `'number[]'`: An array of numbers.
* * `'string[]'`: An array of strings.
* * `'Actor[]'`: An array of `Actor`.
* * `'number[][]'`: A matrix of numbers.
*
* ##### Objects
*
* To specify a plain object type, just specify the string `'object'`:
*
* * `'object'`: A plain JavaScript object.
*
* Some common JavaScript objects are supported as well:
*
* * `'Date'`: A JavaScript `Date` instance.
* * `'RegExp'`: A JavaScript `RegExp` instance.
*
* ##### Components
*
* An attribute can hold a reference to a [`Component`](https://layrjs.com/docs/v2/reference/component) instance, or contain an [`EmbeddedComponent`](https://layrjs.com/docs/v2/reference/embedded-component) instance. To specify such a type, just specify the name of the component:
*
* * `'Director'`: A reference to a `Director` component instance.
* * `'MovieDetails'`: A `MovieDetails` embedded component instance.
*
* It is also possible to specify a type that represents a reference to a [`Component`](https://layrjs.com/docs/v2/reference/component) class. To do so, add `'typeof '` before the name of the component:
*
* * `'typeof Director'`: A reference to the `Director` component class.
*
* ##### `'?'` Modifier
*
* By default, all attribute values are required, which means a value cannot be `undefined`. To make a value optional, add a question mark (`'?'`) after its type:
*
* * `'string?'`: A string or `undefined`.
* * `'number[]?'`: A number array or `undefined`.
* * `'number?[]'`: An array containing some values of type number or `undefined`.
* * `'Director?'`: A reference to a `Director` component instance or `undefined`.
*
* ##### '`any`' Type
*
* In some rare occasions, you may want to define an attribute that can handle any type of values. To do so, you can specify the string `'any'`:
*
* * `'any'`: Any type of values.
*/
export class ValueType {
_isOptional: boolean | undefined;
_sanitizers: Sanitizer[];
_validators: Validator[];
constructor(attribute: Attribute, options: ValueTypeOptions = {}) {
const {isOptional, sanitizers = [], validators = []} = options;
const normalizedSanitizers = sanitizers.map((sanitizer) =>
normalizeSanitizer(sanitizer, attribute)
);
const normalizedValidators = validators.map((validator) =>
normalizeValidator(validator, attribute)
);
this._isOptional = isOptional;
this._sanitizers = normalizedSanitizers;
this._validators = normalizedValidators;
}
/**
* Returns whether the value type is marked as optional. A value of a type marked as optional can be `undefined`.
*
* @returns A boolean.
*
* @example
* ```
* movie.getAttribute('summary').getValueType().isOptional(); // => true
* movie.summary = undefined; // Okay
*
* movie.getAttribute('title').getValueType().isOptional(); // => false
* movie.title = undefined; // Error
* ```
*
* @category Methods
*/
isOptional() {
return this._isOptional === true;
}
getSanitizers() {
return this._sanitizers;
}
/**
* Returns the validators associated to the value type.
*
* @returns A array of [`Validator`](https://layrjs.com/docs/v2/reference/component).
*
* @example
* ```
* movie.getAttribute('reference').getValueType().getValidators();
* // => [integerValidator, greaterThanValidator]
* ```
*
* @category Methods
*/
getValidators() {
return this._validators;
}
/**
* @method getItemType
*
* In case the value type is an array, returns the value type of the items it contains.
*
* @returns A [ValueType](https://layrjs.com/docs/v2/reference/value-type).
*
* @example
* ```
* movie.getAttribute('actors').getValueType().getItemType().toString(); // => 'Actor'
* ```
*
* @category Methods
*/
/**
* Returns a string representation of the value type.
*
* @returns A string.
*
* @example
* ```
* movie.getAttribute('title').getValueType().toString(); // => 'string'
* movie.getAttribute('summary').getValueType().toString(); // => 'string?'
* movie.getAttribute('actors').getValueType().toString(); // => 'Actor[]'
* ```
*
* @category Methods
*/
toString(): string {
return this.isOptional() ? '?' : '';
}
getScalarType() {
return this as ValueType;
}
checkValue(value: unknown, attribute: Attribute) {
if (!this._checkValue(value, attribute)) {
throw new Error(
`Cannot assign a value of an unexpected type (${attribute.describe()}, expected type: '${this.toString()}', received type: '${getTypeOf(
value
)}')`
);
}
}
_checkValue(value: unknown, _attribute: Attribute) {
return value === undefined ? this.isOptional() : undefined;
}
_traverseAttributes(
_iteratee: TraverseAttributesIteratee,
_attribute: Attribute,
_value: unknown,
_options: TraverseAttributesOptions
) {
// NOOP
}
_resolveAttributeSelector(
normalizedAttributeSelector: AttributeSelector,
_attribute: Attribute,
_value: unknown,
_options: ResolveAttributeSelectorOptions
): AttributeSelector {
return normalizedAttributeSelector !== false;
}
sanitizeValue(value: any) {
return runSanitizers(this.getSanitizers(), value);
}
isValidValue(value: unknown) {
const failedValidators = this.runValidators(value);
return failedValidators.length === 0;
}
runValidators(value: unknown, _attributeSelector?: AttributeSelector) {
const failedValidators = runValidators(this.getValidators(), value);
const failedValidatorsWithPath = failedValidators.map((failedValidator) => ({
validator: failedValidator,
path: ''
}));
return failedValidatorsWithPath;
}
serializeValue(value: unknown, _attribute: Attribute, options: SerializeOptions = {}) {
return serialize(value, options);
}
canDeserializeInPlace(_attribute: Attribute) {
return false;
}
introspect() {
const introspectedValueType: IntrospectedValueType = {valueType: this.toString()};
const validators = this.getValidators();
if (validators.length > 0) {
introspectedValueType.validators = validators;
}
return introspectedValueType;
}
}
================================================
FILE: packages/component/src/sanitization/index.ts
================================================
export * from './utilities';
export * from './sanitizer-builders';
export * from './sanitizer';
================================================
FILE: packages/component/src/sanitization/sanitizer-builders.test.ts
================================================
import {isSanitizerInstance} from './sanitizer';
import {sanitizers} from './sanitizer-builders';
describe('Sanitizer builders', () => {
test('Building sanitizers', async () => {
let sanitizer = sanitizers.trim();
expect(isSanitizerInstance(sanitizer));
expect(sanitizer.getName()).toBe('trim');
expect(typeof sanitizer.getFunction()).toBe('function');
expect(sanitizer.getArguments()).toEqual([]);
sanitizer = sanitizers.compact();
expect(isSanitizerInstance(sanitizer));
expect(sanitizer.getName()).toBe('compact');
expect(typeof sanitizer.getFunction()).toBe('function');
expect(sanitizer.getArguments()).toEqual([]);
});
test('Running built-in sanitizers', async () => {
expect(sanitizers.trim().run('hello')).toBe('hello');
expect(sanitizers.trim().run(' hello ')).toBe('hello');
expect(sanitizers.trim().run(undefined)).toBe(undefined);
expect(sanitizers.compact().run(['hello'])).toStrictEqual(['hello']);
expect(sanitizers.compact().run(['hello', ''])).toStrictEqual(['hello']);
expect(sanitizers.compact().run([''])).toStrictEqual([]);
expect(sanitizers.compact().run([])).toStrictEqual([]);
expect(sanitizers.compact().run(undefined)).toBe(undefined);
});
});
================================================
FILE: packages/component/src/sanitization/sanitizer-builders.ts
================================================
import trim from 'lodash/trim';
import compact from 'lodash/compact';
import {Sanitizer, SanitizerFunction} from './sanitizer';
const sanitizerFunctions: {[name: string]: SanitizerFunction} = {
// Strings
trim: (value) => (value !== undefined ? trim(value) : undefined),
// Arrays
compact: (value) => (value !== undefined ? compact(value) : undefined)
};
export type SanitizerBuilder = (...args: any[]) => Sanitizer;
export const sanitizers: {[name: string]: SanitizerBuilder} = {};
for (const [name, func] of Object.entries(sanitizerFunctions)) {
sanitizers[name] = (...args) => createSanitizer(name, func, args);
}
function createSanitizer(name: string, func: SanitizerFunction, args: any[]) {
const numberOfRequiredArguments = func.length - 1;
const sanitizerArguments = args.slice(0, numberOfRequiredArguments);
if (sanitizerArguments.length < numberOfRequiredArguments) {
throw new Error(`A required parameter is missing to build the sanitizer '${name}'`);
}
return new Sanitizer(func, {name, arguments: sanitizerArguments});
}
================================================
FILE: packages/component/src/sanitization/sanitizer.test.ts
================================================
import {Sanitizer, isSanitizerInstance, runSanitizers} from './sanitizer';
runSanitizers;
describe('Sanitizer', () => {
const trimStart = (value: string) => value.trimStart();
const trim = (value: string, {start = true, end = true}: {start?: boolean; end?: boolean}) => {
if (start) {
value = value.trimStart();
}
if (end) {
value = value.trimEnd();
}
return value;
};
const upperCaseFirst = (value: string) => value.slice(0, 1).toUpperCase() + value.slice(1);
test('Creation', async () => {
let sanitizer = new Sanitizer(trimStart);
expect(isSanitizerInstance(sanitizer));
expect(sanitizer.getName()).toBe('trimStart');
expect(sanitizer.getFunction()).toBe(trimStart);
expect(sanitizer.getArguments()).toEqual([]);
sanitizer = new Sanitizer(trim, {arguments: [{end: false}]});
expect(isSanitizerInstance(sanitizer));
expect(sanitizer.getName()).toBe('trim');
expect(sanitizer.getFunction()).toBe(trim);
expect(sanitizer.getArguments()).toEqual([{end: false}]);
});
test('Execution', async () => {
const trimStartSanitizer = new Sanitizer(trim, {arguments: [{end: false}]});
const upperCaseFirstSanitizer = new Sanitizer(upperCaseFirst);
expect(trimStartSanitizer.run('hello')).toBe('hello');
expect(trimStartSanitizer.run(' hello ')).toBe('hello ');
expect(upperCaseFirstSanitizer.run('hello')).toBe('Hello');
expect(runSanitizers([trimStartSanitizer], 'hello')).toBe('hello');
expect(runSanitizers([trimStartSanitizer], ' hello ')).toBe('hello ');
expect(runSanitizers([trimStartSanitizer, upperCaseFirstSanitizer], ' hello')).toBe('Hello');
expect(runSanitizers([upperCaseFirstSanitizer, trimStartSanitizer], ' hello')).toBe('hello');
});
});
================================================
FILE: packages/component/src/sanitization/sanitizer.ts
================================================
import {getFunctionName} from 'core-helpers';
export type SanitizerFunction = (value: any, ...args: any[]) => any;
type SanitizerOptions = {
name?: string;
arguments?: any[];
};
/**
* A class to handle the sanitization of the component attributes.
*
* #### Usage
*
* You shouldn't have to create a `Sanitizer` instance directly. Instead, when you define an attribute (using a decorator such as [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator)), you can invoke some [built-in sanitizer builders](https://layrjs.com/docs/v2/reference/sanitizer#built-in-sanitizer-builders) or specify your own [custom sanitization functions](https://layrjs.com/docs/v2/reference/sanitizer#custom-sanitization-functions) that will be automatically transformed into `Sanitizer` instances.
*
* **Example:**
*
* ```
* // JS
*
* import {Component, attribute, sanitizers} from '﹫layr/component';
*
* const {trim, compact} = sanitizers;
*
* class Movie extends Component {
* // An attribute of type 'string' that is automatically trimmed
* ﹫attribute('string', {sanitizers: [trim()]}) title;
*
* // An array attribute for storing non-empty strings
* ﹫attribute('string[]', {sanitizers: [compact()], items: {sanitizers: [trim()]}})
* tags = [];
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute, sanitizers} from '﹫layr/component';
*
* const {trim, compact} = sanitizers;
*
* class Movie extends Component {
* // An attribute of type 'string' that is automatically trimmed
* ﹫attribute('string', {sanitizers: [trim()]}) title!: string;
*
* // An array attribute for storing non-empty strings
* ﹫attribute('string[]', {sanitizers: [compact()], items: {sanitizers: [trim()]}})
* tags: string[] = [];
* }
* ```
*
* In case you want to access the `Sanitizer` instances that were created under the hood, you can do the following:
*
* ```
* const movie = new Movie({ ... });
*
* movie.getAttribute('title').getValueType().getSanitizers();
* // => [trimSanitizer]
*
* movie.getAttribute('tags').getValueType().getSanitizers();
* // => [compactSanitizer]
*
* movie.getAttribute('tags').getValueType().getItemType().getSanitizers();
* // => [trimSanitizer]
* ```
*
* #### Built-In Sanitizer Builders
*
* Layr provides some sanitizer builders that can be used when you define your component attributes. See an [example of use](https://layrjs.com/docs/v2/reference/sanitizer#usage) above.
*
* ##### Strings
*
* The following sanitizer builder can be used to sanitize strings:
*
* * `trim()`: Removes leading and trailing whitespace from the string.
*
* ##### Arrays
*
* The following sanitizer builder can be used to sanitize arrays:
*
* * `compact()`: Removes all falsey values from the array. The values `false`, `null`, `0`, `''`, `undefined`, and `NaN` are falsey.
*
* #### Custom Sanitization Functions
*
* In addition to the [built-in sanitizer builders](https://layrjs.com/docs/v2/reference/sanitizer#built-in-sanitizer-builders), you can sanitize your component attributes with your own custom sanitization functions.
*
* A custom sanitization function takes a value as first parameter and returns a new value that is the result of the sanitization.
*
* **Example:**
*
* ```
* // JS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Integer extends Component {
* // Ensures that the value is an integer
* ﹫attribute('number', {sanitizers: [(value) => Math.round(value)]}) value;
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute} from '﹫layr/component';
*
* class Integer extends Component {
* // Ensures that the value is an integer
* ﹫attribute('number', {sanitizers: [(value) => Math.round(value)]}) value!: number;
* }
* ```
*/
export class Sanitizer {
_function: SanitizerFunction;
_name: string;
_arguments: any[];
constructor(func: SanitizerFunction, options: SanitizerOptions = {}) {
let {name, arguments: args = []} = options;
if (name === undefined) {
name = getFunctionName(func) || 'anonymous';
}
this._function = func;
this._name = name;
this._arguments = args;
}
getFunction() {
return this._function;
}
getName() {
return this._name;
}
getArguments() {
return this._arguments;
}
run(value: any) {
return this.getFunction()(value, ...this.getArguments());
}
static isSanitizer(value: any): value is Sanitizer {
return isSanitizerInstance(value);
}
}
export function isSanitizerInstance(value: any): value is Sanitizer {
return typeof value?.constructor?.isSanitizer === 'function';
}
export function runSanitizers(sanitizers: Sanitizer[], value: any) {
for (const sanitizer of sanitizers) {
value = sanitizer.run(value);
}
return value;
}
================================================
FILE: packages/component/src/sanitization/utilities.test.ts
================================================
import {Component} from '../component';
import {Attribute} from '../properties';
import {Sanitizer, isSanitizerInstance} from './sanitizer';
import {sanitizers} from './sanitizer-builders';
import {normalizeSanitizer} from './utilities';
describe('Utilities', () => {
test('Normalization', async () => {
class TestComponent extends Component {}
const attribute = new Attribute('testAttribute', TestComponent.prototype);
let sanitizer: any = new Sanitizer((value) => value > 0);
let normalizedSanitizer = normalizeSanitizer(sanitizer, attribute);
expect(normalizedSanitizer).toBe(sanitizer);
sanitizer = sanitizers.trim();
normalizedSanitizer = normalizeSanitizer(sanitizer, attribute);
expect(normalizedSanitizer).toBe(sanitizer);
sanitizer = sanitizers.trim;
expect(() => normalizeSanitizer(sanitizer, attribute)).toThrow(
"The specified sanitizer is a sanitizer builder that has not been called (attribute: 'TestComponent.prototype.testAttribute')"
);
sanitizer = (value: number) => value > 0;
normalizedSanitizer = normalizeSanitizer(sanitizer, attribute);
expect(isSanitizerInstance(normalizedSanitizer));
expect(normalizedSanitizer.getName()).toBe('sanitizer');
expect(normalizedSanitizer.getFunction()).toBe(sanitizer);
expect(normalizedSanitizer.getArguments()).toEqual([]);
});
});
================================================
FILE: packages/component/src/sanitization/utilities.ts
================================================
import {sanitizers, SanitizerBuilder} from './sanitizer-builders';
import {Sanitizer, SanitizerFunction, isSanitizerInstance} from './sanitizer';
import type {Attribute} from '../properties';
export function normalizeSanitizer(sanitizer: Sanitizer | SanitizerFunction, attribute: Attribute) {
if (isSanitizerInstance(sanitizer)) {
return sanitizer;
}
if (typeof sanitizer !== 'function') {
throw new Error(`The specified sanitizer is not a function (${attribute.describe()})`);
}
if (Object.values(sanitizers).includes((sanitizer as unknown) as SanitizerBuilder)) {
throw new Error(
`The specified sanitizer is a sanitizer builder that has not been called (${attribute.describe()})`
);
}
return new Sanitizer(sanitizer);
}
================================================
FILE: packages/component/src/serialization.test.ts
================================================
import {Component, ComponentSet} from './component';
import {EmbeddedComponent} from './embedded-component';
import {attribute, primaryIdentifier, secondaryIdentifier, provide} from './decorators';
import {serialize} from './serialization';
describe('Serialization', () => {
test('Component classes', async () => {
class BaseMovie extends Component {}
expect(BaseMovie.serialize()).toStrictEqual({__component: 'typeof BaseMovie'});
class Movie extends BaseMovie {
@attribute() static limit = 100;
@attribute() static offset: number;
}
expect(Movie.serialize()).toStrictEqual({
__component: 'typeof Movie',
limit: 100,
offset: {__undefined: true}
});
expect(Movie.serialize({attributeSelector: {limit: true}})).toStrictEqual({
__component: 'typeof Movie',
limit: 100
});
expect(Movie.serialize({returnComponentReferences: true})).toStrictEqual({
__component: 'typeof Movie'
});
// - Value sourcing -
Movie.getAttribute('limit').setValueSource('client');
expect(Movie.serialize()).toStrictEqual({
__component: 'typeof Movie',
limit: 100,
offset: {__undefined: true}
});
expect(Movie.serialize({target: 'client'})).toStrictEqual({
__component: 'typeof Movie',
offset: {__undefined: true}
});
Movie.getAttribute('offset').setValueSource('client');
expect(Movie.serialize({target: 'client'})).toStrictEqual({
__component: 'typeof Movie'
});
// --- With referenced components ---
class Cinema extends Component {
@attribute() static limit = 100;
@attribute() static MovieClass = Movie;
}
expect(Cinema.serialize()).toStrictEqual({
__component: 'typeof Cinema',
limit: 100,
MovieClass: {__component: 'typeof Movie'}
});
let componentDependencies: ComponentSet = new Set();
expect(Cinema.serialize({componentDependencies})).toStrictEqual({
__component: 'typeof Cinema',
limit: 100,
MovieClass: {__component: 'typeof Movie'}
});
expect(Array.from(componentDependencies)).toStrictEqual([]);
componentDependencies = new Set();
expect(
Cinema.serialize({returnComponentReferences: true, componentDependencies})
).toStrictEqual({
__component: 'typeof Cinema'
});
expect(Array.from(componentDependencies)).toStrictEqual([]);
});
test('Component instances', async () => {
class Person extends EmbeddedComponent {
@attribute() name?: string;
@attribute() country?: string;
}
class Director extends Person {}
class Actor extends Person {}
class Movie extends Component {
@provide() static Director = Director;
@provide() static Actor = Actor;
@attribute() title = '';
@attribute('Director?') director?: Director;
@attribute('Actor[]') actors = new Array();
}
let movie = new Movie();
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
__new: true,
title: '',
director: {__undefined: true},
actors: []
});
expect(movie.serialize({attributeSelector: {title: true}})).toStrictEqual({
__component: 'Movie',
__new: true,
title: ''
});
expect(movie.serialize({includeIsNewMarks: false})).toStrictEqual({
__component: 'Movie',
title: '',
director: {__undefined: true},
actors: []
});
movie = Movie.instantiate();
expect(movie.serialize()).toStrictEqual({
__component: 'Movie'
});
expect(movie.serialize({includeComponentTypes: false})).toStrictEqual({});
movie.title = 'Inception';
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
title: 'Inception'
});
expect(movie.serialize({includeComponentTypes: false})).toStrictEqual({
title: 'Inception'
});
// - Value sourcing -
movie = Movie.instantiate();
movie.getAttribute('title').setValue('Inception', {source: 'client'});
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
title: 'Inception'
});
expect(movie.serialize({target: 'client'})).toStrictEqual({
__component: 'Movie'
});
// --- With an embedded component ---
movie.director = new Director({name: 'Christopher Nolan'});
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
title: 'Inception',
director: {
__component: 'Director',
__new: true,
name: 'Christopher Nolan',
country: {__undefined: true}
}
});
expect(
movie.serialize({attributeSelector: {title: true, director: {name: true}}})
).toStrictEqual({
__component: 'Movie',
title: 'Inception',
director: {__component: 'Director', __new: true, name: 'Christopher Nolan'}
});
expect(movie.serialize({attributeSelector: {title: true, director: {}}})).toStrictEqual({
__component: 'Movie',
title: 'Inception',
director: {__component: 'Director', __new: true}
});
expect(movie.serialize({includeIsNewMarks: false})).toStrictEqual({
__component: 'Movie',
title: 'Inception',
director: {__component: 'Director', name: 'Christopher Nolan', country: {__undefined: true}}
});
expect(
movie.serialize({
attributeFilter(attribute) {
expect(this).toBe(movie);
expect(attribute.getParent()).toBe(movie);
return attribute.getName() === 'title';
}
})
).toStrictEqual({
__component: 'Movie',
title: 'Inception'
});
expect(
await movie.serialize({
async attributeFilter(attribute) {
expect(this).toBe(movie);
expect(attribute.getParent()).toBe(movie);
return attribute.getName() === 'title';
}
})
).toStrictEqual({
__component: 'Movie',
title: 'Inception'
});
// - Value sourcing -
const director = Director.instantiate();
director.getAttribute('name').setValue('Christopher Nolan', {source: 'client'});
director.getAttribute('country').setValue('USA', {source: 'client'});
movie.getAttribute('director').setValue(director, {source: 'client'});
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
title: 'Inception',
director: {__component: 'Director', name: 'Christopher Nolan', country: 'USA'}
});
expect(movie.serialize({target: 'client'})).toStrictEqual({__component: 'Movie'});
movie.director.country = 'US';
expect(movie.serialize({target: 'client'})).toStrictEqual({
__component: 'Movie',
director: {__component: 'Director', country: 'US'}
});
// --- With an array of embedded components ---
movie.actors = [new Actor({name: 'Leonardo DiCaprio'})];
expect(movie.serialize({attributeSelector: {actors: true}})).toStrictEqual({
__component: 'Movie',
actors: [
{__component: 'Actor', __new: true, name: 'Leonardo DiCaprio', country: {__undefined: true}}
]
});
// - Value sourcing -
const actor = Actor.instantiate();
actor.getAttribute('name').setValue('Leonardo DiCaprio', {source: 'client'});
actor.getAttribute('country').setValue('USA', {source: 'client'});
movie.getAttribute('actors').setValue([actor], {source: 'client'});
expect(movie.serialize({attributeSelector: {actors: true}})).toStrictEqual({
__component: 'Movie',
actors: [{__component: 'Actor', name: 'Leonardo DiCaprio', country: 'USA'}]
});
expect(movie.serialize({attributeSelector: {actors: true}, target: 'client'})).toStrictEqual({
__component: 'Movie'
});
movie.actors[0].country = 'US';
expect(movie.serialize({attributeSelector: {actors: true}, target: 'client'})).toStrictEqual({
__component: 'Movie',
actors: [{__component: 'Actor', name: 'Leonardo DiCaprio', country: 'US'}]
});
});
test('Identifiable component instances', async () => {
class Movie extends Component {
@primaryIdentifier() id!: string;
@secondaryIdentifier() slug!: string;
@attribute('string') title = '';
}
let movie = Movie.fork().instantiate({id: 'abc123'});
movie.title = 'Inception';
expect(movie.serialize()).toEqual({
__component: 'Movie',
id: 'abc123',
title: 'Inception'
});
expect(movie.serialize({returnComponentReferences: true})).toEqual({
__component: 'Movie',
id: 'abc123'
});
movie = Movie.fork().instantiate({slug: 'inception'});
movie.title = 'Inception';
expect(movie.serialize()).toEqual({
__component: 'Movie',
slug: 'inception',
title: 'Inception'
});
expect(movie.serialize({returnComponentReferences: true})).toEqual({
__component: 'Movie',
slug: 'inception'
});
movie = Movie.fork().instantiate({id: 'abc123'});
movie.slug = 'inception';
movie.title = 'Inception';
expect(movie.serialize()).toEqual({
__component: 'Movie',
id: 'abc123',
slug: 'inception',
title: 'Inception'
});
expect(movie.serialize({returnComponentReferences: true})).toEqual({
__component: 'Movie',
id: 'abc123'
});
// - Value sourcing -
movie = Movie.fork().instantiate({id: 'abc123'}, {source: 'client'});
movie.getAttribute('title').setValue('Inception', {source: 'client'});
expect(movie.serialize()).toStrictEqual({
__component: 'Movie',
id: 'abc123',
title: 'Inception'
});
expect(movie.serialize({target: 'client'})).toStrictEqual({
__component: 'Movie',
id: 'abc123'
});
// --- With referenced identifiable component instances ---
class Cinema extends Component {
@provide() static Movie = Movie;
@primaryIdentifier() id!: string;
@attribute('string') name = '';
@attribute('Movie[]') movies!: Movie[];
}
movie = Movie.instantiate({id: 'abc123'});
movie.title = 'Inception';
const cinema = Cinema.instantiate({id: 'xyz456'});
cinema.name = 'Paradiso';
cinema.movies = [movie];
expect(cinema.serialize()).toEqual({
__component: 'Cinema',
id: 'xyz456',
name: 'Paradiso',
movies: [{__component: 'Movie', id: 'abc123'}]
});
let componentDependencies: ComponentSet = new Set();
expect(cinema.serialize({componentDependencies})).toEqual({
__component: 'Cinema',
id: 'xyz456',
name: 'Paradiso',
movies: [{__component: 'Movie', id: 'abc123'}]
});
expect(Array.from(componentDependencies)).toEqual([Cinema, Movie]);
componentDependencies = new Set();
expect(cinema.serialize({returnComponentReferences: true, componentDependencies})).toEqual({
__component: 'Cinema',
id: 'xyz456'
});
expect(Array.from(componentDependencies)).toEqual([Cinema, Movie]);
// - With an array of components -
const serializedComponents: ComponentSet = new Set();
componentDependencies = new Set();
expect(
serialize([cinema, movie, movie], {serializedComponents, componentDependencies})
).toEqual([
{
__component: 'Cinema',
id: 'xyz456',
name: 'Paradiso',
movies: [{__component: 'Movie', id: 'abc123'}]
},
{__component: 'Movie', id: 'abc123', title: 'Inception'},
{__component: 'Movie', id: 'abc123'}
]);
expect(Array.from(serializedComponents)).toEqual([cinema, movie]);
expect(Array.from(componentDependencies)).toEqual([Cinema, Movie]);
// - Using 'returnComponentReferences' option -
expect(
serialize(
{
'<=': cinema,
'play=>': {'()': [movie]}
},
{returnComponentReferences: true}
)
).toEqual({
'<=': {__component: 'Cinema', id: 'xyz456'},
'play=>': {'()': [{__component: 'Movie', id: 'abc123'}]}
});
});
test('Functions', async () => {
function sum(a: number, b: number) {
return a + b;
}
expect(serialize(sum)).toStrictEqual({});
expect(trimSerializedFunction(serialize(sum, {serializeFunctions: true}))).toStrictEqual({
__function: 'function sum(a, b) {\nreturn a + b;\n}'
});
sum.displayName = 'sum';
expect(serialize(sum)).toStrictEqual({displayName: 'sum'});
expect(trimSerializedFunction(serialize(sum, {serializeFunctions: true}))).toStrictEqual({
__function: 'function sum(a, b) {\nreturn a + b;\n}',
displayName: 'sum'
});
function trimSerializedFunction(serializedFunction: any) {
return {
...serializedFunction,
__function: serializedFunction.__function.replace(/\n +/g, '\n')
};
}
});
});
================================================
FILE: packages/component/src/serialization.ts
================================================
import {
serialize as simpleSerialize,
SerializeOptions as SimpleSerializeOptions,
SerializeResult
} from 'simple-serialization';
import {possiblyAsync} from 'possibly-async';
import {isES2015Class} from 'core-helpers';
import type {ComponentSet} from './component';
import type {PropertyFilter, AttributeSelector, ValueSource} from './properties';
import {isValidatorInstance} from './validation/validator';
import {isComponentClassOrInstance} from './utilities';
export type SerializeOptions = SimpleSerializeOptions & {
attributeSelector?: AttributeSelector;
attributeFilter?: PropertyFilter;
serializedComponents?: ComponentSet;
componentDependencies?: ComponentSet;
serializeFunctions?: boolean;
returnComponentReferences?: boolean;
ignoreEmptyComponents?: boolean;
includeComponentTypes?: boolean;
includeIsNewMarks?: boolean;
includeReferencedComponents?: boolean;
target?: ValueSource;
};
/**
* Serializes any type of values including objects, arrays, dates, and components (using Component's `serialize()` [class method](https://layrjs.com/docs/v2/reference/component#serialize-class-method) and [instance method](https://layrjs.com/docs/v2/reference/component#serialize-instance-method)).
*
* @param value A value of any type.
* @param [options.attributeFilter] A (possibly async) function used to filter the component attributes to be serialized. The function is invoked for each attribute with an [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) instance as first argument.
* @param [options.target] The target of the serialization (default: `undefined`).
*
* @returns The serialized value.
*
* @example
* ```
* import {serialize} from '﹫layr/component';
*
* const data = {
* createdOn: new Date(),
* updatedOn: undefined,
* movie: new Movie({title: 'Inception'})
* };
*
* console.log(serialize(data));
*
* // Should output something like:
* // {
* // createdOn: {__date: "2020-07-18T23:43:33.778Z"},
* // updatedOn: {__undefined: true},
* // movie: {__component: 'Movie', title: 'Inception'}
* // }
* ```
*
* @category Serialization
* @possiblyasync
*/
export function serialize(value: Value, options?: SerializeOptions): SerializeResult;
export function serialize(value: any, options: SerializeOptions = {}) {
const {
serializedComponents = new Set(),
objectSerializer: originalObjectSerializer,
functionSerializer: originalFunctionSerializer,
serializeFunctions = false,
...otherOptions
} = options;
const objectSerializer = function (object: object): object | void {
if (originalObjectSerializer !== undefined) {
const serializedObject = originalObjectSerializer(object);
if (serializedObject !== undefined) {
return serializedObject;
}
}
if (isComponentClassOrInstance(object)) {
return object.serialize({...options, serializedComponents});
}
if (isValidatorInstance(object)) {
return object.serialize(serialize);
}
};
let functionSerializer: SerializeOptions['functionSerializer'];
if (serializeFunctions) {
functionSerializer = function (func) {
if (originalFunctionSerializer !== undefined) {
const serializedFunction = originalFunctionSerializer(func);
if (serializedFunction !== undefined) {
return serializedFunction;
}
}
if (isES2015Class(func)) {
throw new Error('Cannot serialize a class');
}
const functionCode = serializeFunction(func);
const serializedFunction = {__function: functionCode};
return possiblyAsync(
possiblyAsync.mapValues(func as any, (attributeValue) =>
simpleSerialize(attributeValue, {...otherOptions, objectSerializer, functionSerializer})
),
(serializedAttributes) => {
Object.assign(serializedFunction, serializedAttributes);
return serializedFunction;
}
);
};
}
return simpleSerialize(value, {...otherOptions, objectSerializer, functionSerializer});
}
export function serializeFunction(func: Function) {
let sourceCode = func.toString();
// Clean functions generated by `new Function()`
if (sourceCode.startsWith('function anonymous(\n)')) {
sourceCode = 'function ()' + sourceCode.slice('function anonymous(\n)'.length);
}
return sourceCode;
}
================================================
FILE: packages/component/src/utilities.test.ts
================================================
import {Component} from './component';
import {
isComponentClass,
isComponentInstance,
isComponentClassOrInstance,
isComponentName,
isComponentType
} from './utilities';
describe('Utilities', () => {
test('isComponentClass()', async () => {
expect(isComponentClass(undefined)).toBe(false);
expect(isComponentClass(null)).toBe(false);
expect(isComponentClass(true)).toBe(false);
expect(isComponentClass(1)).toBe(false);
expect(isComponentClass({})).toBe(false);
class Movie extends Component {}
expect(isComponentClass(Movie)).toBe(true);
expect(isComponentClass(Movie.prototype)).toBe(false);
const movie = new Movie();
expect(isComponentClass(movie)).toBe(false);
});
test('isComponentInstance()', async () => {
expect(isComponentInstance(undefined)).toBe(false);
expect(isComponentInstance(null)).toBe(false);
expect(isComponentInstance(true)).toBe(false);
expect(isComponentInstance(1)).toBe(false);
expect(isComponentInstance({})).toBe(false);
class Movie extends Component {}
expect(isComponentInstance(Movie.prototype)).toBe(true);
const movie = new Movie();
expect(isComponentInstance(movie)).toBe(true);
});
test('isComponentClassOrInstance()', async () => {
expect(isComponentClassOrInstance(undefined)).toBe(false);
expect(isComponentClassOrInstance(null)).toBe(false);
expect(isComponentClassOrInstance(true)).toBe(false);
expect(isComponentClassOrInstance(1)).toBe(false);
expect(isComponentClassOrInstance({})).toBe(false);
class Movie extends Component {}
expect(isComponentClassOrInstance(Movie)).toBe(true);
expect(isComponentClassOrInstance(Movie.prototype)).toBe(true);
const movie = new Movie();
expect(isComponentClassOrInstance(movie)).toBe(true);
});
test('isComponentName()', async () => {
expect(isComponentName('Movie')).toBe(true);
expect(isComponentName('Movie2')).toBe(true);
expect(isComponentName('MotionPicture')).toBe(true);
expect(isComponentName('Prefix_Movie')).toBe(true);
expect(isComponentName('$Movie')).toBe(false);
expect(isComponentName('_Movie')).toBe(false);
expect(isComponentName('Movie!')).toBe(false);
expect(isComponentName('1Place')).toBe(false);
});
test('isComponentType()', async () => {
expect(isComponentType('typeof Movie')).toBe('componentClassType');
expect(isComponentType('Movie')).toBe('componentInstanceType');
expect(isComponentType('typeof MotionPicture')).toBe('componentClassType');
expect(isComponentType('MotionPicture')).toBe('componentInstanceType');
expect(isComponentType('typeof Prefix_Movie')).toBe('componentClassType');
expect(isComponentType('Prefix_Movie')).toBe('componentInstanceType');
expect(isComponentType('$Movie')).toBe(false);
expect(isComponentType('_Movie')).toBe(false);
expect(isComponentType('Movie!')).toBe(false);
expect(isComponentType('1Place')).toBe(false);
expect(isComponentType('typeof Movie', {allowClasses: false})).toBe(false);
expect(isComponentType('Movie', {allowInstances: false})).toBe(false);
});
});
================================================
FILE: packages/component/src/utilities.ts
================================================
import {isES2015Class, getFunctionName, getTypeOf} from 'core-helpers';
import compact from 'lodash/compact';
import type {Component, ComponentMixin} from './component';
/**
* Returns whether the specified value is a component class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isComponentClass(value: any): value is typeof Component {
return typeof value?.isComponent === 'function';
}
/**
* Returns whether the specified value is a component instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isComponentInstance(value: any): value is Component {
return typeof value?.constructor?.isComponent === 'function';
}
/**
* Returns whether the specified value is a component class or instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isComponentClassOrInstance(value: any): value is typeof Component | Component {
return (
typeof value?.isComponent === 'function' ||
typeof value?.constructor?.isComponent === 'function'
);
}
/**
* Throws an error if the specified value is not a component class.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsComponentClass(value: any): asserts value is typeof Component {
if (!isComponentClass(value)) {
throw new Error(
`Expected a component class, but received a value of type '${getTypeOf(value)}'`
);
}
}
/**
* Throws an error if the specified value is not a component instance.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsComponentInstance(value: any): asserts value is Component {
if (!isComponentInstance(value)) {
throw new Error(
`Expected a component instance, but received a value of type '${getTypeOf(value)}'`
);
}
}
/**
* Throws an error if the specified value is not a component class or instance.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsComponentClassOrInstance(
value: any
): asserts value is typeof Component | Component {
if (!isComponentClassOrInstance(value)) {
throw new Error(
`Expected a component class or instance, but received a value of type '${getTypeOf(value)}'`
);
}
}
/**
* Ensures that the specified component is a class. If you specify a component instance (or prototype), the class of the component is returned. If you specify a component class, it is returned as is.
*
* @param component A component class or instance.
*
* @returns A component class.
*
* @example
* ```
* ensureComponentClass(movie) => Movie
* ensureComponentClass(Movie.prototype) => Movie
* ensureComponentClass(Movie) => Movie
* ```
*
* @category Utilities
*/
export function ensureComponentClass(component: any) {
if (isComponentClass(component)) {
return component;
}
if (isComponentInstance(component)) {
return component.constructor as typeof Component;
}
throw new Error(
`Expected a component class or instance, but received a value of type '${getTypeOf(component)}'`
);
}
/**
* Ensures that the specified component is an instance (or prototype). If you specify a component class, the component prototype is returned. If you specify a component instance (or prototype), it is returned as is.
*
* @param component A component class or instance.
*
* @returns A component instance (or prototype).
*
* @example
* ```
* ensureComponentInstance(Movie) => Movie.prototype
* ensureComponentInstance(Movie.prototype) => Movie.prototype
* ensureComponentInstance(movie) => movie
* ```
*
* @category Utilities
*/
export function ensureComponentInstance(component: any) {
if (isComponentClass(component)) {
return component.prototype;
}
if (isComponentInstance(component)) {
return component;
}
throw new Error(
`Expected a component class or instance, but received a value of type '${getTypeOf(component)}'`
);
}
const COMPONENT_NAME_PATTERN = /^[A-Z][A-Za-z0-9_]*$/;
/**
* Returns whether the specified string is a valid component name. The rule is the same as for typical JavaScript class names.
*
* @param name The string to check.
*
* @returns A boolean.
*
* @example
* ```
* isComponentName('Movie') => true
* isComponentName('Movie123') => true
* isComponentName('Awesome_Movie') => true
* isComponentName('123Movie') => false
* isComponentName('Awesome-Movie') => false
* isComponentName('movie') => false
* ```
*
* @category Utilities
*/
export function isComponentName(name: string) {
return COMPONENT_NAME_PATTERN.test(name);
}
/**
* Throws an error if the specified string is not a valid component name.
*
* @param name The string to check.
*
* @category Utilities
*/
export function assertIsComponentName(name: string) {
if (name === '') {
throw new Error('A component name cannot be empty');
}
if (!isComponentName(name)) {
throw new Error(`The specified component name ('${name}') is invalid`);
}
}
/**
* Transforms a component class type into a component name.
*
* @param name A string representing a component class type.
*
* @returns A component name.
*
* @example
* ```
* getComponentNameFromComponentClassType('typeof Movie') => 'Movie'
* ```
*
* @category Utilities
*/
export function getComponentNameFromComponentClassType(type: string) {
assertIsComponentType(type, {allowInstances: false});
return type.slice('typeof '.length);
}
/**
* Transforms a component instance type into a component name.
*
* @param name A string representing a component instance type.
*
* @returns A component name.
*
* @example
* ```
* getComponentNameFromComponentInstanceType('Movie') => 'Movie'
* ```
*
* @category Utilities
*/
export function getComponentNameFromComponentInstanceType(type: string) {
assertIsComponentType(type, {allowClasses: false});
return type;
}
const COMPONENT_CLASS_TYPE_PATTERN = /^typeof [A-Z][A-Za-z0-9_]*$/;
const COMPONENT_INSTANCE_TYPE_PATTERN = /^[A-Z][A-Za-z0-9_]*$/;
/**
* Returns whether the specified string is a valid component type.
*
* @param name The string to check.
* @param [options.allowClasses] A boolean specifying whether component class types are allowed (default: `true`).
* @param [options.allowInstances] A boolean specifying whether component instance types are allowed (default: `true`).
*
* @returns A boolean.
*
* @example
* ```
* isComponentType('typeof Movie') => true
* isComponentType('Movie') => true
* isComponentType('typeof Awesome-Movie') => false
* isComponentType('movie') => false
* isComponentType('typeof Movie', {allowClasses: false}) => false
* isComponentType('Movie', {allowInstances: false}) => false
* ```
*
* @category Utilities
*/
export function isComponentType(type: string, {allowClasses = true, allowInstances = true} = {}) {
if (allowClasses && COMPONENT_CLASS_TYPE_PATTERN.test(type)) {
return 'componentClassType';
}
if (allowInstances && COMPONENT_INSTANCE_TYPE_PATTERN.test(type)) {
return 'componentInstanceType';
}
return false;
}
/**
* Throws an error if the specified string is not a valid component type.
*
* @param name The string to check.
* @param [options.allowClasses] A boolean specifying whether component class types are allowed (default: `true`).
* @param [options.allowInstances] A boolean specifying whether component instance types are allowed (default: `true`).
*
* @category Utilities
*/
export function assertIsComponentType(
type: string,
{allowClasses = true, allowInstances = true} = {}
) {
if (type === '') {
throw new Error('A component type cannot be empty');
}
const isComponentTypeResult = isComponentType(type, {allowClasses, allowInstances});
if (isComponentTypeResult === false) {
throw new Error(`The specified component type ('${type}') is invalid`);
}
return isComponentTypeResult;
}
/**
* Transforms a component name into a component class type.
*
* @param name A component name.
*
* @returns A component class type.
*
* @example
* ```
* getComponentClassTypeFromComponentName('Movie') => 'typeof Movie'
* ```
*
* @category Utilities
*/
export function getComponentClassTypeFromComponentName(name: string) {
assertIsComponentName(name);
return `typeof ${name}`;
}
/**
* Transforms a component name into a component instance type.
*
* @param name A component name.
*
* @returns A component instance type.
*
* @example
* ```
* getComponentInstanceTypeFromComponentName('Movie') => 'Movie'
* ```
*
* @category Utilities
*/
export function getComponentInstanceTypeFromComponentName(name: string) {
assertIsComponentName(name);
return name;
}
type ComponentMap = {[name: string]: typeof Component};
export function createComponentMap(components: typeof Component[] = []) {
const componentMap: ComponentMap = Object.create(null);
for (const component of components) {
assertIsComponentClass(component);
componentMap[component.getComponentName()] = component;
}
return componentMap;
}
export function getComponentFromComponentMap(componentMap: ComponentMap, name: string) {
assertIsComponentName(name);
const component = componentMap[name];
if (component === undefined) {
throw new Error(`The component '${name}' is unknown`);
}
return component;
}
export function isComponentMixin(value: any): value is ComponentMixin {
return typeof value === 'function' && getFunctionName(value) !== '' && !isES2015Class(value);
}
export function assertIsComponentMixin(value: any): asserts value is ComponentMixin {
if (!isComponentMixin(value)) {
throw new Error(
`Expected a component mixin, but received a value of type '${getTypeOf(value)}'`
);
}
}
export function composeDescription(description: string[]) {
let composedDescription = compact(description).join(', ');
if (composedDescription !== '') {
composedDescription = ` (${composedDescription})`;
}
return composedDescription;
}
export function joinAttributePath(path: [string?, string?]) {
const compactedPath = compact(path);
if (compactedPath.length === 0) {
return '';
}
if (compactedPath.length === 1) {
return compactedPath[0];
}
const [first, second] = compactedPath;
if (second.startsWith('[')) {
return `${first}${second}`;
}
return `${first}.${second}`;
}
================================================
FILE: packages/component/src/validation/index.ts
================================================
export * from './utilities';
export * from './validator-builders';
export * from './validator';
================================================
FILE: packages/component/src/validation/utilities.test.ts
================================================
import {Component} from '../component';
import {Attribute} from '../properties';
import {Validator, isValidatorInstance} from './validator';
import {validators} from './validator-builders';
import {normalizeValidator} from './utilities';
describe('Utilities', () => {
test('Normalization', async () => {
class TestComponent extends Component {}
const attribute = new Attribute('testAttribute', TestComponent.prototype);
let validator: any = new Validator((value) => value > 0);
let normalizedValidator = normalizeValidator(validator, attribute);
expect(normalizedValidator).toBe(validator);
validator = validators.notEmpty();
normalizedValidator = normalizeValidator(validator, attribute);
expect(normalizedValidator).toBe(validator);
validator = validators.notEmpty;
expect(() => normalizeValidator(validator, attribute)).toThrow(
"The specified validator is a validator builder that has not been called (attribute: 'TestComponent.prototype.testAttribute')"
);
validator = (value: number) => value > 0;
normalizedValidator = normalizeValidator(validator, attribute);
expect(isValidatorInstance(normalizedValidator));
expect(normalizedValidator.getName()).toBe('validator');
expect(normalizedValidator.getFunction()).toBe(validator);
expect(normalizedValidator.getArguments()).toEqual([]);
expect(normalizedValidator.getMessage()).toBe('The validator `validator()` failed');
});
});
================================================
FILE: packages/component/src/validation/utilities.ts
================================================
import {validators, ValidatorBuilder} from './validator-builders';
import {Validator, ValidatorFunction, isValidatorInstance} from './validator';
import type {Attribute} from '../properties';
export function normalizeValidator(validator: Validator | ValidatorFunction, attribute: Attribute) {
if (isValidatorInstance(validator)) {
return validator;
}
if (typeof validator !== 'function') {
throw new Error(`The specified validator is not a function (${attribute.describe()})`);
}
if (Object.values(validators).includes((validator as unknown) as ValidatorBuilder)) {
throw new Error(
`The specified validator is a validator builder that has not been called (${attribute.describe()})`
);
}
return new Validator(validator);
}
================================================
FILE: packages/component/src/validation/validator-builders.test.ts
================================================
import {isValidatorInstance} from './validator';
import {validators} from './validator-builders';
describe('Validator builders', () => {
test('Building validators', async () => {
let validator = validators.notEmpty();
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('notEmpty');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments()).toEqual([]);
expect(validator.getMessage()).toBe('The validator `notEmpty()` failed');
validator = validators.minLength(5);
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('minLength');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments()).toEqual([5]);
expect(validator.getMessage()).toBe('The validator `minLength(5)` failed');
validator = validators.minLength(5, 'The minimum length is 5');
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('minLength');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments()).toEqual([5]);
expect(validator.getMessage()).toBe('The minimum length is 5');
expect(() => validators.minLength()).toThrow(
"A required parameter is missing to build the validator 'minLength'"
);
expect(() => validators.minLength(5, 10)).toThrow(
"When building a validator, if an extra parameter is specified, it must be a string representing the failed validation message (validator: 'minLength')"
);
validator = validators.anyOf([1, 2, 3]);
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('anyOf');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments()).toEqual([[1, 2, 3]]);
expect(validator.getMessage()).toBe('The validator `anyOf([1,2,3])` failed');
const missingValidator = validators.missing();
const minLengthValidator = validators.minLength(5);
validator = validators.either([missingValidator, minLengthValidator]);
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('either');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments()).toEqual([[missingValidator, minLengthValidator]]);
expect(validator.getMessage()).toBe('The validator `either([missing(),minLength(5)])` failed');
});
test('Running built-in validators', async () => {
expect(validators.required().run(1)).toBe(true);
expect(validators.required().run(undefined)).toBe(false);
expect(validators.anyOf([1, 2, 3]).run(1)).toBe(true);
expect(validators.anyOf([1, 2, 3]).run(5)).toBe(false);
expect(validators.positive().run(1)).toBe(true);
expect(validators.positive().run(-1)).toBe(false);
expect(validators.lessThan(5).run(3)).toBe(true);
expect(validators.lessThan(5).run(7)).toBe(false);
expect(validators.range([5, 10]).run(7)).toBe(true);
expect(validators.range([5, 10]).run(3)).toBe(false);
expect(validators.notEmpty().run('abc')).toBe(true);
expect(validators.notEmpty().run('')).toBe(false);
expect(validators.notEmpty().run([1])).toBe(true);
expect(validators.notEmpty().run([])).toBe(false);
expect(validators.maxLength(3).run('abc')).toBe(true);
expect(validators.maxLength(3).run('abcd')).toBe(false);
expect(validators.maxLength(3).run([1, 2, 3])).toBe(true);
expect(validators.maxLength(3).run([1, 2, 3, 4])).toBe(false);
expect(validators.match(/b/).run('abc')).toBe(true);
expect(validators.match(/e/).run('abc')).toBe(false);
expect(validators.either([validators.missing(), validators.minLength(3)]).run(undefined)).toBe(
true
);
expect(validators.either([validators.missing(), validators.minLength(3)]).run('abc')).toBe(
true
);
expect(validators.either([validators.missing(), validators.minLength(3)]).run('ab')).toBe(
false
);
expect(validators.optional(validators.minLength(3)).run(undefined)).toBe(true);
expect(validators.optional(validators.minLength(3)).run('abc')).toBe(true);
expect(validators.optional(validators.minLength(3)).run('ab')).toBe(false);
});
});
================================================
FILE: packages/component/src/validation/validator-builders.ts
================================================
import {Validator, ValidatorFunction} from './validator';
const validatorFunctions: {[name: string]: ValidatorFunction} = {
// Numbers
integer: (value) => value !== undefined && Number.isInteger(value),
positive: (value) => value !== undefined && value >= 0,
negative: (value) => value !== undefined && value < 0,
lessThan: (value, number) => value !== undefined && value < number,
lessThanOrEqual: (value, number) => value !== undefined && value <= number,
greaterThan: (value, number) => value !== undefined && value > number,
greaterThanOrEqual: (value, number) => value !== undefined && value >= number,
range: (value, [min, max]) => value !== undefined && value >= min && value <= max,
// Strings and arrays
notEmpty: (value) => value !== undefined && value.length > 0,
minLength: (value, minLength) => value !== undefined && value.length >= minLength,
maxLength: (value, maxLength) => value !== undefined && value.length <= maxLength,
rangeLength: (value, [minLength, maxLength]) =>
value !== undefined && value.length >= minLength && value.length <= maxLength,
// Strings
match: (value, pattern) => value !== undefined && pattern.test(value),
// Any values
required: (value) => value !== undefined,
missing: (value) => value === undefined,
anyOf: (value, array) => array.includes(value),
noneOf: (value, array) => !array.includes(value),
// Operators
either: (value, validators: Validator[]) => {
return validators.some((validator) => validator.run(value));
},
optional: (value, validators: Validator[] | Validator) => {
if (!Array.isArray(validators)) {
validators = [validators];
}
return value === undefined || validators.every((validator) => validator.run(value));
}
};
export type ValidatorBuilder = (...args: any[]) => Validator;
export const validators: {[name: string]: ValidatorBuilder} = {};
for (const [name, func] of Object.entries(validatorFunctions)) {
validators[name] = (...args) => createValidator(name, func, args);
}
function createValidator(name: string, func: ValidatorFunction, args: any[]) {
const numberOfRequiredArguments = func.length - 1;
const validatorArguments = args.slice(0, numberOfRequiredArguments);
if (validatorArguments.length < numberOfRequiredArguments) {
throw new Error(`A required parameter is missing to build the validator '${name}'`);
}
const [message] = args.slice(numberOfRequiredArguments);
if (message !== undefined && typeof message !== 'string') {
throw new Error(
`When building a validator, if an extra parameter is specified, it must be a string representing the failed validation message (validator: '${name}')`
);
}
return new Validator(func, {name, arguments: validatorArguments, message});
}
================================================
FILE: packages/component/src/validation/validator.test.ts
================================================
import {Validator, isValidatorInstance, runValidators} from './validator';
import {serialize} from '../serialization';
import {deserialize} from '../deserialization';
describe('Validator', () => {
test('Creation', async () => {
const notEmpty = (value: string | any[]) => value.length > 0;
let validator = new Validator(notEmpty);
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('notEmpty');
expect(validator.getFunction()).toBe(notEmpty);
expect(validator.getArguments()).toEqual([]);
expect(validator.getMessage()).toBe('The validator `notEmpty()` failed');
expect(validator.getMessage({generateIfMissing: false})).toBeUndefined();
const greaterThan = (value: number, number: number) => value > number;
validator = new Validator(greaterThan, {arguments: [5]});
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('greaterThan');
expect(validator.getFunction()).toBe(greaterThan);
expect(validator.getArguments()).toEqual([5]);
expect(validator.getMessage()).toBe('The validator `greaterThan(5)` failed');
expect(validator.getMessage({generateIfMissing: false})).toBeUndefined();
const match = (value: string, pattern: RegExp) => pattern.test(value);
const regExp = /abc/gi;
validator = new Validator(match, {arguments: [regExp]});
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('match');
expect(validator.getFunction()).toBe(match);
expect(validator.getArguments()).toEqual([regExp]);
expect(validator.getMessage()).toBe('The validator `match(/abc/gi)` failed');
expect(validator.getMessage({generateIfMissing: false})).toBeUndefined();
const validatorFunction = (value: number, number: number) => value < number;
validator = new Validator(validatorFunction, {
name: 'lessThanOrEqual5',
message: 'The maximum value is 5'
});
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('lessThanOrEqual5');
expect(validator.getFunction()).toBe(validatorFunction);
expect(validator.getArguments()).toEqual([]);
expect(validator.getMessage()).toBe('The maximum value is 5');
expect(validator.getMessage({generateIfMissing: false})).toBe('The maximum value is 5');
});
test('Execution', async () => {
const validatorFunction = (value: number, number: number) => value > number;
const validator = new Validator(validatorFunction, {
name: 'greaterThan',
arguments: [5],
message: 'The value is not greater than 5'
});
expect(validator.run(7)).toBe(true);
expect(validator.run(3)).toBe(false);
expect(runValidators([validator], 7)).toEqual([]);
expect(runValidators([validator], 3)).toEqual([validator]);
});
test('Serialization', async () => {
const greaterThan = (value: number, number: number) => value > number;
const greaterThanValidator = new Validator(greaterThan, {
name: 'greaterThan',
arguments: [5],
message: 'The value is not greater than 5'
});
const serializedGreaterThanValidator = greaterThanValidator.serialize(serialize);
expect(serializedGreaterThanValidator).toStrictEqual({
__validator: {
name: 'greaterThan',
function: {__function: '(value, number) => value > number'},
arguments: [5],
message: 'The value is not greater than 5'
}
});
const optional = (value: any, validator: Validator) =>
value === undefined || validator.run(value);
const optionalValidator = new Validator(optional, {
name: 'optional',
arguments: [greaterThanValidator]
});
const serializedOptionalValidator = optionalValidator.serialize(serialize);
expect(serializedOptionalValidator).toStrictEqual({
__validator: {
name: 'optional',
function: {__function: '(value, validator) => value === undefined || validator.run(value)'},
arguments: [serializedGreaterThanValidator]
}
});
});
test('Deserialization', async () => {
const validator = Validator.recreate(
{
__validator: {
name: 'optional',
function: {
__function: '(value, validator) => value === undefined || validator.run(value)'
},
arguments: [
{
__validator: {
name: 'greaterThan',
function: {__function: '(value, number) => value > number'},
arguments: [5],
message: 'The value is not greater than 5'
}
}
]
}
},
deserialize
);
expect(isValidatorInstance(validator));
expect(validator.getName()).toBe('optional');
expect(typeof validator.getFunction()).toBe('function');
expect(validator.getArguments().length).toBe(1);
expect(validator.getArguments()[0].getName()).toBe('greaterThan');
expect(typeof validator.getArguments()[0].getFunction()).toBe('function');
expect(validator.getArguments()[0].getArguments()).toEqual([5]);
expect(validator.getArguments()[0].getMessage()).toBe('The value is not greater than 5');
expect(validator.getMessage()).toBe('The validator `optional(greaterThan(5))` failed');
});
});
================================================
FILE: packages/component/src/validation/validator.ts
================================================
import {hasOwnProperty, getFunctionName} from 'core-helpers';
export type ValidatorFunction = (value: any, ...args: any[]) => boolean;
type ValidatorOptions = {
name?: string;
arguments?: any[];
message?: string;
};
/**
* A class to handle the validation of the component attributes.
*
* #### Usage
*
* You shouldn't have to create a `Validator` instance directly. Instead, when you define an attribute (using a decorator such as [`@attribute()`](https://layrjs.com/docs/v2/reference/component#attribute-decorator)), you can invoke some [built-in validator builders](https://layrjs.com/docs/v2/reference/validator#built-in-validator-builders) or specify your own [custom validation functions](https://layrjs.com/docs/v2/reference/validator#custom-validation-functions) that will be automatically transformed into `Validator` instances.
*
* **Example:**
*
* ```
* // JS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty, maxLength, integer, greaterThan} = validators;
*
* class Movie extends Component {
* // An attribute of type 'string' that cannot be empty or exceed 30 characters
* ﹫attribute('string', {validators: [notEmpty(), maxLength(30)]}) title;
*
* // An attribute of type 'number' that must an integer greater than 0
* ﹫attribute('number', {validators: [integer(), greaterThan(0)]}) reference;
*
* // An array attribute that can contain up to 5 non-empty strings
* ﹫attribute('string[]', {validators: [maxLength(5)], items: {validators: [notEmpty()]}})
* tags = [];
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute, validators} from '﹫layr/component';
*
* const {notEmpty, maxLength, integer, greaterThan} = validators;
*
* class Movie extends Component {
* // An attribute of type 'string' that cannot be empty or exceed 30 characters
* ﹫attribute('string', {validators: [notEmpty(), maxLength(30)]}) title!: string;
*
* // An attribute of type 'number' that must an integer greater than 0
* ﹫attribute('number', {validators: [integer(), greaterThan(0)]}) reference!: number;
*
* // An array attribute that can contain up to 5 non-empty strings
* ﹫attribute('string[]', {validators: [maxLength(5)], items: {validators: [notEmpty()]}})
* tags: string[] = [];
* }
* ```
*
* In case you want to access the `Validator` instances that were created under the hood, you can do the following:
*
* ```
* const movie = new Movie({ ... });
*
* movie.getAttribute('title').getValueType().getValidators();
* // => [notEmptyValidator, maxLengthValidator]
*
* movie.getAttribute('reference').getValueType().getValidators();
* // => [integerValidator, greaterThanValidator]
*
* movie.getAttribute('tags').getValueType().getValidators();
* // => [maxLengthValidator]
*
* movie.getAttribute('tags').getValueType().getItemType().getValidators();
* // => [notEmptyValidator]
* ```
*
* #### Built-In Validator Builders
*
* Layr provides a number of validator builders that can be used when you define your component attributes. See an [example of use](https://layrjs.com/docs/v2/reference/validator#usage) above.
*
* ##### Numbers
*
* The following validator builders can be used to validate numbers:
*
* * `integer()`: Ensures that a number is an integer.
* * `positive()`: Ensures that a number is greater than or equal to 0.
* * `negative()`: Ensures that a number is less than 0.
* * `lessThan(value)`: Ensures that a number is less than the specified value.
* * `lessThanOrEqual(value)`: Ensures that a number is less than or equal to the specified value.
* * `greaterThan(value)`: Ensures that a number is greater than the specified value.
* * `greaterThanOrEqual(value)`: Ensures that a number is greater than or equal to the specified value.
* * `range([min, max])`: Ensures that a number is in the specified inclusive range.
* * `anyOf(arrayOfNumbers)`: Ensures that a number is any of the specified numbers.
* * `noneOf(arrayOfNumbers)`: Ensures that a number is none of the specified numbers.
*
* ##### Strings
*
* The following validator builders can be used to validate strings:
*
* * `notEmpty()`: Ensures that a string is not empty.
* * `minLength(value)`: Ensures that a string has at least the specified number of characters.
* * `maxLength(value)`: Ensures that a string doesn't exceed the specified number of characters.
* * `rangeLength([min, max])`: Ensures that the length of a string is in the specified inclusive range.
* * `match(regExp)`: Ensures that a string matches the specified [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions).
* * `anyOf(arrayOfStrings)`: Ensures that a string is any of the specified strings.
* * `noneOf(arrayOfStrings)`: Ensures that a string is none of the specified strings.
*
* ##### Arrays
*
* The following validator builders can be used to validate arrays:
*
* * `notEmpty()`: Ensures that an array is not empty.
* * `minLength(value)`: Ensures that an array has at least the specified number of items.
* * `maxLength(value)`: Ensures that an array doesn't exceed the specified number of items.
* * `rangeLength([min, max])`: Ensures that the length of an array is in the specified inclusive range.
*
* ##### Any Type
*
* The following validator builder can be used to validate any type of values:
*
* * `required()`: Ensures that a value is not undefined.
* * `missing()`: Ensures that a value is undefined.
*
* ##### Validator Operators
*
* You can compose several validators using some validator operators:
*
* * `either(arrayOfValidators)`: Performs a logical **OR** operation on an array of validators.
* * `optional(validatorOrArrayOfValidators)`: If a value is is not undefined, ensures that it satisfies the specified validators (can be a single validator or an array of validators).
*
* ##### Custom Failed Validation Message
*
* You can pass an additional parameter to all the built-in validators builder to customize the message of the error that is thrown in case of failed validation.
*
* **Example:**
*
* ```
* maxLength(16, 'A username cannot exceed 16 characters');
* ```
*
* #### Custom Validation Functions
*
* In addition to the [built-in validator builders](https://layrjs.com/docs/v2/reference/validator#built-in-validator-builders), you can validate your component attributes with your own custom validation functions.
*
* A custom validation function takes a value as first parameter and returns a boolean indicating whether the validation has succeeded or not.
*
* **Example:**
*
* ```
* // JS
*
* import {Component, attribute} from '﹫layr/component';
*
* class OddNumber extends Component {
* // Ensures that the value is an odd number
* ﹫attribute('number', {validators: [(value) => value % 2 === 1]}) value;
* }
* ```
*
* ```
* // TS
*
* import {Component, attribute} from '﹫layr/component';
*
* class OddNumber extends Component {
* // Ensures that the value is an odd number
* ﹫attribute('number', {validators: [(value) => value % 2 === 1]}) value!: number;
* }
* ```
*/
export class Validator {
_function: ValidatorFunction;
_name: string;
_arguments: any[];
_message: string | undefined;
constructor(func: ValidatorFunction, options: ValidatorOptions = {}) {
let {name, arguments: args = [], message} = options;
if (name === undefined) {
name = getFunctionName(func) || 'anonymous';
}
this._function = func;
this._name = name;
this._arguments = args;
this._message = message;
}
/**
* Returns the function associated to the validator.
*
* @returns A function.
*
* @example
* ```
* maxLength(8).getFunction();
* // => function (value, maxLength) { return value.length <= maxLength; }
* ```
*
* @category Methods
*/
getFunction() {
return this._function;
}
/**
* Returns the name of the validator.
*
* @returns A string.
*
* @example
* ```
* maxLength(8).getName(); // => 'maxLength'
* ```
*
* @category Methods
*/
getName() {
return this._name;
}
/**
* Returns the arguments of the validator.
*
* @returns An array of values of any type.
*
* @example
* ```
* maxLength(8).getArguments(); // => [8]
* ```
*
* @category Methods
*/
getArguments() {
return this._arguments;
}
getSignature(): string {
return `${this.getName()}(${stringifyArguments(this.getArguments())})`;
}
/**
* Returns the message of the error that is thrown in case of failed validation.
*
* @returns A string.
*
* @example
* ```
* maxLength(8).getMessage(); // => 'The validator maxLength(8) failed'
* ```
*
* @category Methods
*/
getMessage({generateIfMissing = true}: {generateIfMissing?: boolean} = {}) {
let message = this._message;
if (message === undefined && generateIfMissing) {
message = `The validator \`${this.getSignature()}\` failed`;
}
return message;
}
/**
* Runs the validator against the specified value.
*
* @returns `true` if the validation has succeeded, `false` otherwise.
*
* @example
* ```
* maxLength(8).run('1234567'); // => true
* maxLength(8).run('12345678'); // => true
* maxLength(8).run('123456789'); // => false
* ```
*
* @category Methods
*/
run(value: any) {
return this.getFunction()(value, ...this.getArguments());
}
serialize(serializer: Function) {
let serializedValidator: any = {
name: this.getName(),
function: this.getFunction()
};
const args = this.getArguments();
if (args.length > 0) {
serializedValidator.arguments = args;
}
if (this._message !== undefined) {
serializedValidator.message = this._message;
}
serializedValidator = {
__validator: serializer(serializedValidator, {serializeFunctions: true})
};
return serializedValidator;
}
static recreate(serializedValidator: any, deserializer: Function) {
const {
name,
function: func,
arguments: args,
message
} = deserializer(serializedValidator.__validator, {deserializeFunctions: true});
const validator = new this(func, {name, arguments: args, message});
return validator;
}
static isValidator(value: any): value is Validator {
return isValidatorInstance(value);
}
}
export function isValidatorInstance(value: any): value is Validator {
return typeof value?.constructor?.isValidator === 'function';
}
export function isSerializedValidator(object: object) {
return object !== undefined && hasOwnProperty(object, '__validator');
}
export function runValidators(validators: Validator[], value: any) {
const failedValidators: Validator[] = [];
for (const validator of validators) {
if (!validator.run(value)) {
failedValidators.push(validator);
}
}
return failedValidators;
}
function stringifyArguments(args: any[]) {
let string = JSON.stringify(args, (_key, value) => {
if (value instanceof RegExp) {
return `__regExp(${value.toString()})regExp__`;
}
if (value instanceof Validator) {
return `__validator(${value.getSignature()})validator__`;
}
return value;
});
// Fix RegExps
string = string.replace(/"__regExp\(/g, '');
string = string.replace(/\)regExp__"/g, '');
// Fix validator signatures
string = string.replace(/"__validator\(/g, '');
string = string.replace(/\)validator__"/g, '');
// Remove the array brackets
string = string.slice(1, -1);
return string;
}
================================================
FILE: packages/component/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-client/README.md
================================================
# @layr/component-client
Consumes Layr components served by @layr/component-server.
## Installation
```
npm install @layr/component-client
```
## License
MIT
================================================
FILE: packages/component-client/package.json
================================================
{
"name": "@layr/component-client",
"version": "2.0.86",
"description": "Consumes Layr components served by @layr/component-server",
"keywords": [
"layr",
"component",
"client"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-client",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/component-server": "^2.0.70",
"core-helpers": "^1.0.8",
"debug": "^4.3.4",
"lodash": "^4.17.21",
"microbatcher": "^2.0.8",
"possibly-async": "^1.0.7",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/debug": "^4.1.7",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191"
}
}
================================================
FILE: packages/component-client/src/component-client.test.ts
================================================
import {Component, isComponentClass} from '@layr/component';
import type {ComponentServerLike} from '@layr/component-server';
import isEqual from 'lodash/isEqual';
import {ComponentClient} from './component-client';
describe('ComponentClient', () => {
const server: ComponentServerLike = {
receive({query, components, version: clientVersion}) {
const serverVersion = 1;
if (clientVersion !== serverVersion) {
throw Object.assign(
new Error(
`The component client version (${clientVersion}) doesn't match the component server version (${serverVersion})`
),
{code: 'COMPONENT_CLIENT_VERSION_DOES_NOT_MATCH_COMPONENT_SERVER_VERSION'}
);
}
// client.getComponents()
if (
isEqual({query, components}, {query: {'introspect=>': {'()': []}}, components: undefined})
) {
return {
result: {
component: {
name: 'Backend',
providedComponents: [
{
name: 'Session',
properties: [
{
name: 'token',
type: 'Attribute',
valueType: 'string?',
value: {__undefined: true},
exposure: {get: true, set: true}
}
]
},
{
name: 'Movie',
mixins: ['Storable'],
properties: [{name: 'find', type: 'Method', exposure: {call: true}}],
prototype: {
properties: [
{
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'string',
default: {
__function: 'function () {\nreturn this.constructor.generateId();\n}'
},
exposure: {get: true, set: true}
},
{
name: 'slug',
type: 'SecondaryIdentifierAttribute',
valueType: 'string',
exposure: {get: true, set: true}
},
{
name: 'title',
type: 'Attribute',
valueType: 'string',
default: {__function: "function () {\nreturn '';\n}"},
validators: [
{
__validator: {
name: 'notEmpty',
function: {__function: '(value) => value.length > 0'}
}
}
],
exposure: {get: true, set: true}
},
{
name: 'isPlaying',
type: 'Attribute',
valueType: 'boolean',
exposure: {get: true}
},
{name: 'play', type: 'Method', exposure: {call: true}},
{name: 'validateTitle', type: 'Method', exposure: {call: true}}
]
},
consumedComponents: ['Session']
}
]
}
}
};
}
// Movie.find() without Session's token
if (
isEqual(
{query, components},
{
query: {
'<=': {__component: 'typeof Movie'},
'find=>': {'()': []}
},
components: [{__component: 'typeof Session', token: {__undefined: true}}]
}
)
) {
return {
result: {__error: 'Access denied'}
};
}
// Movie.find() with Session's token
if (
isEqual(
{query, components},
{
query: {
'<=': {__component: 'typeof Movie'},
'find=>': {'()': []}
},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: [
{
__component: 'Movie',
id: 'movie1',
slug: 'inception',
title: 'Inception',
isPlaying: false
},
{
__component: 'Movie',
id: 'movie2',
slug: 'the-matrix',
title: 'The Matrix',
isPlaying: false
}
]
};
}
// Movie.find({limit: 1})
if (
isEqual(
{query, components},
{
query: {
'<=': {__component: 'typeof Movie'},
'find=>': {'()': [{limit: 1}]}
},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: [
{
__component: 'Movie',
id: 'movie1',
slug: 'inception',
title: 'Inception',
isPlaying: false
}
]
};
}
// movie.play()
if (
isEqual(
{query, components},
{
query: {'<=': {__component: 'Movie', id: 'movie1'}, 'play=>': {'()': []}},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: {__component: 'Movie', id: 'movie1', isPlaying: true}
};
}
// movie.validateTitle('')
if (
isEqual(
{query, components},
{
query: {
'<=': {__component: 'Movie', id: 'movie1', title: ''},
'validateTitle=>': {'()': []}
},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: false
};
}
// movie.validateTitle('Inception 2')
if (
isEqual(
{query, components},
{
query: {
'<=': {__component: 'Movie', id: 'movie1', title: 'Inception 2'},
'validateTitle=>': {'()': []}
},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: true
};
}
// [movie1.play(), movie2.play()]
if (
isEqual(
{query, components},
{
query: {
'||': [
{'<=': {__component: 'Movie', id: 'movie1'}, 'play=>': {'()': []}},
{'<=': {__component: 'Movie', id: 'movie2'}, 'play=>': {'()': []}}
]
},
components: [{__component: 'typeof Session', token: 'abc123'}]
}
)
) {
return {
result: [
{__component: 'Movie', id: 'movie1', isPlaying: true},
{__component: 'Movie', id: 'movie2', isPlaying: true}
]
};
}
throw new Error(
`Received an unknown request (query: ${JSON.stringify(query)}, components: ${JSON.stringify(
components
)})`
);
}
};
const Storable = (Base = Component) => {
const _Storable = class extends Base {
static isStorable() {}
isStorable() {}
};
Object.defineProperty(_Storable, '__mixin', {value: 'Storable'});
return _Storable;
};
test('Getting component', async () => {
let client = new ComponentClient(server);
expect(() => client.getComponent()).toThrow(
"The component client version (undefined) doesn't match the component server version (1)"
);
client = new ComponentClient(server, {version: 1, mixins: [Storable]});
const Backend = client.getComponent() as typeof Component;
expect(isComponentClass(Backend)).toBe(true);
expect(Backend.getComponentName()).toBe('Backend');
const Session = Backend.getProvidedComponent('Session')!;
expect(isComponentClass(Session)).toBe(true);
expect(Session.getComponentName()).toBe('Session');
let attribute = Session.getAttribute('token');
expect(attribute.getValueType().toString()).toBe('string?');
expect(attribute.getExposure()).toEqual({get: true, set: true});
expect(attribute.getValue()).toBeUndefined();
const Movie = Backend.getProvidedComponent('Movie')!;
expect(isComponentClass(Movie)).toBe(true);
expect(Movie.getComponentName()).toBe('Movie');
expect(Movie.getConsumedComponent('Session')).toBe(Session);
let method = Movie.getMethod('find');
expect(method.getExposure()).toEqual({call: true});
attribute = Movie.prototype.getPrimaryIdentifierAttribute();
expect(attribute.getName()).toBe('id');
expect(attribute.getValueType().toString()).toBe('string');
expect(typeof attribute.getDefault()).toBe('function');
expect(attribute.getExposure()).toEqual({get: true, set: true});
attribute = Movie.prototype.getSecondaryIdentifierAttribute('slug');
expect(attribute.getValueType().toString()).toBe('string');
expect(attribute.getDefault()).toBeUndefined();
expect(attribute.getExposure()).toEqual({get: true, set: true});
attribute = Movie.prototype.getAttribute('title');
expect(attribute.getValueType().toString()).toBe('string');
expect(attribute.evaluateDefault()).toBe('');
expect(attribute.getValueType().getValidators()).toHaveLength(1);
expect(attribute.getExposure()).toEqual({get: true, set: true});
attribute = Movie.prototype.getAttribute('isPlaying');
expect(attribute.getValueType().toString()).toBe('boolean');
expect(attribute.evaluateDefault()).toBe(undefined);
expect(attribute.getExposure()).toEqual({get: true});
expect(attribute.isControlled()).toBe(true);
method = Movie.prototype.getMethod('play');
expect(method.getExposure()).toEqual({call: true});
method = Movie.prototype.getMethod('validateTitle');
expect(method.getExposure()).toEqual({call: true});
expect(typeof (Movie as any).isStorable).toBe('function');
});
describe('Invoking methods', () => {
class BaseSession extends Component {
static token?: string;
}
class BaseMovie extends Component {
static Session: typeof BaseSession;
// @ts-ignore
static find({limit}: {limit?: number} = {}): BaseMovie[] {}
id!: string;
slug!: string;
title = '';
isPlaying = false;
play() {}
// @ts-ignore
validateTitle(): boolean {}
}
class BaseBackend extends Component {
static Session: typeof BaseSession;
static Movie: typeof BaseMovie;
}
test('One by one', async () => {
const client = new ComponentClient(server, {version: 1, mixins: [Storable]});
const {Movie, Session} = client.getComponent() as typeof BaseBackend;
expect(() => Movie.find()).toThrow('Access denied'); // The token is missing
Session.token = 'abc123';
let movies = Movie.find();
expect(movies).toHaveLength(2);
expect(movies[0]).toBeInstanceOf(Movie);
expect(movies[0].id).toBe('movie1');
expect(movies[0].slug).toBe('inception');
expect(movies[0].title).toBe('Inception');
expect(movies[1]).toBeInstanceOf(Movie);
expect(movies[1].id).toBe('movie2');
expect(movies[1].slug).toBe('the-matrix');
expect(movies[1].title).toBe('The Matrix');
movies = Movie.find({limit: 1});
expect(movies).toHaveLength(1);
expect(movies[0]).toBeInstanceOf(Movie);
expect(movies[0].id).toBe('movie1');
expect(movies[0].slug).toBe('inception');
expect(movies[0].title).toBe('Inception');
const movie = movies[0];
movie.play();
expect(movie.isPlaying).toBe(true);
movie.title = '';
expect(movie.validateTitle()).toBe(false);
movie.title = 'Inception 2';
expect(movie.validateTitle()).toBe(true);
});
test('In batch mode', async () => {
const client = new ComponentClient(server, {version: 1, mixins: [Storable], batchable: true});
const {Movie, Session} = (await client.getComponent()) as typeof BaseBackend;
Session.token = 'abc123';
const movies = await Movie.find();
expect(movies).toHaveLength(2);
expect(movies[0]).toBeInstanceOf(Movie);
expect(movies[0].id).toBe('movie1');
expect(movies[0].slug).toBe('inception');
expect(movies[0].title).toBe('Inception');
expect(movies[1]).toBeInstanceOf(Movie);
expect(movies[1].id).toBe('movie2');
expect(movies[1].slug).toBe('the-matrix');
expect(movies[1].title).toBe('The Matrix');
await Promise.all([movies[0].play(), movies[1].play()]);
expect(movies[0].isPlaying).toBe(true);
expect(movies[1].isPlaying).toBe(true);
});
});
});
================================================
FILE: packages/component-client/src/component-client.ts
================================================
import {
Component,
ComponentSet,
Attribute,
serialize,
deserialize,
ensureComponentClass,
ComponentMixin,
assertIsComponentMixin,
IntrospectedComponent
} from '@layr/component';
import type {ComponentServerLike} from '@layr/component-server';
import {Microbatcher, Operation} from 'microbatcher';
import {getTypeOf, PlainObject} from 'core-helpers';
import {possiblyAsync} from 'possibly-async';
import debugModule from 'debug';
const debug = debugModule('layr:component-client');
// To display the debug log, set this environment:
// DEBUG=layr:component-client DEBUG_DEPTH=5
import {isComponentClientInstance} from './utilities';
interface SendOperation extends Operation {
params: Parameters;
resolve: (value: ReturnType) => void;
}
export type ComponentClientOptions = {
version?: number;
mixins?: ComponentMixin[];
introspection?: IntrospectedComponent;
batchable?: boolean;
};
/**
* A base class allowing to access a root [`Component`](https://layrjs.com/docs/v2/reference/component) that is served by a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server).
*
* Typically, instead of using this class, you would use a subclass such as [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client).
*/
export class ComponentClient {
_componentServer: ComponentServerLike;
_version: number | undefined;
_mixins: ComponentMixin[] | undefined;
_introspection: IntrospectedComponent | undefined;
_sendBatcher: Microbatcher | undefined;
/**
* Creates a component client.
*
* @param componentServer The [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) to connect to.
* @param [options.version] A number specifying the expected version of the component server (default: `undefined`). If a version is specified, an error is thrown when a request is sent and the component server has a different version. The thrown error is a JavaScript `Error` instance with a `code` attribute set to `'COMPONENT_CLIENT_VERSION_DOES_NOT_MATCH_COMPONENT_SERVER_VERSION'`.
* @param [options.mixins] An array of the component mixins (e.g., [`Storable`](https://layrjs.com/docs/v2/reference/storable)) to use when constructing the components exposed by the component server (default: `[]`).
*
* @returns A `ComponentClient` instance.
*
* @example
* ```
* // JS
*
* import {Component, attribute, expose} from '﹫layr/component';
* import {ComponentClient} from '﹫layr/component-client';
* import {ComponentServer} from '﹫layr/component-server';
*
* class Movie extends Component {
* ﹫expose({get: true, set: true}) ﹫attribute('string') title;
* }
*
* const server = new ComponentServer(Movie);
* const client = new ComponentClient(server);
*
* const RemoteMovie = client.getComponent();
* ```
*
* @example
* ```
* // TS
*
* import {Component, attribute, expose} from '﹫layr/component';
* import {ComponentClient} from '﹫layr/component-client';
* import {ComponentServer} from '﹫layr/component-server';
*
* class Movie extends Component {
* ﹫expose({get: true, set: true}) ﹫attribute('string') title!: string;
* }
*
* const server = new ComponentServer(Movie);
* const client = new ComponentClient(server);
*
* const RemoteMovie = client.getComponent() as typeof Movie;
* ```
*
* @category Creation
*/
constructor(componentServer: ComponentServerLike, options: ComponentClientOptions = {}) {
const {version, mixins, introspection, batchable = false} = options;
if (typeof componentServer?.receive !== 'function') {
throw new Error(
`Expected a component server, but received a value of type '${getTypeOf(componentServer)}'`
);
}
if (mixins !== undefined) {
for (const mixin of mixins) {
assertIsComponentMixin(mixin);
}
}
this._componentServer = componentServer;
this._version = version;
this._mixins = mixins;
this._introspection = introspection;
if (batchable) {
this._sendBatcher = new Microbatcher(this._sendMany.bind(this));
}
}
_component!: typeof Component;
/**
* Gets the component that is served by the component server.
*
* @returns A [`Component`](https://layrjs.com/docs/v2/reference/component) class.
*
* @examplelink See [`constructor`'s example](https://layrjs.com/docs/v2/reference/component-client#constructor).
*
* @category Getting the Served Component
* @possiblyasync
*/
getComponent() {
if (this._component === undefined) {
return possiblyAsync(this._createComponent(), (component) => {
this._component = component;
return component;
});
}
return this._component;
}
_createComponent() {
return possiblyAsync(this._introspectComponentServer(), (introspectedComponentServer) => {
const methodBuilder = (name: string) => this._createMethodProxy(name);
return Component.unintrospect(introspectedComponentServer.component, {
mixins: this._mixins,
methodBuilder
});
});
}
_createMethodProxy(name: string) {
const componentClient = this;
return function (this: typeof Component | Component, ...args: any[]) {
const query = {
'<=': this,
[`${name}=>`]: {'()': args}
};
const rootComponent = ensureComponentClass(this);
return componentClient.send(query, {rootComponent});
};
}
_introspectedComponentServer!: PlainObject;
_introspectComponentServer() {
if (this._introspectedComponentServer !== undefined) {
return this._introspectedComponentServer;
}
if (this._introspection !== undefined) {
this._introspectedComponentServer = {component: this._introspection};
return this._introspectedComponentServer;
}
const query = {'introspect=>': {'()': []}};
return possiblyAsync(this.send(query), (introspectedComponentServer) => {
this._introspectedComponentServer = introspectedComponentServer;
return introspectedComponentServer;
});
}
send(query: PlainObject, options: {rootComponent?: typeof Component} = {}): any {
if (this._sendBatcher !== undefined) {
return this._sendBatcher.batch(query, options);
}
return this._sendOne(query, options);
}
_sendOne(query: PlainObject, options: {rootComponent?: typeof Component}): any {
const {rootComponent} = options;
const {serializedQuery, serializedComponents} = this._serializeQuery(query);
debugRequest({serializedQuery, serializedComponents});
return possiblyAsync(
this._componentServer.receive({
query: serializedQuery,
...(serializedComponents && {components: serializedComponents}),
version: this._version
}),
({result: serializedResult, components: serializedComponents}) => {
debugResponse({serializedResult, serializedComponents});
const errorHandler = function (error: Error) {
throw error;
};
return possiblyAsync(
deserialize(serializedComponents, {
rootComponent,
deserializeFunctions: true,
errorHandler,
source: 'server'
}),
() => {
return deserialize(serializedResult, {
rootComponent,
deserializeFunctions: true,
errorHandler,
source: 'server'
});
}
);
}
);
}
async _sendMany(operations: SendOperation[]) {
if (operations.length === 1) {
const operation = operations[0];
try {
operation.resolve(await this._sendOne(...operation.params));
} catch (error) {
operation.reject(error);
}
return;
}
const queries = {'||': operations.map(({params: [query]}) => query)};
const {serializedQuery, serializedComponents} = this._serializeQuery(queries);
debugRequests({serializedQuery, serializedComponents});
const serializedResponse = await this._componentServer.receive({
query: serializedQuery,
...(serializedComponents && {components: serializedComponents}),
version: this._version
});
debugResponses({
serializedResult: serializedResponse.result,
serializedComponents: serializedResponse.components
});
const errorHandler = function (error: Error) {
throw error;
};
const firstRootComponent = operations[0].params[1].rootComponent;
await deserialize(serializedResponse.components, {
rootComponent: firstRootComponent,
deserializeFunctions: true,
errorHandler,
source: 'server'
});
for (let index = 0; index < operations.length; index++) {
const operation = operations[index];
const serializedResult = (serializedResponse.result as unknown[])[index];
try {
const result = await deserialize(serializedResult, {
rootComponent: operation.params[1].rootComponent,
deserializeFunctions: true,
errorHandler,
source: 'server'
});
operation.resolve(result);
} catch (error) {
operation.reject(error);
}
}
}
_serializeQuery(query: PlainObject) {
const componentDependencies: ComponentSet = new Set();
const attributeFilter = function (this: typeof Component | Component, attribute: Attribute) {
// Exclude properties that cannot be set in the remote components
const remoteComponent = this.getRemoteComponent();
if (remoteComponent === undefined) {
return false;
}
const attributeName = attribute.getName();
const remoteAttribute = remoteComponent.hasAttribute(attributeName)
? remoteComponent.getAttribute(attributeName)
: undefined;
if (remoteAttribute === undefined) {
return false;
}
return remoteAttribute.operationIsAllowed('set') as boolean;
};
const serializedQuery: PlainObject = serialize(query, {
componentDependencies,
attributeFilter,
target: 'server'
});
let serializedComponentDependencies: PlainObject[] | undefined;
const handledComponentDependencies: ComponentSet = new Set();
const serializeComponentDependencies = function (componentDependencies: ComponentSet) {
if (componentDependencies.size === 0) {
return;
}
const additionalComponentDependency: ComponentSet = new Set();
for (const componentDependency of componentDependencies.values()) {
if (handledComponentDependencies.has(componentDependency)) {
continue;
}
const serializedComponentDependency = componentDependency.serialize({
componentDependencies: additionalComponentDependency,
ignoreEmptyComponents: true,
attributeFilter,
target: 'server'
});
if (serializedComponentDependency !== undefined) {
if (serializedComponentDependencies === undefined) {
serializedComponentDependencies = [];
}
serializedComponentDependencies.push(serializedComponentDependency);
}
handledComponentDependencies.add(componentDependency);
}
serializeComponentDependencies(additionalComponentDependency);
};
serializeComponentDependencies(componentDependencies);
return {serializedQuery, serializedComponents: serializedComponentDependencies};
}
static isComponentClient(value: any): value is ComponentClient {
return isComponentClientInstance(value);
}
}
function debugRequest({
serializedQuery,
serializedComponents
}: {
serializedQuery: PlainObject;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Sending query: %o';
const values = [serializedQuery];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
function debugResponse({
serializedResult,
serializedComponents
}: {
serializedResult: unknown;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Result received: %o';
const values = [serializedResult];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
function debugRequests({
serializedQuery,
serializedComponents
}: {
serializedQuery: PlainObject;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Sending queries: %o';
const values = [serializedQuery];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
function debugResponses({
serializedResult,
serializedComponents
}: {
serializedResult: unknown;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Results received: %o';
const values = [serializedResult];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
================================================
FILE: packages/component-client/src/index.ts
================================================
export * from './component-client';
export * from './utilities';
================================================
FILE: packages/component-client/src/utilities.ts
================================================
import type {ComponentClient} from './component-client';
export function isComponentClientClass(value: any): value is typeof ComponentClient {
return typeof value?.isComponentClient === 'function';
}
export function isComponentClientInstance(value: any): value is ComponentClient {
return typeof value?.constructor?.isComponentClient === 'function';
}
================================================
FILE: packages/component-client/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-express-middleware/README.md
================================================
# @layr/component-koa-middleware
A Koa middleware for your Layr components.
## Installation
```
npm install @layr/component-koa-middleware
```
## License
MIT
================================================
FILE: packages/component-express-middleware/package.json
================================================
{
"name": "@layr/component-express-middleware",
"version": "2.0.94",
"description": "An Express middleware for your Layr components",
"keywords": [
"layr",
"component",
"express",
"middleware"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-express-middleware",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/component-server": "^2.0.70",
"@layr/routable": "^2.0.113",
"core-helpers": "^1.0.8",
"http-errors": "^1.8.1",
"mime-types": "^2.1.35",
"raw-body": "^2.5.1",
"sleep-promise": "^9.1.0",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/express": "^4.17.15",
"@types/http-errors": "^1.8.2",
"@types/mime-types": "^2.1.1"
}
}
================================================
FILE: packages/component-express-middleware/src/component-express-middleware.ts
================================================
/**
* @module component-express-middleware
*
* An [Express](https://expressjs.com/) middleware allowing to serve a root [`Component`](https://layrjs.com/docs/v2/reference/component) so it can be accessed by a [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client).
*
* #### Usage
*
* Call the [`serveComponent()`](https://layrjs.com/docs/v2/reference/component-express-middleware#serve-component-function) function to create a middleware for your Express app.
*
* **Example:**
*
* ```
* import express from 'express';
* import {Component} from '@layr/component';
* import {serveComponent} from '@layr/component-express-middleware';
*
* class Movie extends Component {
* // ...
* }
*
* const app = express();
*
* app.use('/api', serveComponent(Movie));
*
* app.listen(3210);
* ```
*/
import type {Component} from '@layr/component';
import {ensureComponentServer} from '@layr/component-server';
import type {ComponentServer, ComponentServerOptions} from '@layr/component-server';
import {callRouteByURL, isRoutableClass} from '@layr/routable';
import type {Request, Response} from 'express';
import getRawBody from 'raw-body';
import mime from 'mime-types';
import httpError from 'http-errors';
import sleep from 'sleep-promise';
const DEFAULT_LIMIT = '8mb';
export type ServeComponentOptions = ComponentServerOptions & {
limit?: number | string;
delay?: number;
errorRate?: number;
};
/**
* Creates an [Express](https://expressjs.com/) middleware exposing the specified root [`Component`](https://layrjs.com/docs/v2/reference/component) class.
*
* @param componentOrComponentServer The root [`Component`](https://layrjs.com/docs/v2/reference/component) class to serve. An instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) will be created under the hood. Alternatively, you can pass an existing instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server).
* @param [options.version] A number specifying the version of the created [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) (default: `undefined`).
*
* @returns An Express middleware.
*
* @category Functions
*/
export function serveComponent(
componentOrComponentServer: typeof Component | ComponentServer,
options: ServeComponentOptions = {}
) {
const componentServer = ensureComponentServer(componentOrComponentServer, options);
const component = componentServer.getComponent();
const routableComponent = isRoutableClass(component) ? component : undefined;
const {limit = DEFAULT_LIMIT, delay = 0, errorRate = 0} = options;
return async function (req: Request, res: Response) {
if (delay > 0) {
await sleep(delay);
}
if (errorRate > 0) {
const threshold = errorRate / 100;
if (Math.random() < threshold) {
throw httpError(500, 'A simulated error occurred while handling a component server query');
}
}
const method = req.method;
const url = req.url;
const headers = (req.headers as Record) ?? {};
const contentType = headers['content-type'] || 'application/octet-stream';
const charset = mime.charset(contentType) || undefined;
const body: string | Buffer = await getRawBody(req, {limit, encoding: charset});
if (url === '/') {
if (method === 'GET') {
res.json(await componentServer.receive({query: {'introspect=>': {'()': []}}}));
return;
}
if (method === 'POST') {
let parsedBody: any;
if (typeof body !== 'string') {
throw new Error(
`Expected a body of type 'string', but received a value of type '${typeof body}'`
);
}
try {
parsedBody = JSON.parse(body);
} catch (error) {
throw new Error(`An error occurred while parsing a JSON body string ('${body}')`);
}
const {query, components, version} = parsedBody;
res.json(await componentServer.receive({query, components, version}));
return;
}
throw httpError(405);
}
if (routableComponent !== undefined) {
const routableComponentFork = routableComponent.fork();
await routableComponentFork.initialize();
const routeResponse: {
status: number;
headers?: Record;
body?: string | Buffer;
} = await callRouteByURL(routableComponentFork, url, {method, headers, body});
if (typeof routeResponse?.status !== 'number') {
throw new Error(
`Unexpected response \`${JSON.stringify(
routeResponse
)}\` returned by a component route (a proper response should be an object of the shape \`{status: number; headers?: Record; body?: string | Buffer;}\`)`
);
}
res.status(routeResponse.status);
if (routeResponse.headers !== undefined) {
res.set(routeResponse.headers);
}
if (routeResponse.body !== undefined) {
res.send(routeResponse.body);
} else {
res.end();
}
return;
}
throw httpError(404);
};
}
================================================
FILE: packages/component-express-middleware/src/index.ts
================================================
export * from './component-express-middleware';
================================================
FILE: packages/component-express-middleware/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-http-client/.gitignore
================================================
.DS_STORE
node_modules
*.log
/dist
================================================
FILE: packages/component-http-client/README.md
================================================
# @layr/component-http-client
An HTTP client for your Layr components.
## Installation
```
npm install @layr/component-http-client
```
## License
MIT
================================================
FILE: packages/component-http-client/package.json
================================================
{
"name": "@layr/component-http-client",
"version": "2.0.76",
"description": "An HTTP client for your Layr components",
"keywords": [
"layr",
"component",
"http",
"client"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-http-client",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component-client": "^2.0.86",
"@layr/utilities": "^1.0.9",
"core-helpers": "^1.0.8",
"cross-fetch": "^3.1.5",
"tslib": "^2.4.1"
},
"devDependencies": {
"@koa/cors": "^3.4.3",
"@layr/component": "^2.0.51",
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/co-body": "^5.1.1",
"@types/jest": "^29.2.5",
"@types/koa": "^2.13.5",
"@types/koa__cors": "^3.3.0",
"@types/koa-json-error": "^3.1.4",
"@types/lodash": "^4.14.191",
"co-body": "^6.1.0",
"koa": "^2.14.1",
"koa-json-error": "^3.1.2",
"lodash": "^4.17.21"
}
}
================================================
FILE: packages/component-http-client/src/component-http-client.test.ts
================================================
import {isComponentClass} from '@layr/component';
import Koa from 'koa';
import jsonError from 'koa-json-error';
import cors from '@koa/cors';
import body from 'co-body';
import type {Server} from 'http';
import isEqual from 'lodash/isEqual';
import {ComponentHTTPClient} from './component-http-client';
const SERVER_PORT = Math.floor(Math.random() * (60000 - 50000 + 1) + 50000);
describe('ComponentHTTPClient', () => {
let server: Server | undefined;
beforeAll(() => {
const koa = new Koa();
koa.use(jsonError());
koa.use(cors({maxAge: 900})); // 15 minutes
koa.use(async function (ctx) {
const {query, version: clientVersion} = await body.json(ctx.req);
const serverVersion = 1;
if (clientVersion !== serverVersion) {
throw Object.assign(
new Error(
`The component client version (${clientVersion}) doesn't match the component server version (${serverVersion})`
),
{code: 'COMPONENT_CLIENT_VERSION_DOES_NOT_MATCH_COMPONENT_SERVER_VERSION', expose: true}
);
}
if (isEqual(query, {'introspect=>': {'()': []}})) {
ctx.body = {
result: {
component: {
name: 'Movie',
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
}
]
}
}
};
} else {
throw new Error(`Received an unknown query: ${JSON.stringify(query)}`);
}
});
return new Promise((resolve) => {
server = koa.listen(SERVER_PORT, resolve);
});
});
afterAll(() => {
if (server === undefined) {
return;
}
return new Promise((resolve) => {
server!.close(() => {
server = undefined;
resolve();
});
});
});
test('Getting components', async () => {
let client = new ComponentHTTPClient(`http://localhost:${SERVER_PORT}`);
await expect(client.getComponent()).rejects.toThrow(
"The component client version (undefined) doesn't match the component server version (1)"
);
client = new ComponentHTTPClient(`http://localhost:${SERVER_PORT}`, {version: 1});
const Movie = await client.getComponent();
expect(isComponentClass(Movie)).toBe(true);
expect(Movie.getComponentName()).toBe('Movie');
const attribute = Movie.getAttribute('limit');
expect(attribute.getValue()).toBe(100);
expect(attribute.getExposure()).toEqual({get: true});
});
});
================================================
FILE: packages/component-http-client/src/component-http-client.ts
================================================
import {ComponentClient, ComponentClientOptions} from '@layr/component-client';
import fetch from 'cross-fetch';
import {sleep} from '@layr/utilities';
import type {PlainObject} from 'core-helpers';
const DEFAULT_MAXIMUM_REQUEST_RETRIES = 10;
const DEFAULT_MINIMUM_TIME_BETWEEN_REQUEST_RETRIES = 3000; // 3 seconds
export type ComponentHTTPClientOptions = ComponentClientOptions & {
retryFailedRequests?: RetryFailedRequests;
maximumRequestRetries?: number;
minimumTimeBetweenRequestRetries?: number;
};
export type RetryFailedRequests =
| boolean
| (({error, numberOfRetries}: {error: Error; numberOfRetries: number}) => Promise);
/**
* *Inherits from [`ComponentClient`](https://layrjs.com/docs/v2/reference/component-client).*
*
* A class allowing to access a root [`Component`](https://layrjs.com/docs/v2/reference/component) that is served by a [`ComponentHTTPServer`](https://layrjs.com/docs/v2/reference/component-http-server), a middleware such as [`component-express-middleware`](https://layrjs.com/docs/v2/reference/component-express-middleware), or any HTTP server exposing a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server).
*
* #### Usage
*
* Create an instance of `ComponentHTTPClient` by specifying the URL of the component server, and use the [`getComponent()`](https://layrjs.com/docs/v2/reference/component-http-client#get-component-instance-method) method to get the served component.
*
* For example, to access a `Movie` component that is served by a component server, you could do the following:
*
* ```
* // JS
*
* // backend.js
*
* import {Component, attribute, method, expose} from '@layr/component';
* import {ComponentHTTPServer} from '@layr/component-http-server';
*
* export class Movie extends Component {
* @expose({get: true, set: true}) @attribute('string') title;
*
* @expose({call: true}) @method() async play() {
* return `Playing `${this.title}`...`;
* }
* }
*
* const server = new ComponentHTTPServer(Movie, {port: 3210});
*
* server.start();
* ```
*
* ```
* // JS
*
* // frontend.js
*
* import {ComponentHTTPClient} from '@layr/component-http-client';
*
* (async () => {
* const client = new ComponentHTTPClient('http://localhost:3210');
*
* const Movie = await client.getComponent();
*
* const movie = new Movie({title: 'Inception'});
*
* await movie.play(); // => 'Playing Inception...'
* })();
* ```
*
* ```
* // TS
*
* // backend.ts
*
* import {Component, attribute, method, expose} from '@layr/component';
* import {ComponentHTTPServer} from '@layr/component-http-server';
*
* export class Movie extends Component {
* @expose({get: true, set: true}) @attribute('string') title!: string;
*
* @expose({call: true}) @method() async play() {
* return `Playing `${this.title}`...`;
* }
* }
*
* const server = new ComponentHTTPServer(Movie, {port: 3210});
*
* server.start();
* ```
*
* ```
* // TS
*
* // frontend.ts
*
* import {ComponentHTTPClient} from '@layr/component-http-client';
*
* import type {Movie as MovieType} from './backend';
*
* (async () => {
* const client = new ComponentHTTPClient('http://localhost:3210');
*
* const Movie = (await client.getComponent()) as typeof MovieType;
*
* const movie = new Movie({title: 'Inception'});
*
* await movie.play(); // => 'Playing Inception...'
* })();
* ```
*/
export class ComponentHTTPClient extends ComponentClient {
/**
* Creates a component HTTP client.
*
* @param url A string specifying the URL of the component server to connect to.
* @param [options.version] A number specifying the expected version of the component server (default: `undefined`). If a version is specified, an error is thrown when a request is sent and the component server has a different version. The thrown error is a JavaScript `Error` instance with a `code` attribute set to `'COMPONENT_CLIENT_VERSION_DOES_NOT_MATCH_COMPONENT_SERVER_VERSION'`.
* @param [options.mixins] An array of the component mixins (e.g., [`Storable`](https://layrjs.com/docs/v2/reference/storable)) to use when constructing the components exposed by the component server (default: `[]`).
* @param [options.retryFailedRequests] A boolean or a function returning a boolean specifying whether a request should be retried in case of a network issue (default: `false`). In case a function is specified, the function will receive an object of the shape `{error, numberOfRetries}` where `error` is the error that has occurred and `numberOfRetries` is the number of retries that has been attempted so far. The function can be asynchronous ans should return a boolean.
* @param [options.maximumRequestRetries] The maximum number of times a request can be retried (default: `10`).
* @param [options.minimumTimeBetweenRequestRetries] A number specifying the minimum time in milliseconds that should elapse between each request retry (default: `3000`).
*
* @returns A `ComponentHTTPClient` instance.
*
* @category Creation
*/
constructor(url: string, options: ComponentHTTPClientOptions = {}) {
const {
retryFailedRequests = false,
maximumRequestRetries = DEFAULT_MAXIMUM_REQUEST_RETRIES,
minimumTimeBetweenRequestRetries = DEFAULT_MINIMUM_TIME_BETWEEN_REQUEST_RETRIES,
...componentClientOptions
} = options;
const componentServer = createComponentServer(url, {
retryFailedRequests,
maximumRequestRetries,
minimumTimeBetweenRequestRetries
});
super(componentServer, {...componentClientOptions, batchable: true});
}
/**
* @method getComponent
*
* Gets the component that is served by the component server.
*
* @returns A [`Component`](https://layrjs.com/docs/v2/reference/component) class.
*
* @examplelink See an [example of use](https://layrjs.com/docs/v2/reference/component-http-client#usage) above.
*
* @category Getting the Served Component
* @async
*/
}
function createComponentServer(
url: string,
{
retryFailedRequests,
maximumRequestRetries,
minimumTimeBetweenRequestRetries
}: {
retryFailedRequests: RetryFailedRequests;
maximumRequestRetries: number;
minimumTimeBetweenRequestRetries: number;
}
) {
return {
async receive(request: {query: PlainObject; components?: PlainObject[]; version?: number}) {
const {query, components, version} = request;
const jsonRequest = {query, components, version};
let numberOfRetries = 0;
while (true) {
let fetchResponse: Response;
let jsonResponse: any;
try {
fetchResponse = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(jsonRequest)
});
jsonResponse = await fetchResponse.json();
} catch (error: any) {
if (numberOfRetries < maximumRequestRetries) {
const startTime = Date.now();
const shouldRetry =
typeof retryFailedRequests === 'function'
? await retryFailedRequests({error, numberOfRetries})
: retryFailedRequests;
if (shouldRetry) {
const elapsedTime = Date.now() - startTime;
if (elapsedTime < minimumTimeBetweenRequestRetries) {
await sleep(minimumTimeBetweenRequestRetries - elapsedTime);
}
numberOfRetries++;
continue;
}
}
throw error;
}
if (fetchResponse.status !== 200) {
const {
message = 'An error occurred while sending query to remote components',
...attributes
} = jsonResponse ?? {};
throw Object.assign(new Error(message), attributes);
}
return jsonResponse;
}
}
};
}
================================================
FILE: packages/component-http-client/src/index.ts
================================================
export * from './component-http-client';
================================================
FILE: packages/component-http-client/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-http-server/README.md
================================================
# @layr/component-http-server
A basic HTTP server for your Layr components.
## Installation
```
npm install @layr/component-http-server
```
## License
MIT
================================================
FILE: packages/component-http-server/package.json
================================================
{
"name": "@layr/component-http-server",
"version": "2.0.97",
"description": "A basic HTTP server for your Layr components",
"keywords": [
"layr",
"component",
"http",
"server"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-http-server",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@koa/cors": "^3.4.3",
"@layr/component-koa-middleware": "^2.0.94",
"@layr/component-server": "^2.0.70",
"core-helpers": "^1.0.8",
"debug": "^4.3.4",
"koa": "^2.14.1",
"koa-json-error": "^3.1.2",
"koa-logger": "^3.2.1",
"tslib": "^2.4.1"
},
"devDependencies": {
"@layr/component": "^2.0.51",
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/debug": "^4.1.7",
"@types/jest": "^29.2.5",
"@types/koa": "^2.13.5",
"@types/koa__cors": "^3.3.0",
"@types/koa-json-error": "^3.1.4",
"@types/koa-logger": "^3.1.2",
"cross-fetch": "^3.1.5"
}
}
================================================
FILE: packages/component-http-server/src/component-http-server.test.ts
================================================
import {Component, attribute, expose} from '@layr/component';
import fetch from 'cross-fetch';
import {ComponentHTTPServer} from './component-http-server';
const SERVER_PORT = Math.floor(Math.random() * (60000 - 50000 + 1) + 50000);
describe('ComponentHTTPServer', () => {
let server: ComponentHTTPServer;
beforeAll(async () => {
class Movie extends Component {
@expose({get: true}) @attribute('number') static limit = 100;
}
server = new ComponentHTTPServer(Movie, {port: SERVER_PORT});
await server.start();
});
afterAll(async () => {
await server?.stop();
});
test('Introspecting components', async () => {
const expectedResponse = {
result: {
component: {
name: 'Movie',
properties: [
{
name: 'limit',
type: 'Attribute',
valueType: 'number',
value: 100,
exposure: {get: true}
}
]
}
}
};
expect(await get()).toStrictEqual(expectedResponse);
expect(await postJSON({query: {'introspect=>': {'()': []}}})).toStrictEqual(expectedResponse);
await expect(postJSON({query: {'introspect=>': {'()': []}}, version: 1})).rejects.toThrow(
"The component client version (1) doesn't match the component server version (undefined)"
);
});
});
async function get() {
const url = `http://localhost:${SERVER_PORT}`;
const response = await fetch(url);
return await handleFetchResponse(response);
}
async function postJSON(json: object) {
const url = `http://localhost:${SERVER_PORT}`;
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(json)
});
return await handleFetchResponse(response);
}
async function handleFetchResponse(response: Response) {
const result = await response.json();
if (response.status !== 200) {
const {message = 'An error occurred while sending query to remote components', ...attributes} =
result ?? {};
throw Object.assign(new Error(message), attributes);
}
return result;
}
================================================
FILE: packages/component-http-server/src/component-http-server.ts
================================================
import Koa from 'koa';
import type {Server} from 'http';
import logger from 'koa-logger';
import jsonError from 'koa-json-error';
import cors from '@koa/cors';
import type {Component} from '@layr/component';
import type {ComponentServer} from '@layr/component-server';
import {ensureComponentServer} from '@layr/component-server';
import {serveComponent, ServeComponentOptions} from '@layr/component-koa-middleware';
import debugModule from 'debug';
const debug = debugModule('layr:component-http-server');
// To display the debug log, set this environment:
// DEBUG=layr:component-http-server DEBUG_DEPTH=10
const DEFAULT_PORT = 3333;
export type ComponentHTTPServerOptions = {port?: number} & ServeComponentOptions;
/**
* A class allowing to serve a root [`Component`](https://layrjs.com/docs/v2/reference/component) so it can be accessed by a [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client).
*
* This class provides a basic HTTP server providing one endpoint to serve your root component. If you wish to build an HTTP server providing multiple endpoints, you can use a middleware such as [`component-express-middleware`](https://layrjs.com/docs/v2/reference/component-express-middleware), or implement the necessary plumbing to integrate a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) in your custom HTTP server.
*
* #### Usage
*
* Create an instance of `ComponentHTTPServer` by specifying the root [`Component`](https://layrjs.com/docs/v2/reference/component) you want to serve, and use the [`start()`](https://layrjs.com/docs/v2/reference/component-http-server#start-instance-method) method to start the server.
*
* See an example of use in [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client).
*/
export class ComponentHTTPServer {
_componentServer: ComponentServer;
_port: number;
_serveComponentOptions: ServeComponentOptions;
/**
* Creates a component HTTP server.
*
* @param componentOrComponentServer The root [`Component`](https://layrjs.com/docs/v2/reference/component) class to serve. An instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) will be created under the hood. Alternatively, you can pass an existing instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server).
* @param [options.port] A number specifying the TCP port to listen to (default: `3333`).
* @param [options.version] A number specifying the version of the created [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) (default: `undefined`).
*
* @returns A `ComponentHTTPServer` instance.
*
* @category Creation
*/
constructor(
componentOrComponentServer: typeof Component | ComponentServer,
options: ComponentHTTPServerOptions = {}
) {
const componentServer = ensureComponentServer(componentOrComponentServer, options);
const {port = DEFAULT_PORT, limit, delay, errorRate} = options;
this._componentServer = componentServer;
this._port = port;
this._serveComponentOptions = {limit, delay, errorRate};
}
_server: Server | undefined;
/**
* Starts the component HTTP server.
*
* @example
* ```
* const server = new ComponentHTTPServer(Movie, {port: 3210});
*
* await server.start();
* ```
*
* @category Methods
* @async
*/
start() {
if (this._server !== undefined) {
throw new Error('The component HTTP server is already started');
}
const koa = new Koa();
koa.use(
logger((message) => {
debug(message);
})
);
koa.use(jsonError());
koa.use(cors({maxAge: 900})); // 15 minutes
koa.use(serveComponent(this._componentServer, this._serveComponentOptions));
return new Promise((resolve) => {
this._server = koa.listen(this._port, () => {
debug(`Component HTTP server started (port: ${this._port})`);
resolve();
});
});
}
/**
* Stops the component HTTP server.
*
* @category Methods
* @async
*/
stop() {
const server = this._server;
if (server === undefined) {
throw new Error('The component HTTP server is not started');
}
return new Promise((resolve) => {
server.close(() => {
this._server = undefined;
debug(`Component HTTP server stopped`);
resolve();
});
});
}
}
================================================
FILE: packages/component-http-server/src/index.ts
================================================
export * from './component-http-server';
================================================
FILE: packages/component-http-server/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-koa-middleware/README.md
================================================
# @layr/component-koa-middleware
A Koa middleware for your Layr components.
## Installation
```
npm install @layr/component-koa-middleware
```
## License
MIT
================================================
FILE: packages/component-koa-middleware/package.json
================================================
{
"name": "@layr/component-koa-middleware",
"version": "2.0.94",
"description": "A Koa middleware for your Layr components",
"keywords": [
"layr",
"component",
"koa",
"middleware"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-koa-middleware",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/component-server": "^2.0.70",
"@layr/routable": "^2.0.113",
"core-helpers": "^1.0.8",
"mime-types": "^2.1.35",
"raw-body": "^2.5.1",
"sleep-promise": "^9.1.0",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/koa": "^2.13.5",
"@types/mime-types": "^2.1.1"
}
}
================================================
FILE: packages/component-koa-middleware/src/component-koa-middleware.ts
================================================
/**
* @module component-koa-middleware
*
* A [Koa](https://koajs.com/) middleware allowing to serve a root [`Component`](https://layrjs.com/docs/v2/reference/component) so it can be accessed by a [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client).
*
* #### Usage
*
* Call the [`serveComponent()`](https://layrjs.com/docs/v2/reference/component-koa-middleware#serve-component-function) function to create a middleware for your Koa app.
*
* **Example:**
*
* ```
* import Koa from 'koa';
* import {Component} from '@layr/component';
* import {serveComponent} from '@layr/component-koa-middleware';
*
* class Movie extends Component {
* // ...
* }
*
* const app = new Koa();
*
* // Serve the `Movie` component at the root ('/')
* app.use(serveComponent(Movie));
*
* app.listen(3210);
* ```
*
* If you want to serve your component at a specific URL, you can use [`koa-mount`](https://github.com/koajs/mount):
*
* ```
* import mount from 'koa-mount';
*
* // Serve the `Movie` component at a specific URL ('/api')
* app.use(mount('/api', serveComponent(Movie)));
* ```
*/
import type {Component} from '@layr/component';
import {ensureComponentServer} from '@layr/component-server';
import type {ComponentServer, ComponentServerOptions} from '@layr/component-server';
import {callRouteByURL, isRoutableClass} from '@layr/routable';
import type {Context} from 'koa';
import getRawBody from 'raw-body';
import mime from 'mime-types';
import sleep from 'sleep-promise';
const DEFAULT_LIMIT = '8mb';
export type ServeComponentOptions = ComponentServerOptions & {
limit?: number | string;
delay?: number;
errorRate?: number;
};
/**
* Creates a [Koa](https://koajs.com/) middleware exposing the specified root [`Component`](https://layrjs.com/docs/v2/reference/component) class.
*
* @param componentOrComponentServer The root [`Component`](https://layrjs.com/docs/v2/reference/component) class to serve. An instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) will be created under the hood. Alternatively, you can pass an existing instance of a [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server).
* @param [options.version] A number specifying the version of the created [`ComponentServer`](https://layrjs.com/docs/v2/reference/component-server) (default: `undefined`).
*
* @returns A Koa middleware.
*
* @category Functions
*/
export function serveComponent(
componentOrComponentServer: typeof Component | ComponentServer,
options: ServeComponentOptions = {}
) {
const componentServer = ensureComponentServer(componentOrComponentServer, options);
const component = componentServer.getComponent();
const routableComponent = isRoutableClass(component) ? component : undefined;
const {limit = DEFAULT_LIMIT, delay = 0, errorRate = 0} = options;
return async function (ctx: Context) {
if (delay > 0) {
await sleep(delay);
}
if (errorRate > 0) {
const threshold = errorRate / 100;
if (Math.random() < threshold) {
throw new Error('A simulated error occurred while handling a component server query');
}
}
const method = ctx.request.method;
const url = ctx.request.url;
const headers = (ctx.request.headers as Record) ?? {};
const contentType = headers['content-type'] || 'application/octet-stream';
const charset = mime.charset(contentType) || undefined;
const body: string | Buffer = await getRawBody(ctx.req, {limit, encoding: charset});
if (url === '/') {
if (method === 'GET') {
ctx.response.body = await componentServer.receive({query: {'introspect=>': {'()': []}}});
return;
}
if (method === 'POST') {
let parsedBody: any;
if (typeof body !== 'string') {
throw new Error(
`Expected a body of type 'string', but received a value of type '${typeof body}'`
);
}
try {
parsedBody = JSON.parse(body);
} catch (error) {
throw new Error(`An error occurred while parsing a JSON body string ('${body}')`);
}
const {query, components, version} = parsedBody;
ctx.response.body = await componentServer.receive({query, components, version});
return;
}
ctx.throw(405);
}
if (routableComponent !== undefined) {
const routableComponentFork = routableComponent.fork();
await routableComponentFork.initialize();
const routeResponse: {
status: number;
headers?: Record;
body?: string | Buffer;
} = await callRouteByURL(routableComponentFork, url, {method, headers, body});
if (typeof routeResponse?.status !== 'number') {
throw new Error(
`Unexpected response \`${JSON.stringify(
routeResponse
)}\` returned by a component route (a proper response should be an object of the shape \`{status: number; headers?: Record; body?: string | Buffer;}\`)`
);
}
ctx.response.status = routeResponse.status;
if (routeResponse.headers !== undefined) {
ctx.response.set(routeResponse.headers);
}
if (routeResponse.body !== undefined) {
ctx.response.body = routeResponse.body;
}
return;
}
ctx.throw(404);
};
}
================================================
FILE: packages/component-koa-middleware/src/index.ts
================================================
export * from './component-koa-middleware';
================================================
FILE: packages/component-koa-middleware/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/component-server/README.md
================================================
# @layr/component-server
Serves your Layr components.
## Installation
```
npm install @layr/component-server
```
## License
MIT
================================================
FILE: packages/component-server/package.json
================================================
{
"name": "@layr/component-server",
"version": "2.0.70",
"description": "Serves your Layr components",
"keywords": [
"layr",
"component",
"server"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/component-server",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@deepr/runtime": "^1.1.0",
"@layr/component": "^2.0.51",
"core-helpers": "^1.0.8",
"debug": "^4.3.4",
"lodash": "^4.17.21",
"possibly-async": "^1.0.7",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/debug": "^4.1.7",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191"
}
}
================================================
FILE: packages/component-server/src/component-server.test.ts
================================================
import {
Component,
attribute,
primaryIdentifier,
secondaryIdentifier,
method,
expose,
provide,
consume,
validators
} from '@layr/component';
import {PlainObject, forEachDeep} from 'core-helpers';
import {ComponentServer} from './component-server';
describe('ComponentServer', () => {
test('Introspecting components', async () => {
class Session extends Component {
@expose({get: true, set: true}) @attribute('string?') static token?: string;
}
class Movie extends Component {
@consume() static Session: typeof Session;
@expose({call: true}) @method() static find() {}
@method() static count() {}
@expose({get: true, set: true}) @primaryIdentifier() id!: string;
@expose({get: true, set: true}) @secondaryIdentifier() slug!: string;
@expose({get: true, set: true})
@attribute('string', {validators: [validators.notEmpty()]})
title = '';
@expose({get: true}) @attribute('boolean') isPlaying = false;
@expose({call: true}) @method() play() {}
@method() delete() {}
}
class Backend extends Component {
@provide() static Session = Session;
@provide() static Movie = Movie;
}
const server = new ComponentServer(Backend);
const response = server.receive({query: {'introspect=>': {'()': []}}});
trimSerializedFunctions(response);
expect(response).toStrictEqual({
result: {
component: {
name: 'Backend',
providedComponents: [
{
name: 'Session',
properties: [
{
name: 'token',
type: 'Attribute',
valueType: 'string?',
value: {__undefined: true},
exposure: {get: true, set: true}
}
]
},
{
name: 'Movie',
properties: [{name: 'find', type: 'Method', exposure: {call: true}}],
prototype: {
properties: [
{
name: 'id',
type: 'PrimaryIdentifierAttribute',
valueType: 'string',
default: {
__function: 'function () {\nreturn this.constructor.generateId();\n}'
},
exposure: {get: true, set: true}
},
{
name: 'slug',
type: 'SecondaryIdentifierAttribute',
valueType: 'string',
exposure: {get: true, set: true}
},
{
name: 'title',
type: 'Attribute',
valueType: 'string',
default: {__function: "function () {\nreturn ''\n}"},
validators: [
{
__validator: {
name: 'notEmpty',
function: {
__function: '(value) => value !== undefined && value.length > 0'
}
}
}
],
exposure: {get: true, set: true}
},
{
name: 'isPlaying',
type: 'Attribute',
valueType: 'boolean',
exposure: {get: true}
},
{name: 'play', type: 'Method', exposure: {call: true}}
]
},
consumedComponents: ['Session']
}
]
}
}
});
expect(() => server.receive({query: {'introspect=>': {'()': []}}, version: 1})).toThrow(
"The component client version (1) doesn't match the component server version (undefined)"
);
function trimSerializedFunctions(object: PlainObject) {
forEachDeep(object, (value, name, node) => {
if (name === '__function') {
(node as any).__function = value.replace(/\n +/g, '\n');
}
});
}
});
test('Accessing attributes', async () => {
class Movie extends Component {
@attribute('number') static limit = 100;
@expose({get: true, set: true}) @attribute('number') static offset = 0;
@expose({get: true, set: true}) @primaryIdentifier('string') id!: string;
@expose({get: true, set: true}) @attribute('string') title = '';
@attribute('number?') rating?: number;
}
const server = new ComponentServer(Movie);
expect(
server.receive({
query: {'<=': {__component: 'typeof Movie'}}
})
).toStrictEqual({
result: {__component: 'typeof Movie', offset: 0}
});
expect(
server.receive({
query: {
'<=': {__component: 'typeof Movie'},
'offset': true
}
})
).toStrictEqual({
result: {offset: 0},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'typeof Movie'},
'offset': true,
'limit': true
}
})
).toStrictEqual({
result: {
offset: 0,
limit: {__error: "Cannot get the value of an attribute that is not allowed (name: 'limit')"}
},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', __new: true, id: 'm1'}
}
})
).toStrictEqual({
result: {__component: 'Movie', __new: true, id: 'm1', title: ''},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', __new: true, id: 'm1'},
'title': true
},
components: [{__component: 'typeof Movie', offset: 0}]
})
).toStrictEqual({
result: {title: ''},
components: [{__component: 'Movie', __new: true, id: 'm1', title: ''}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', __new: true, id: 'm1', title: 'Inception'}
}
})
).toStrictEqual({
result: {__component: 'Movie', __new: true, id: 'm1'},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', __new: true, id: 'm1', title: 'Inception'},
'title': true
}
})
).toStrictEqual({
result: {title: 'Inception'},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', id: 'm1'}
}
})
).toStrictEqual({
result: {__component: 'Movie', id: 'm1'},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', id: 'm1'},
'title': true
}
})
).toStrictEqual({
result: {
title: {
__error: "Cannot get the value of an unset attribute (attribute: 'Movie.prototype.title')"
}
},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', id: 'm1', rating: 10}
}
})
).toStrictEqual({
result: {__component: 'Movie', id: 'm1'},
components: [{__component: 'typeof Movie', offset: 0}]
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie', id: 'm1', rating: 10},
'rating': true
}
})
).toStrictEqual({
result: {
rating: {
__error:
"Cannot get the value of an unset attribute (attribute: 'Movie.prototype.rating')"
}
},
components: [{__component: 'typeof Movie', offset: 0}]
});
});
test('Invoking methods', async () => {
class Movie extends Component {
@expose({call: true}) @method() static exposedClassMethod() {
return 'exposedClassMethod()';
}
@expose({call: true}) @method() static async exposedAsyncClassMethod() {
return 'exposedAsyncClassMethod()';
}
@method() static unexposedClassMethod() {
return 'unexposedClassMethod()';
}
@expose({call: true}) @method() exposedInstanceMethod() {
return 'exposedInstanceMethod()';
}
@expose({call: true}) @method() async exposedAsyncInstanceMethod() {
return 'exposedAsyncInstanceMethod()';
}
@method() unexposedInstanceMethod() {
return 'unexposedInstanceMethod()';
}
@expose({call: true}) @method() exposedInstanceMethodWithParameters(
param1: any,
param2: any
) {
return `exposedInstanceMethodWithParameters(${param1}, ${param2})`;
}
}
const server = new ComponentServer(Movie);
expect(
server.receive({
query: {
'<=': {__component: 'typeof Movie'},
'exposedClassMethod=>': {
'()': []
}
}
})
).toStrictEqual({result: 'exposedClassMethod()'});
expect(
await server.receive({
query: {
'<=': {__component: 'typeof Movie'},
'exposedAsyncClassMethod=>': {
'()': []
}
}
})
).toStrictEqual({result: 'exposedAsyncClassMethod()'});
expect(
server.receive({
query: {
'<=': {__component: 'typeof Movie'},
'unexposedClassMethod=>': {
'()': []
}
}
})
).toStrictEqual({
result: {
__error: "Cannot execute a method that is not allowed (name: 'unexposedClassMethod')"
}
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie'},
'exposedInstanceMethod=>': {
'()': []
}
}
})
).toStrictEqual({result: 'exposedInstanceMethod()'});
expect(
await server.receive({
query: {
'<=': {__component: 'Movie'},
'exposedAsyncInstanceMethod=>': {
'()': []
}
}
})
).toStrictEqual({result: 'exposedAsyncInstanceMethod()'});
expect(
server.receive({
query: {
'<=': {__component: 'Movie'},
'unexposedInstanceMethod=>': {
'()': []
}
}
})
).toStrictEqual({
result: {
__error: "Cannot execute a method that is not allowed (name: 'unexposedInstanceMethod')"
}
});
expect(
server.receive({
query: {
'<=': {__component: 'Movie'},
'exposedInstanceMethodWithParameters=>': {
'()': [1, 2]
}
}
})
).toStrictEqual({result: 'exposedInstanceMethodWithParameters(1, 2)'});
});
});
================================================
FILE: packages/component-server/src/component-server.ts
================================================
import {
Component,
ComponentSet,
IntrospectedComponent,
PropertyFilter,
Attribute,
PropertyOperation,
ExecutionMode,
isComponentClassOrInstance,
assertIsComponentClass,
serialize,
deserialize
} from '@layr/component';
import {invokeQuery} from '@deepr/runtime';
import {possiblyAsync} from 'possibly-async';
import {PlainObject} from 'core-helpers';
import debugModule from 'debug';
// To display the debug log, set this environment:
// DEBUG=layr:component-server DEBUG_DEPTH=5
const debug = debugModule('layr:component-server');
// To display errors occurring while invoking queries, set this environment:
// DEBUG=layr:component-server:error DEBUG_DEPTH=5
const debugError = debugModule('layr:component-server:error');
import {isComponentServerInstance} from './utilities';
export interface ComponentServerLike {
receive: ComponentServer['receive'];
}
export type ComponentServerOptions = {
name?: string;
version?: number;
};
/**
* A base class allowing to serve a root [`Component`](https://layrjs.com/docs/v2/reference/component) so it can be accessed by a [`ComponentClient`](https://layrjs.com/docs/v2/reference/component-client).
*
* Typically, instead of using this class, you would use a class such as [`ComponentHTTPServer`](https://layrjs.com/docs/v2/reference/component-http-server), or a middleware such as [`component-express-middleware`](https://layrjs.com/docs/v2/reference/component-express-middleware).
*/
export class ComponentServer {
_component: typeof Component;
_introspectedComponent: IntrospectedComponent | undefined;
_name: string | undefined;
_version: number | undefined;
/**
* Creates a component server.
*
* @param component The root [`Component`](https://layrjs.com/docs/v2/reference/component) class to serve.
* @param [options.version] A number specifying the version of the returned component server (default: `undefined`).
*
* @returns A `ComponentServer` instance.
*
* @examplelink See [`ComponentClient`'s example](https://layrjs.com/docs/v2/reference/component-client#constructor).
*
* @category Creation
*/
constructor(component: typeof Component, options: ComponentServerOptions = {}) {
const {name, version} = options;
assertIsComponentClass(component);
const introspectedComponent = component.introspect();
this._component = component;
this._introspectedComponent = introspectedComponent;
this._name = name;
this._version = version;
}
getComponent() {
return this._component;
}
receive(
request: {
query: PlainObject;
components?: PlainObject[];
version?: number;
},
options: {executionMode?: ExecutionMode} = {}
) {
const {
query: serializedQuery,
components: serializedComponents,
version: clientVersion
} = request;
const {executionMode} = options;
this.validateVersion(clientVersion);
const componentFork = this._component.fork();
if (executionMode !== undefined) {
componentFork.setExecutionMode(executionMode);
}
const deeprRoot = this.getDeeprRoot();
const getFilter = function (attribute: Attribute) {
return attribute.operationIsAllowed('get');
};
const setFilter = function (attribute: Attribute) {
return executionMode === 'background' || attribute.operationIsAllowed('set');
};
const authorizer = function (this: any, name: string, operation: string, _params?: any[]) {
if (executionMode === 'background') {
return true;
}
if (this === deeprRoot && name === 'introspect' && operation === 'call') {
return true;
}
if (isComponentClassOrInstance(this)) {
const property = this.hasProperty(name) ? this.getProperty(name) : undefined;
if (property !== undefined) {
return property.operationIsAllowed(operation as PropertyOperation);
}
}
return false;
};
const errorHandler = function (error: Error) {
debugError(
`An error occurred while invoking a query (query: %o, components: %o)\n%s`,
serializedQuery,
serializedComponents,
error.stack
);
if (executionMode === 'background') {
console.error(
`An error occurred while invoking a background query (query: ${JSON.stringify(
serializedQuery
)}): ${error.message}`
);
}
return error;
};
debugRequest({serializedQuery, serializedComponents});
return possiblyAsync(
this._deserializeRequest(
{serializedQuery, serializedComponents},
{rootComponent: componentFork, attributeFilter: setFilter}
),
({deserializedQuery, deserializedComponents}) =>
possiblyAsync(componentFork.initialize(), () =>
possiblyAsync(
invokeQuery(deeprRoot, deserializedQuery, {authorizer, errorHandler}),
(result) => {
if (executionMode === 'background') {
return {};
}
return possiblyAsync(
this._serializeResponse(
{result, components: deserializedComponents},
{attributeFilter: getFilter}
),
({serializedResult, serializedComponents}) => {
debugResponse({serializedResult, serializedComponents});
return {
...(typeof serializedResult !== 'undefined' && {result: serializedResult}),
...(typeof serializedComponents !== 'undefined' && {
components: serializedComponents
})
};
}
);
}
)
)
);
}
_deserializeRequest(
{
serializedQuery,
serializedComponents
}: {serializedQuery: PlainObject; serializedComponents: PlainObject[] | undefined},
{
rootComponent,
attributeFilter
}: {rootComponent: typeof Component; attributeFilter: PropertyFilter}
) {
return possiblyAsync(
deserialize(serializedComponents, {
rootComponent,
attributeFilter,
source: 'client'
}),
(deserializedComponents: (typeof Component | Component)[] | undefined) => {
const deserializedComponentSet: ComponentSet = new Set(deserializedComponents);
return possiblyAsync(
deserialize(serializedQuery, {
rootComponent,
attributeFilter,
deserializedComponents: deserializedComponentSet,
source: 'client'
}),
(deserializedQuery: PlainObject) => {
deserializedComponents = Array.from(deserializedComponentSet);
return {deserializedQuery, deserializedComponents};
}
);
}
);
}
_serializeResponse(
{
result,
components
}: {result: unknown; components: (typeof Component | Component)[] | undefined},
{attributeFilter}: {attributeFilter: PropertyFilter}
) {
const serializedComponents: ComponentSet = new Set();
const componentDependencies: ComponentSet = new Set(components);
const possiblyAsyncSerializedResult =
typeof result !== 'undefined'
? serialize(result, {
attributeFilter,
serializedComponents,
componentDependencies,
serializeFunctions: true,
target: 'client'
})
: undefined;
return possiblyAsync(possiblyAsyncSerializedResult, (serializedResult) => {
let serializedComponentDependencies: PlainObject[] | undefined;
const handledComponentDependencies = new Set(serializedComponents);
const serializeComponentDependencies = function (
componentDependencies: ComponentSet
): void | PromiseLike {
if (componentDependencies.size === 0) {
return;
}
const additionalComponentDependencies: ComponentSet = new Set();
return possiblyAsync(
possiblyAsync.forEach(
componentDependencies.values(),
(componentDependency: typeof Component | Component) => {
if (handledComponentDependencies.has(componentDependency)) {
return;
}
return possiblyAsync(
componentDependency.serialize({
attributeFilter,
componentDependencies: additionalComponentDependencies,
ignoreEmptyComponents: true,
serializeFunctions: true,
target: 'client'
}),
(serializedComponentDependency) => {
if (serializedComponentDependency !== undefined) {
if (serializedComponentDependencies === undefined) {
serializedComponentDependencies = [];
}
serializedComponentDependencies.push(serializedComponentDependency);
}
handledComponentDependencies.add(componentDependency);
}
);
}
),
() => serializeComponentDependencies(additionalComponentDependencies)
);
};
return possiblyAsync(serializeComponentDependencies(componentDependencies), () => ({
serializedResult,
serializedComponents: serializedComponentDependencies
}));
});
}
validateVersion(clientVersion: number | undefined) {
const serverVersion = this._version;
if (clientVersion !== serverVersion) {
throw Object.assign(
new Error(
`The component client version (${clientVersion}) doesn't match the component server version (${serverVersion})`
),
{code: 'COMPONENT_CLIENT_VERSION_DOES_NOT_MATCH_COMPONENT_SERVER_VERSION', expose: true}
);
}
}
_deeprRoot!: PlainObject;
getDeeprRoot() {
if (this._deeprRoot === undefined) {
this._deeprRoot = Object.create(null);
this._deeprRoot.introspect = () => ({
...(this._name !== undefined && {name: this._name}),
component: this._introspectedComponent
});
}
return this._deeprRoot;
}
static isComponentServer(value: any): value is ComponentServer {
return isComponentServerInstance(value);
}
}
export function ensureComponentServer(
componentOrComponentServer: typeof Component | ComponentServer,
options: ComponentServerOptions = {}
): ComponentServer {
if (isComponentServerInstance(componentOrComponentServer)) {
return componentOrComponentServer;
}
return new ComponentServer(componentOrComponentServer, options);
}
function debugRequest({
serializedQuery,
serializedComponents
}: {
serializedQuery: PlainObject;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Receiving query: %o';
const values = [serializedQuery];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
function debugResponse({
serializedResult,
serializedComponents
}: {
serializedResult: unknown;
serializedComponents: PlainObject[] | undefined;
}) {
let message = 'Returning result: %o';
const values = [serializedResult];
if (serializedComponents !== undefined) {
message += ' (components: %o)';
values.push(serializedComponents);
}
debug(message, ...values);
}
================================================
FILE: packages/component-server/src/index.ts
================================================
export * from './component-server';
export * from './utilities';
================================================
FILE: packages/component-server/src/utilities.ts
================================================
import {getTypeOf} from 'core-helpers';
import type {ComponentServer} from './component-server';
export function isComponentServerClass(value: any): value is typeof ComponentServer {
return typeof value?.isComponentServer === 'function';
}
export function isComponentServerInstance(value: any): value is ComponentServer {
return typeof value?.constructor?.isComponentServer === 'function';
}
export function assertIsComponentServerInstance(value: any): asserts value is ComponentServer {
if (!isComponentServerInstance(value)) {
throw new Error(
`Expected a component server instance, but received a value of type '${getTypeOf(value)}'`
);
}
}
================================================
FILE: packages/component-server/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/execution-queue/README.md
================================================
# @layr/store
A base class for implementing Layr stores.
## Installation
```
npm install @layr/store
```
## License
MIT
================================================
FILE: packages/execution-queue/package.json
================================================
{
"name": "@layr/execution-queue",
"version": "2.0.37",
"description": "A class for queueing method executions",
"keywords": [
"layr",
"method",
"queue",
"execution",
"queueing"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/execution-queue",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"core-helpers": "^1.0.8",
"lodash": "^4.17.21",
"tslib": "^2.4.1"
},
"devDependencies": {
"@layr/component-server": "^2.0.70",
"@layr/utilities": "^1.0.9",
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191"
}
}
================================================
FILE: packages/execution-queue/src/execution-queue.test.ts
================================================
import {Component, provide, primaryIdentifier, attribute, method} from '@layr/component';
import {ComponentServer} from '@layr/component-server';
import {sleep} from '@layr/utilities';
import {ExecutionQueue} from './execution-queue';
describe('ExecutionQueue', () => {
test('Static method queuing', async () => {
let taskStatus: string | undefined = undefined;
class Application extends Component {
@method({queue: true}) static async task() {
taskStatus = 'running';
await sleep(150);
taskStatus = 'done';
}
}
const componentServer = new ComponentServer(Application);
const executionQueue = new ExecutionQueue(async (query) => {
await sleep(10);
componentServer.receive({query}, {executionMode: 'background'});
});
executionQueue.registerRootComponent(Application);
expect(taskStatus).toBeUndefined();
await Application.task();
expect(taskStatus).toBe('running');
await sleep(200);
expect(taskStatus).toBe('done');
});
test('Instance method queuing', async () => {
let movieStatus: string | undefined = undefined;
class Movie extends Component {
@primaryIdentifier() id!: string;
@attribute('string') title!: string;
@method({queue: true}) async play() {
movieStatus = `playing (id: '${this.id}')`;
await sleep(150);
movieStatus = `played (id: '${this.id}')`;
}
}
class Application extends Component {
@provide() static Movie = Movie;
}
const componentServer = new ComponentServer(Application);
const executionQueue = new ExecutionQueue(async (query) => {
await sleep(10);
componentServer.receive({query}, {executionMode: 'background'});
});
executionQueue.registerRootComponent(Application);
const movie = new Application.Movie({id: 'movie1', title: 'Inception'});
expect(movieStatus).toBeUndefined();
await movie.play();
expect(movieStatus).toBe("playing (id: 'movie1')");
await sleep(200);
expect(movieStatus).toBe("played (id: 'movie1')");
});
});
================================================
FILE: packages/execution-queue/src/execution-queue.ts
================================================
import {Component, serialize, assertIsComponentClass} from '@layr/component';
import {hasOwnProperty, isPlainObject, PlainObject} from 'core-helpers';
type ExecutionQueueSender = (query: PlainObject) => Promise;
export class ExecutionQueue {
_sender: ExecutionQueueSender;
constructor(sender: ExecutionQueueSender) {
this._sender = sender;
}
registerRootComponent(rootComponent: typeof Component) {
assertIsComponentClass(rootComponent);
const register = (component: typeof Component | Component) => {
for (const method of component.getMethods()) {
const name = method.getName();
const queueing = method.getQueueing();
if (!queueing) {
continue;
}
const orignalMethod: (...args: any[]) => any = (component as any)[name];
if (hasOwnProperty(orignalMethod, '__queued')) {
throw new Error(`Method already registered to an execution queue (${method.describe()})`);
}
const executionQueue = this;
const backgroundMethod = async function (
this: typeof Component | Component,
...args: any[]
) {
const [firstArgument, ...otherArguments] = args;
if (
isPlainObject(firstArgument) &&
hasOwnProperty(firstArgument, '__callOriginalMethod')
) {
await orignalMethod.call(this, ...otherArguments);
return;
}
const query = {
'<=': this,
[`${name}=>`]: {'()': [{__callOriginalMethod: true}, ...args]}
};
const serializedQuery = serialize(query, {returnComponentReferences: true});
await executionQueue._sender(serializedQuery);
};
Object.defineProperty(backgroundMethod, '__queued', {value: true});
(component as any)[name] = backgroundMethod;
}
};
for (const component of rootComponent.traverseComponents()) {
if (!component.isEmbedded()) {
register(component);
register(component.prototype);
}
}
}
}
================================================
FILE: packages/execution-queue/src/index.ts
================================================
export * from './execution-queue';
================================================
FILE: packages/execution-queue/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/integration-testing/README.md
================================================
# @layr/integration-testing
Tests involving a combination of Layr packages.
## License
MIT
================================================
FILE: packages/integration-testing/package.json
================================================
{
"name": "@layr/integration-testing",
"version": "2.0.125",
"private": true,
"description": "Tests involving a combination of Layr packages",
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/integration-testing",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"prepare": "npm run test",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/component-client": "^2.0.86",
"@layr/component-express-middleware": "^2.0.94",
"@layr/component-http-client": "^2.0.76",
"@layr/component-http-server": "^2.0.97",
"@layr/component-koa-middleware": "^2.0.94",
"@layr/component-server": "^2.0.70",
"@layr/memory-navigator": "^2.0.59",
"@layr/memory-store": "^2.0.82",
"@layr/mongodb-store": "^2.0.82",
"@layr/navigator": "^2.0.55",
"@layr/routable": "^2.0.113",
"@layr/storable": "^2.0.76",
"@layr/store": "^2.0.81",
"cross-fetch": "^3.1.5",
"express": "^4.18.2",
"koa": "^2.14.1",
"koa-mount": "^4.0.0",
"lodash": "^4.17.21",
"mongodb": "^3.7.3",
"mongodb-memory-server": "^8.11.0",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5",
"@types/koa": "^2.13.5",
"@types/koa-mount": "^4.0.2",
"@types/lodash": "^4.14.191",
"@types/mongodb": "^3.6.20"
}
}
================================================
FILE: packages/integration-testing/src/client-server.test.ts
================================================
import {ComponentClient} from '@layr/component-client';
import {ComponentServer} from '@layr/component-server';
import {Counter as BackendCounter} from './counter.fixture';
describe('Client/server', () => {
test('Simple component', async () => {
const server = new ComponentServer(BackendCounter);
const client = new ComponentClient(server);
const Counter = client.getComponent() as typeof BackendCounter;
let counter = new Counter();
expect(counter.value).toBe(0);
counter.increment();
expect(counter.value).toBe(1);
counter.increment();
expect(counter.value).toBe(2);
});
});
================================================
FILE: packages/integration-testing/src/component-express-middleware.test.ts
================================================
import express from 'express';
import type {Server} from 'http';
import {serveComponent} from '@layr/component-express-middleware';
import {ComponentHTTPClient} from '@layr/component-http-client';
import fetch from 'cross-fetch';
import {Counter as BackendCounter} from './counter.fixture';
const SERVER_PORT = 6666;
describe('Express middleware', () => {
let server: Server | undefined;
beforeAll(() => {
const app = express();
app.use('/api', serveComponent(BackendCounter));
return new Promise((resolve) => {
server = app.listen(SERVER_PORT, resolve);
});
});
afterAll(() => {
server?.close();
});
test('API-less', async () => {
const client = new ComponentHTTPClient(`http://localhost:${SERVER_PORT}/api`);
const Counter = (await client.getComponent()) as typeof BackendCounter;
let counter = new Counter();
expect(counter.value).toBe(0);
await counter.increment();
expect(counter.value).toBe(1);
await counter.increment();
expect(counter.value).toBe(2);
});
test('REST API', async () => {
let response = await fetch(`http://localhost:${SERVER_PORT}/api/ping`);
let result = await response.json();
expect(result).toBe('pong');
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello'})
});
result = await response.json();
expect(result).toStrictEqual({message: 'hello'});
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: 'hello'
});
result = await response.json();
expect(result).toBe('hello');
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
body: Buffer.from([1, 2, 3])
});
result = await response.json();
expect(result).toStrictEqual([1, 2, 3]);
});
});
================================================
FILE: packages/integration-testing/src/component-koa-middleware.test.ts
================================================
import Koa from 'koa';
import mount from 'koa-mount';
import type {Server} from 'http';
import {serveComponent} from '@layr/component-koa-middleware';
import {ComponentHTTPClient} from '@layr/component-http-client';
import fetch from 'cross-fetch';
import {Counter as BackendCounter} from './counter.fixture';
const SERVER_PORT = 5555;
describe('Koa middleware', () => {
let server: Server | undefined;
beforeAll(() => {
const app = new Koa();
app.use(mount('/api', serveComponent(BackendCounter)));
return new Promise((resolve) => {
server = app.listen(SERVER_PORT, resolve);
});
});
afterAll(() => {
server?.close();
});
test('API-less', async () => {
const client = new ComponentHTTPClient(`http://localhost:${SERVER_PORT}/api`);
const Counter = (await client.getComponent()) as typeof BackendCounter;
let counter = new Counter();
expect(counter.value).toBe(0);
await counter.increment();
expect(counter.value).toBe(1);
await counter.increment();
expect(counter.value).toBe(2);
});
test('REST API', async () => {
let response = await fetch(`http://localhost:${SERVER_PORT}/api/ping`);
let result = await response.json();
expect(result).toBe('pong');
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello'})
});
result = await response.json();
expect(result).toStrictEqual({message: 'hello'});
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: 'hello'
});
result = await response.json();
expect(result).toBe('hello');
response = await fetch(`http://localhost:${SERVER_PORT}/api/echo`, {
method: 'POST',
body: Buffer.from([1, 2, 3])
});
result = await response.json();
expect(result).toStrictEqual([1, 2, 3]);
});
});
================================================
FILE: packages/integration-testing/src/counter.fixture.ts
================================================
import {Component, primaryIdentifier, attribute, method, expose} from '@layr/component';
import {Routable, httpRoute} from '@layr/routable';
import {MemoryNavigator} from '@layr/memory-navigator';
export class Counter extends Routable(Component) {
@expose({get: true, set: true}) @primaryIdentifier() id!: string;
@expose({get: true, set: true}) @attribute('number') value = 0;
@expose({call: true}) @method() increment() {
this.value++;
}
@httpRoute('GET', '/ping') static ping() {
return 'pong';
}
@httpRoute('POST', '/echo', {
transformers: {
input(_, {headers, body}) {
let data;
if (Buffer.isBuffer(body)) {
data = Array.from(body);
} else if (headers['content-type'] === 'application/json') {
data = JSON.parse(body);
} else {
data = body;
}
return {data};
}
}
})
static echo({data}: {data: any}) {
return data;
}
}
const navigator = new MemoryNavigator();
Counter.registerNavigator(navigator);
================================================
FILE: packages/integration-testing/src/http-client-server.test.ts
================================================
import {ComponentHTTPClient} from '@layr/component-http-client';
import {ComponentHTTPServer} from '@layr/component-http-server';
import {Counter as BackendCounter} from './counter.fixture';
const SERVER_PORT = Math.floor(Math.random() * (60000 - 50000 + 1) + 50000);
describe('HTTP client/server', () => {
let server: ComponentHTTPServer;
beforeAll(async () => {
server = new ComponentHTTPServer(BackendCounter, {port: SERVER_PORT});
await server.start();
});
afterAll(async () => {
await server?.stop();
});
test('Simple component', async () => {
const client = new ComponentHTTPClient(`http://localhost:${SERVER_PORT}`);
const Counter = (await client.getComponent()) as typeof BackendCounter;
let counter = new Counter();
expect(counter.value).toBe(0);
await counter.increment();
expect(counter.value).toBe(1);
await counter.increment();
expect(counter.value).toBe(2);
});
});
================================================
FILE: packages/integration-testing/src/memory-navigator.test.ts
================================================
import {Component, provide, primaryIdentifier} from '@layr/component';
import {MemoryNavigator} from '@layr/memory-navigator';
import {Routable, route, wrapper, callRouteByURL} from '@layr/routable';
describe('MemoryNavigator', () => {
let currentRouteResult: string;
const getNavigator = function () {
class Home extends Routable(Component) {
@route('[]/') static HomePage() {
return `Home`;
}
}
class Movie extends Routable(Component) {
@primaryIdentifier() id!: string;
@route('[/]movies') static ListPage() {
return `Movies`;
}
@wrapper('[/]movies/:id') ItemLayout({children}: {children: () => any}) {
return `Movie #${this.id}${children()}`;
}
@route('[/movies/:id]') ItemPage() {
return '';
}
@route('[/movies/:id]/details') DetailsPage() {
return ' (details)';
}
}
class Application extends Routable(Component) {
@provide() static Home = Home;
@provide() static Movie = Movie;
@wrapper('') static RootLayout({children}: {children: () => any}) {
return `{${children()}}`;
}
@wrapper('[]/') static MainLayout({children}: {children: () => any}) {
return `[${children()}]`;
}
}
const navigator = new MemoryNavigator({
initialURLs: ['/', '/movies', '/movies/abc123?showTrailers=true#main']
});
Application.registerNavigator(navigator);
navigator.addObserver(() => {
currentRouteResult = callRouteByURL(Application, navigator.getCurrentURL());
});
navigator.callObservers();
return navigator;
};
test('new ()', async () => {
let navigator = new MemoryNavigator();
expect(navigator.getHistoryLength()).toBe(0);
expect(() => navigator.getCurrentURL()).toThrow('The navigator has no current URL');
navigator = new MemoryNavigator({initialURLs: ['/', '/movies']});
expect(navigator.getHistoryLength()).toBe(2);
expect(navigator.getCurrentURL()).toBe('/movies');
navigator = new MemoryNavigator({initialURLs: ['/', '/movies'], initialIndex: 0});
expect(navigator.getCurrentURL()).toBe('/');
});
test('getCurrentURL()', async () => {
const navigator = getNavigator();
expect(navigator.getCurrentURL()).toBe('/movies/abc123?showTrailers=true#main');
});
test('getCurrentPath()', async () => {
const navigator = getNavigator();
expect(navigator.getCurrentPath()).toBe('/movies/abc123');
navigator.goBack({defer: false});
expect(navigator.getCurrentPath()).toBe('/movies');
});
test('getCurrentQuery()', async () => {
const navigator = getNavigator();
expect(navigator.getCurrentQuery()).toEqual({showTrailers: 'true'});
navigator.goBack({defer: false});
expect(navigator.getCurrentQuery()).toEqual({});
});
test('getCurrentHash()', async () => {
const navigator = getNavigator();
expect(navigator.getCurrentHash()).toBe('main');
navigator.goBack({defer: false});
expect(navigator.getCurrentHash()).toBeUndefined();
});
test('navigate()', async () => {
const navigator = getNavigator();
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(navigator.getHistoryLength()).toBe(3);
navigator.navigate('/movies/abc123/details', {defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123 (details)]}');
expect(navigator.getHistoryLength()).toBe(4);
navigator.go(-3); // We should be at the first entry of the history
navigator.navigate('/movies/abc123', {defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(navigator.getHistoryLength()).toBe(2);
expect(navigator.getCurrentQuery()).toEqual({});
navigator.navigate('/movies/abc123?showTrailers=true', {defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(navigator.getHistoryLength()).toBe(3);
expect(navigator.getCurrentQuery()).toEqual({showTrailers: 'true'});
});
test('redirect()', async () => {
const navigator = getNavigator();
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(navigator.getHistoryLength()).toBe(3);
navigator.redirect('/movies/def456', {defer: false});
expect(currentRouteResult).toBe('{[Movie #def456]}');
expect(navigator.getHistoryLength()).toBe(3);
navigator.go(-2); // We should be at the first entry of the history
navigator.redirect('/movies/abc123', {defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(navigator.getHistoryLength()).toBe(1);
});
test('go()', async () => {
const navigator = getNavigator();
expect(currentRouteResult).toBe('{[Movie #abc123]}');
navigator.go(-1, {defer: false});
expect(currentRouteResult).toBe('{[Movies]}');
navigator.go(-1, {defer: false});
expect(currentRouteResult).toBe('{Home}');
navigator.go(2, {defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(() => navigator.go(1, {defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(() => navigator.go(2, {defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(() => navigator.go(-3, {defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(() => navigator.go(-4, {defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
});
test('goBack()', async () => {
const navigator = getNavigator();
expect(currentRouteResult).toBe('{[Movie #abc123]}');
navigator.goBack({defer: false});
expect(currentRouteResult).toBe('{[Movies]}');
navigator.goBack({defer: false});
expect(currentRouteResult).toBe('{Home}');
expect(() => navigator.goBack({defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
expect(currentRouteResult).toBe('{Home}');
});
test('goForward()', async () => {
const navigator = getNavigator();
navigator.go(-2, {defer: false});
expect(currentRouteResult).toBe('{Home}');
navigator.goForward({defer: false});
expect(currentRouteResult).toBe('{[Movies]}');
navigator.goForward({defer: false});
expect(currentRouteResult).toBe('{[Movie #abc123]}');
expect(() => navigator.goForward({defer: false})).toThrow(
'Cannot go to an entry that does not exist in the navigator history'
);
expect(currentRouteResult).toBe('{[Movie #abc123]}');
});
test('getHistoryLength()', async () => {
const navigator = getNavigator();
expect(navigator.getHistoryLength()).toBe(3);
});
});
================================================
FILE: packages/integration-testing/src/navigator.test.ts
================================================
import {Component, provide, primaryIdentifier} from '@layr/component';
import {Routable, route, wrapper, findRouteByURL, callRouteByURL} from '@layr/routable';
import {Navigator, isNavigatorInstance} from '@layr/navigator';
describe('Navigator', () => {
class MockNavigator extends Navigator {
_getCurrentURL() {
return new URL('http://localhost/');
}
_navigate(_url: URL) {}
_redirect(_url: URL) {}
_reload(_url: URL | undefined) {}
_go(_delta: number) {}
_getHistoryLength(): number {
return 1;
}
_getHistoryIndex(): number {
return 0;
}
}
test('Creation', async () => {
let navigator = new MockNavigator();
expect(isNavigatorInstance(navigator)).toBe(true);
// @ts-expect-error
expect(() => new MockNavigator({unknown: true})).toThrow(
"Did not expect the option 'unknown' to exist"
);
});
describe('Registration', () => {
test('registerNavigator() and getNavigator()', async () => {
class Profile extends Routable(Component) {}
class User extends Routable(Component) {
@provide() static Profile = Profile;
}
class Movie extends Routable(Component) {}
class Application extends Routable(Component) {
@provide() static User = User;
@provide() static Movie = Movie;
}
const navigator = new MockNavigator();
Application.registerNavigator(navigator);
expect(Application.getNavigator()).toBe(navigator);
expect(User.getNavigator()).toBe(navigator);
expect(Movie.getNavigator()).toBe(navigator);
expect(Profile.getNavigator()).toBe(navigator);
expect(Application.prototype.getNavigator()).toBe(navigator);
expect(User.prototype.getNavigator()).toBe(navigator);
expect(Movie.prototype.getNavigator()).toBe(navigator);
expect(Profile.prototype.getNavigator()).toBe(navigator);
});
});
describe('Routes', () => {
const getApplication = function () {
class Movie extends Routable(Component) {
@primaryIdentifier() id!: string;
@route('[/]movies/:id', {params: {showDetails: 'boolean?'}}) ItemPage({
showDetails = false
}) {
return `Movie #${this.id}${showDetails ? ' (with details)' : ''}`;
}
}
class Actor extends Routable(Component) {
@route('[/]actors/top') static TopPage() {
return 'Top actors';
}
}
class Application extends Routable(Component) {
@provide() static Movie = Movie;
@provide() static Actor = Actor;
@wrapper('/') static MainLayout({children}: {children: () => any}) {
return `[${children()}]`;
}
@route('[/]ping', {
filter(request) {
return request?.method === 'GET';
}
})
static ping() {
return 'pong';
}
@route('[/]*') static NotFoundPage() {
return 'Sorry, there is nothing here.';
}
}
const navigator = new MockNavigator();
Application.registerNavigator(navigator);
return Application;
};
test('findRouteByURL()', async () => {
const Application = getApplication();
let result = findRouteByURL(Application, '/movies/abc123');
expect(result!.routable.getComponentType()).toBe('Movie');
expect(result!.route.getName()).toBe('ItemPage');
expect(result!.identifiers).toEqual({id: 'abc123'});
expect(result!.params).toEqual({});
result = findRouteByURL(Application, '/movies/abc123?showDetails=1');
expect(result!.routable.getComponentType()).toBe('Movie');
expect(result!.route.getName()).toBe('ItemPage');
expect(result!.identifiers).toEqual({id: 'abc123'});
expect(result!.params).toEqual({showDetails: true});
result = findRouteByURL(Application, '/actors/top');
expect(result!.routable.getComponentType()).toBe('typeof Actor');
expect(result!.route.getName()).toBe('TopPage');
expect(result!.identifiers).toEqual({});
expect(result!.params).toEqual({});
result = findRouteByURL(Application, '/movies/abc123/details');
expect(result!.routable.getComponentType()).toBe('typeof Application');
expect(result!.route.getName()).toBe('NotFoundPage');
expect(result!.identifiers).toEqual({});
expect(result!.params).toEqual({});
result = findRouteByURL(Application, '/ping', {method: 'GET'});
expect(result!.routable.getComponentType()).toBe('typeof Application');
expect(result!.route.getName()).toBe('ping');
expect(result!.identifiers).toEqual({});
expect(result!.params).toEqual({});
result = findRouteByURL(Application, '/ping', {method: 'POST'});
expect(result!.routable.getComponentType()).toBe('typeof Application');
expect(result!.route.getName()).toBe('NotFoundPage');
expect(result!.identifiers).toEqual({});
expect(result!.params).toEqual({});
});
test('callRouteByURL()', async () => {
const Application = getApplication();
expect(callRouteByURL(Application, '/movies/abc123')).toBe('[Movie #abc123]');
expect(callRouteByURL(Application, '/movies/abc123?showDetails=1')).toBe(
'[Movie #abc123 (with details)]'
);
expect(callRouteByURL(Application, '/actors/top')).toBe('[Top actors]');
expect(callRouteByURL(Application, '/movies/abc123/details')).toBe(
'[Sorry, there is nothing here.]'
);
expect(callRouteByURL(Application, '/ping', {method: 'GET'})).toBe('[pong]');
expect(callRouteByURL(Application, '/ping', {method: 'POST'})).toBe(
'[Sorry, there is nothing here.]'
);
});
});
});
================================================
FILE: packages/integration-testing/src/storable.fixture.ts
================================================
import {MongoClient} from 'mongodb';
import mapKeys from 'lodash/mapKeys';
export const CREATED_ON = new Date('2020-03-22T01:27:42.612Z');
export const UPDATED_ON = new Date('2020-03-22T01:29:33.673Z');
export function getInitialCollections() {
return {
User: [
{
__component: 'User',
id: 'user1',
email: '1@user.com',
reference: 1,
fullName: 'User 1',
accessLevel: 0,
tags: ['spammer', 'blocked'],
location: {country: 'USA', city: 'Paris'},
pastLocations: [
{country: 'USA', city: 'Nice'},
{country: 'USA', city: 'New York'}
],
picture: {__component: 'Picture', type: 'JPEG', url: 'https://pictures.com/1-2.jpg'},
pastPictures: [
{__component: 'Picture', type: 'JPEG', url: 'https://pictures.com/1-1.jpg'},
{__component: 'Picture', type: 'PNG', url: 'https://pictures.com/1-1.png'}
],
organization: {__component: 'Organization', id: 'org1'},
emailIsVerified: false,
createdOn: CREATED_ON
},
{
__component: 'User',
id: 'user11',
email: '11@user.com',
reference: 11,
fullName: 'User 11',
accessLevel: 3,
tags: ['owner', 'admin'],
location: {country: 'USA'},
pastLocations: [{country: 'France'}],
picture: {__component: 'Picture', type: 'JPEG', url: 'https://pictures.com/11-1.jpg'},
pastPictures: [{__component: 'Picture', type: 'PNG', url: 'https://pictures.com/11-1.png'}],
organization: {__component: 'Organization', id: 'org2'},
emailIsVerified: true,
createdOn: CREATED_ON
},
{
__component: 'User',
id: 'user12',
email: '12@user.com',
reference: 12,
fullName: 'User 12',
accessLevel: 1,
tags: [],
location: {country: 'France', city: 'Paris'},
pastLocations: [
{country: 'France', city: 'Nice'},
{country: 'Japan', city: 'Tokyo'}
],
picture: {__component: 'Picture', type: 'PNG', url: 'https://pictures.com/12-3.png'},
pastPictures: [
{__component: 'Picture', type: 'PNG', url: 'https://pictures.com/12-2.png'},
{__component: 'Picture', type: 'PNG', url: 'https://pictures.com/12-1.png'}
],
organization: {__component: 'Organization', id: 'org2'},
emailIsVerified: true,
createdOn: CREATED_ON
},
{
__component: 'User',
id: 'user13',
email: '13@user.com',
reference: 13,
fullName: 'User 13',
accessLevel: 3,
tags: ['admin'],
pastLocations: [],
pastPictures: [],
emailIsVerified: false,
createdOn: CREATED_ON
}
],
Organization: [
{
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
},
{
__component: 'Organization',
id: 'org2',
slug: 'organization-2',
name: 'Organization 2'
}
]
};
}
export async function seedMongoDB(connectionString: string) {
const client = await MongoClient.connect(connectionString, {
useNewUrlParser: true,
useUnifiedTopology: true
});
const initialCollections = getInitialCollections();
for (const [collectionName, serializedStorables] of Object.entries(initialCollections)) {
const collection = client.db().collection(collectionName);
for (const serializedStorable of serializedStorables) {
const document = mapKeys(serializedStorable, (_, name) => (name === 'id' ? '_id' : name));
await collection.insertOne(document);
}
}
await client.close();
}
================================================
FILE: packages/integration-testing/src/storable.test.ts
================================================
import {
Component,
EmbeddedComponent,
AttributeSelector,
provide,
expose,
serialize
} from '@layr/component';
import {
Storable,
StorableComponent,
attribute,
primaryIdentifier,
secondaryIdentifier,
method,
loader,
finder,
isStorableClass,
isStorableInstance,
StorableAttributeHookName
} from '@layr/storable';
import type {Store} from '@layr/store';
import {MemoryStore} from '@layr/memory-store';
import {MongoDBStore} from '@layr/mongodb-store';
import {MongoMemoryServer} from 'mongodb-memory-server';
import {ComponentClient} from '@layr/component-client';
import {ComponentServer} from '@layr/component-server';
import {PlainObject} from 'core-helpers';
import {getInitialCollections, CREATED_ON, UPDATED_ON, seedMongoDB} from './storable.fixture';
jest.setTimeout(60 * 1000); // 1 minute
describe('Storable', () => {
class BasePicture extends EmbeddedComponent {
@attribute('string?') type?: string;
@attribute('string') url!: string;
}
class BaseOrganization extends Storable(Component) {
@primaryIdentifier() id!: string;
@secondaryIdentifier() slug!: string;
@attribute('string') name!: string;
}
class BaseUser extends Storable(Component) {
@provide() static Picture = BasePicture;
@provide() static Organization = BaseOrganization;
@primaryIdentifier() id!: string;
@secondaryIdentifier() email!: string;
@secondaryIdentifier('number') reference!: number;
@attribute('string') fullName: string = '';
@attribute('number') accessLevel: number = 0;
@attribute('string[]') tags: string[] = [];
@attribute('object?') location?: PlainObject;
@attribute('object[]') pastLocations: PlainObject[] = [];
@attribute('Picture?') picture?: BasePicture;
@attribute('Picture[]') pastPictures: BasePicture[] = [];
@attribute('Organization?') organization?: BaseOrganization;
@attribute('boolean') emailIsVerified: boolean = false;
@attribute('Date') createdOn: Date = new Date('2020-03-22T01:27:42.612Z');
@attribute('Date?') updatedOn?: Date;
}
describe('General methods', () => {
test('isStorableClass()', async () => {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
expect(isStorableClass(User)).toBe(true);
expect(isStorableClass(User.prototype)).toBe(false);
expect(isStorableClass(Picture)).toBe(false);
expect(isStorableClass(Organization)).toBe(true);
const user = new User({id: 'user1', email: '1@user.com', reference: 1});
expect(isStorableClass(user)).toBe(false);
});
test('isStorableInstance()', async () => {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
expect(isStorableInstance(User.prototype)).toBe(true);
expect(isStorableInstance(User)).toBe(false);
expect(isStorableInstance(Picture.prototype)).toBe(false);
expect(isStorableInstance(Organization.prototype)).toBe(true);
const user = new User({
id: 'user1',
email: '1@user.com',
reference: 1,
picture: new Picture({type: 'JPEG', url: 'https://pictures.com/1-1.jpg'}),
organization: new Organization({id: 'org1', slug: 'organization-1', name: 'Organization 1'})
});
expect(isStorableInstance(user)).toBe(true);
expect(isStorableInstance(user.picture)).toBe(false);
expect(isStorableInstance(user.organization)).toBe(true);
});
test('getStore() and hasStore()', async () => {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
expect(User.hasStore()).toBe(false);
expect(() => User.getStore()).toThrow(
"Cannot get the store of a storable component that is not registered (component: 'User')"
);
expect(Organization.hasStore()).toBe(false);
const store = new MemoryStore();
store.registerRootComponent(User);
expect(User.hasStore()).toBe(true);
expect(User.getStore()).toBe(store);
expect(Organization.hasStore()).toBe(true);
expect(Organization.getStore()).toBe(store);
});
});
describe('Storage operations', () => {
if (true) {
describe('With a local memory store', () => {
testOperations(function () {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
const store = new MemoryStore({initialCollections: getInitialCollections()});
store.registerRootComponent(User);
return User;
});
});
}
if (true) {
describe('With a local MongoDB store', () => {
let userClass: typeof BaseUser;
let server: MongoMemoryServer;
let store: MongoDBStore;
beforeEach(async () => {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
userClass = User;
server = await MongoMemoryServer.create({instance: {storageEngine: 'wiredTiger'}});
const connectionString = server.getUri();
await seedMongoDB(connectionString);
store = new MongoDBStore(connectionString);
store.registerRootComponent(User);
await store.connect();
await store.migrateStorables({silent: true});
});
afterEach(async () => {
await store?.disconnect();
await server?.stop();
});
testOperations(function () {
return userClass;
});
});
}
if (true) {
describe('With a remote memory store', () => {
testOperations(async () => {
const server = (() => {
@expose({
prototype: {
type: {get: true, set: true},
url: {get: true, set: true}
}
})
class Picture extends BasePicture {}
@expose({
get: {call: true},
prototype: {
id: {get: true, set: true},
slug: {get: true},
name: {get: true, set: true},
load: {call: true}
}
})
class Organization extends BaseOrganization {}
@expose({
get: {call: true},
find: {call: true},
count: {call: true},
prototype: {
id: {get: true, set: true},
email: {get: true, set: true},
reference: {get: true, set: true},
fullName: {get: true, set: true},
accessLevel: {get: true, set: true},
tags: {get: true, set: true},
location: {get: true, set: true},
pastLocations: {get: true, set: true},
picture: {get: true, set: true},
pastPictures: {get: true, set: true},
organization: {get: true, set: true},
emailIsVerified: {get: true, set: true},
createdOn: {get: true, set: true},
updatedOn: {get: true, set: true},
load: {call: true},
save: {call: true},
delete: {call: true}
}
})
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
const store = new MemoryStore({initialCollections: getInitialCollections()});
store.registerRootComponent(User);
return new ComponentServer(User);
})();
const client = new ComponentClient(server, {mixins: [Storable], batchable: true});
const User = (await client.getComponent()) as typeof BaseUser;
return User;
});
});
}
if (true) {
describe('Without a store', () => {
testOperations(function () {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
}
return User;
});
});
}
function testOperations(userClassProvider: () => typeof BaseUser | Promise) {
describe('Storable instances', () => {
test('get()', async () => {
const User = await userClassProvider();
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
await expect(User.fork().get({id: 'user1'})).rejects.toThrow(
"To be able to execute the load() method (called from get()), a storable component should be registered in a store or have an exposed load() remote method (component: 'User')"
);
await expect(User.fork().get({email: '1@user.com'})).rejects.toThrow(
"To be able to execute the get() method with a secondary identifier, a storable component should be registered in a store or have an exposed get() remote method (component: 'User')"
);
return;
}
let user = await User.fork().get('user1');
const expectedSerializedUser = {
__component: 'User',
id: 'user1',
email: '1@user.com',
reference: 1,
fullName: 'User 1',
accessLevel: 0,
tags: ['spammer', 'blocked'],
location: {country: 'USA', city: 'Paris'},
pastLocations: [
{country: 'USA', city: 'Nice'},
{country: 'USA', city: 'New York'}
],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-2.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-1.jpg'
},
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/1-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
},
emailIsVerified: false,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
};
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual(
expectedSerializedUser
);
user = await User.fork().get({id: 'user1'});
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual(
expectedSerializedUser
);
user = await User.fork().get({id: 'user1'}, {fullName: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
fullName: 'User 1'
});
user = await User.fork().get({id: 'user1'}, {});
expect(user.serialize()).toStrictEqual({__component: 'User', id: 'user1'});
await expect(User.fork().get({id: 'user2'})).rejects.toThrow(
"Cannot load a component that is missing from the store (component: 'User', id: 'user2')"
);
expect(
await User.fork().get({id: 'user2'}, true, {throwIfMissing: false})
).toBeUndefined();
user = await User.fork().get({email: '1@user.com'});
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual(
expectedSerializedUser
);
user = await User.fork().get({email: '1@user.com'}, {fullName: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com',
fullName: 'User 1'
});
await expect(User.fork().get({email: '2@user.com'})).rejects.toThrow(
"Cannot load a component that is missing from the store (component: 'User', email: '2@user.com')"
);
user = await User.fork().get({reference: 1}, {fullName: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
reference: 1,
fullName: 'User 1'
});
await expect(User.fork().get({reference: 2})).rejects.toThrow(
"Cannot load a component that is missing from the store (component: 'User', reference: 2)"
);
await expect(User.fork().get({fullName: 'User 1'})).rejects.toThrow(
"A property with the specified name was found, but it is not an identifier attribute (attribute: 'User.prototype.fullName')"
);
await expect(User.fork().get({name: 'User 1'})).rejects.toThrow(
"The identifier attribute 'name' is missing (component: 'User')"
);
const UserFork = User.fork();
user = new UserFork({id: 'user1', email: '1@user.com', reference: 1});
await expect(UserFork.get({id: 'user1'})).rejects.toThrow(
"Cannot load a storable component that is marked as new (component: 'User')"
);
});
test('has()', async () => {
const User = await userClassProvider();
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
await expect(User.fork().has('user1')).rejects.toThrow(
"To be able to execute the load() method (called from has()), a storable component should be registered in a store or have an exposed load() remote method (component: 'User')"
);
await expect(User.fork().has({email: '1@user.com'})).rejects.toThrow(
"To be able to execute the get() method (called from has()) with a secondary identifier, a storable component should be registered in a store or have an exposed get() remote method (component: 'User')"
);
return;
}
expect(await User.fork().has('user1')).toBe(true);
expect(await User.fork().has('user2')).toBe(false);
expect(await User.fork().has({id: 'user1'})).toBe(true);
expect(await User.fork().has({id: 'user2'})).toBe(false);
expect(await User.fork().has({email: '1@user.com'})).toBe(true);
expect(await User.fork().has({email: '2@user.com'})).toBe(false);
expect(await User.fork().has({reference: 1})).toBe(true);
expect(await User.fork().has({reference: 2})).toBe(false);
});
test('load()', async () => {
const User = await userClassProvider();
let UserFork: typeof BaseUser;
let user: BaseUser;
let organization: BaseOrganization;
user = User.fork().instantiate({id: 'user1'});
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
return await expect(user.load()).rejects.toThrow(
"To be able to execute the load() method, a storable component should be registered in a store or have an exposed load() remote method (component: 'User')"
);
}
expect(await user.load({})).toBe(user);
expect(user.serialize()).toStrictEqual({__component: 'User', id: 'user1'});
expect(await user.load({email: true})).toBe(user);
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com'
});
expect(await user.load()).toBe(user);
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com',
reference: 1,
fullName: 'User 1',
accessLevel: 0,
tags: ['spammer', 'blocked'],
location: {country: 'USA', city: 'Paris'},
pastLocations: [
{country: 'USA', city: 'Nice'},
{country: 'USA', city: 'New York'}
],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-2.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-1.jpg'
},
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/1-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
},
emailIsVerified: false,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
});
// ------
user = User.fork().instantiate({id: 'user2'});
await expect(user.load({})).rejects.toThrow(
"Cannot load a component that is missing from the store (component: 'User', id: 'user2'"
);
expect(await user.load({}, {throwIfMissing: false})).toBeUndefined();
// ------
user = User.fork().instantiate({email: '1@user.com'});
expect(await user.load({})).toBe(user);
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com'
});
expect(await user.load({fullName: true})).toBe(user);
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com',
fullName: 'User 1'
});
// ------
if (User.hasStore()) {
const store = User.getStore() as Store;
user = User.fork().instantiate({id: 'user1'});
store.startTrace();
expect(await user.load({})).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'load',
params: [
user,
{
attributeSelector: {
id: true
},
throwIfMissing: true
}
],
result: user
}
]);
store.stopTrace();
store.startTrace();
expect(await user.load({})).toBe(user);
expect(store.getTrace()).toStrictEqual([]);
store.stopTrace();
store.startTrace();
expect(await user.load({id: true})).toBe(user);
expect(store.getTrace()).toStrictEqual([]);
store.stopTrace();
store.startTrace();
expect(await user.load({id: true, email: true})).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'load',
params: [
user,
{
attributeSelector: {email: true},
throwIfMissing: true
}
],
result: user
}
]);
store.stopTrace();
store.startTrace();
expect(await user.load({id: true, email: true, fullName: true})).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'load',
params: [
user,
{
attributeSelector: {fullName: true},
throwIfMissing: true
}
],
result: user
}
]);
store.stopTrace();
store.startTrace();
expect(await user.load({id: true, email: true, fullName: true})).toBe(user);
expect(store.getTrace()).toStrictEqual([]);
store.stopTrace();
store.startTrace();
expect(await user.load({fullName: true}, {reload: true})).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'load',
params: [
user,
{
attributeSelector: {
id: true,
fullName: true
},
throwIfMissing: true
}
],
result: user
}
]);
store.stopTrace();
store.startTrace();
expect(await user.load({organization: {}})).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'load',
params: [
user,
{
attributeSelector: {organization: {id: true}},
throwIfMissing: true
}
],
result: user
}
]);
store.stopTrace();
store.startTrace();
expect(await user.load({organization: {}})).toBe(user);
expect(store.getTrace()).toStrictEqual([]);
store.stopTrace();
}
// ------
user = new (User.fork())({id: 'user1', email: '1@user.com', reference: 1});
await expect(user.load({})).rejects.toThrow(
"Cannot load a storable component that is marked as new (component: 'User')"
);
// --- With a referenced identifiable component loaded from a fork ---
UserFork = User.fork();
organization = await UserFork.Organization.get('org1');
const UserForkFork = UserFork.fork();
user = await UserForkFork.get('user1', {organization: {}});
expect(user.organization?.isForkOf(organization));
// ------
organization = User.fork().Organization.instantiate(
{slug: 'organization-1'},
{source: 'store'}
);
expect(await organization.load({})).toBe(organization);
expect(organization.serialize()).toStrictEqual({
__component: 'Organization',
id: 'org1',
slug: 'organization-1'
});
organization = User.fork().Organization.instantiate(
{slug: 'organization-1'},
{source: 'store'}
);
expect(await organization.load(true)).toBe(organization);
expect(organization.serialize()).toStrictEqual({
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
});
// ------
UserFork = User.fork();
user = UserFork.instantiate({id: 'user1'});
organization = UserFork.Organization.instantiate({id: 'org1'});
await Promise.all([
user.load({fullName: true, organization: {}}),
organization.load({name: true})
]);
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
fullName: 'User 1',
organization: {
__component: 'Organization',
id: 'org1'
}
});
expect(organization.serialize()).toStrictEqual({
__component: 'Organization',
id: 'org1',
name: 'Organization 1'
});
});
test('save()', async () => {
const User = await userClassProvider();
let UserFork = User.fork();
let PictureFork = UserFork.Picture;
let OrganizationFork = UserFork.Organization;
let user = new UserFork({
id: 'user2',
email: '2@user.com',
reference: 2,
fullName: 'User 2',
tags: ['newcomer'],
location: {country: 'USA', city: 'New York'},
picture: new PictureFork({type: 'JPEG', url: 'https://pictures.com/2-1.jpg'}),
organization: OrganizationFork.instantiate({id: 'org2'})
});
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
return await expect(user.save()).rejects.toThrow(
"To be able to execute the save() method, a storable component should be registered in a store or have an exposed save() remote method (component: 'User')"
);
}
expect(user.isNew()).toBe(true);
expect(user.picture!.isNew()).toBe(true);
expect(user.getAttribute('id').getValueSource()).toBe('local');
expect(user.getAttribute('fullName').getValueSource()).toBe('local');
expect(user.getAttribute('tags').getValueSource()).toBe('local');
expect(user.getAttribute('picture').getValueSource()).toBe('local');
expect(user.picture!.getAttribute('url').getValueSource()).toBe('local');
expect(await user.save()).toBe(user);
expect(user.isNew()).toBe(false);
// TODO: expect(user.picture!.isNew()).toBe(false);
expect(user.getAttribute('id').getValueSource()).toBe(
User.hasStore() ? 'store' : 'server'
);
expect(user.getAttribute('fullName').getValueSource()).toBe(
User.hasStore() ? 'store' : 'server'
);
expect(user.getAttribute('tags').getValueSource()).toBe(
User.hasStore() ? 'store' : 'server'
);
expect(user.getAttribute('picture').getValueSource()).toBe(
User.hasStore() ? 'store' : 'server'
);
expect(user.picture!.getAttribute('url').getValueSource()).toBe(
User.hasStore() ? 'store' : 'server'
);
UserFork = User.fork();
PictureFork = UserFork.Picture;
OrganizationFork = UserFork.Organization;
user = await UserFork.get('user2');
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual({
__component: 'User',
id: 'user2',
email: '2@user.com',
reference: 2,
fullName: 'User 2',
accessLevel: 0,
tags: ['newcomer'],
location: {country: 'USA', city: 'New York'},
pastLocations: [],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/2-1.jpg'
},
pastPictures: [],
organization: {
__component: 'Organization',
id: 'org2',
slug: 'organization-2',
name: 'Organization 2'
},
emailIsVerified: false,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
});
// ------
user.fullName = 'User 2 (modified)';
user.accessLevel = 1;
user.tags = [];
user.location!.state = 'New York';
user.pastLocations.push({country: 'USA', city: 'San Francisco'});
user.picture!.url = 'https://pictures.com/2-2.jpg';
user.pastPictures.push(
new PictureFork({type: 'JPEG', url: 'https://pictures.com/2-1.jpg'})
);
user.organization = OrganizationFork.instantiate({id: 'org1'});
user.updatedOn = UPDATED_ON;
expect(await user.save()).toBe(user);
user = await User.fork().get('user2', {
fullName: true,
accessLevel: true,
tags: true,
location: true,
pastLocations: true,
picture: true,
pastPictures: true,
organization: true,
updatedOn: true
});
expect(user.serialize({includeReferencedComponents: true})).toStrictEqual({
__component: 'User',
id: 'user2',
fullName: 'User 2 (modified)',
accessLevel: 1,
tags: [],
location: {country: 'USA', state: 'New York', city: 'New York'},
pastLocations: [{country: 'USA', city: 'San Francisco'}],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/2-2.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/2-1.jpg'
}
],
organization: {
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
},
updatedOn: {__date: UPDATED_ON.toISOString()}
});
// ------
user.location = {country: 'USA'};
delete user.location.state;
delete user.location.city;
delete user.pastLocations[0].city;
user.picture!.type = undefined;
user.pastPictures[0].type = undefined;
user.organization = undefined;
user.updatedOn = undefined;
expect(await user.save()).toBe(user);
user = await User.fork().get('user2', {
location: true,
pastLocations: true,
picture: true,
pastPictures: true,
organization: true,
updatedOn: true
});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user2',
location: {country: 'USA'},
pastLocations: [{country: 'USA'}],
picture: {
__component: 'Picture',
type: {__undefined: true},
url: 'https://pictures.com/2-2.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: {__undefined: true},
url: 'https://pictures.com/2-1.jpg'
}
],
organization: {__undefined: true},
updatedOn: {__undefined: true}
});
// ------
// Undefined values in object attributes should not be saved
user.location!.country = undefined;
user.pastLocations[0].country = undefined;
expect(await user.save()).toBe(user);
user = await User.fork().get('user2', {location: true, pastLocations: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user2',
location: {},
pastLocations: [{}]
});
// ------
if (User.hasStore()) {
const store = User.getStore() as Store;
user = await User.fork().get('user2', {fullName: true, accessLevel: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user2',
fullName: 'User 2 (modified)',
accessLevel: 1
});
user.accessLevel = 2;
store.startTrace();
expect(await user.save()).toBe(user);
expect(store.getTrace()).toStrictEqual([
{
operation: 'save',
params: [
user,
{
attributeSelector: {id: true, accessLevel: true},
throwIfMissing: true,
throwIfExists: false
}
],
result: user
}
]);
store.stopTrace();
user = await User.fork().get('user2', {fullName: true, accessLevel: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user2',
fullName: 'User 2 (modified)',
accessLevel: 2
});
store.startTrace();
expect(await user.save()).toBe(user);
expect(store.getTrace()).toStrictEqual([]);
store.stopTrace();
}
// ------
user = await User.fork().get('user2', {pastPictures: {type: true}});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user2',
pastPictures: [{__component: 'Picture', type: {__undefined: true}}]
});
user.pastPictures[0].type = 'JPEG';
await expect(user.save()).rejects.toThrow(
"Cannot save an array item that has some unset attributes (component: 'User.Picture')"
);
await expect(user.save({pastPictures: {type: true}})).rejects.toThrow(
"Cannot save an array item that has some unset attributes (component: 'User.Picture')"
);
// ------
user = User.fork().instantiate({id: 'user3'});
user.fullName = 'User 3';
expect(await user.save(true, {throwIfMissing: false})).toBe(undefined);
await expect(user.save()).rejects.toThrow(
"Cannot save a non-new component that is missing from the store (component: 'User', id: 'user3')"
);
// ------
user = new (User.fork())({
id: 'user1',
email: '1@user.com',
reference: 1,
fullName: 'User 1 (modified)'
});
expect(await user.save(true, {throwIfExists: false})).toBe(undefined);
await expect(user.save()).rejects.toThrow(
"Cannot save a new component that already exists in the store (component: 'User', id: 'user1')"
);
// ------
user = User.fork().instantiate({id: 'user3'});
user.fullName = 'User 3';
await expect(
user.save(true, {throwIfMissing: true, throwIfExists: true})
).rejects.toThrow(
"The 'throwIfMissing' and 'throwIfExists' options cannot be both set to true"
);
// ------
if (User.hasStore() && User.getStore() instanceof MongoDBStore) {
user = new (User.fork())({
id: 'user3',
email: '1@user.com',
reference: 3
});
await expect(user.save()).rejects.toThrow(
"Cannot save a component with an attribute value that should be unique but already exists in the store (component: 'User', id: 'user3', index: 'email [unique]')"
);
user = User.fork().instantiate({id: 'user2'});
user.reference = 1;
await expect(user.save()).rejects.toThrow(
"Cannot save a component with an attribute value that should be unique but already exists in the store (component: 'User', id: 'user2', index: 'reference [unique]')"
);
}
});
test('delete()', async () => {
const User = await userClassProvider();
let user = User.fork().instantiate({id: 'user1'});
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
return await expect(user.delete()).rejects.toThrow(
"To be able to execute the delete() method, a storable component should be registered in a store or have an exposed delete() remote method (component: 'User')"
);
}
expect(user.getIsDeletedMark()).toBe(false);
expect(await user.delete()).toBe(user);
expect(user.getIsDeletedMark()).toBe(true);
await expect(user.delete()).rejects.toThrow(
"Cannot delete a component that is missing from the store (component: 'User', id: 'user1'"
);
expect(await user.delete({throwIfMissing: false})).toBeUndefined();
user = new (User.fork())({id: 'user1', email: '1@user.com', reference: 1});
await expect(user.delete()).rejects.toThrow(
"Cannot delete a storable component that is new (component: 'User')"
);
});
test('find()', async () => {
const User = await userClassProvider();
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
return await expect(User.fork().find()).rejects.toThrow(
"To be able to execute the find() method, a storable component should be registered in a store or have an exposed find() remote method (component: 'User')"
);
}
// === Simple queries ===
// --- Without a query ---
let users = await User.fork().find();
expect(serialize(users, {includeReferencedComponents: true})).toStrictEqual([
{
__component: 'User',
id: 'user1',
email: '1@user.com',
reference: 1,
fullName: 'User 1',
accessLevel: 0,
tags: ['spammer', 'blocked'],
location: {country: 'USA', city: 'Paris'},
pastLocations: [
{country: 'USA', city: 'Nice'},
{country: 'USA', city: 'New York'}
],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-2.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/1-1.jpg'
},
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/1-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org1',
slug: 'organization-1',
name: 'Organization 1'
},
emailIsVerified: false,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
},
{
__component: 'User',
id: 'user11',
email: '11@user.com',
reference: 11,
fullName: 'User 11',
accessLevel: 3,
tags: ['owner', 'admin'],
location: {country: 'USA'},
pastLocations: [{country: 'France'}],
picture: {
__component: 'Picture',
type: 'JPEG',
url: 'https://pictures.com/11-1.jpg'
},
pastPictures: [
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/11-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org2',
slug: 'organization-2',
name: 'Organization 2'
},
emailIsVerified: true,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
},
{
__component: 'User',
id: 'user12',
email: '12@user.com',
reference: 12,
fullName: 'User 12',
accessLevel: 1,
tags: [],
location: {country: 'France', city: 'Paris'},
pastLocations: [
{country: 'France', city: 'Nice'},
{country: 'Japan', city: 'Tokyo'}
],
picture: {
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-3.png'
},
pastPictures: [
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-2.png'
},
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org2'
},
emailIsVerified: true,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
},
{
__component: 'User',
id: 'user13',
email: '13@user.com',
reference: 13,
fullName: 'User 13',
accessLevel: 3,
tags: ['admin'],
location: {__undefined: true},
pastLocations: [],
picture: {__undefined: true},
pastPictures: [],
organization: {__undefined: true},
emailIsVerified: false,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
}
]);
// --- With a simple query ---
users = await User.fork().find({fullName: 'User 12'});
expect(serialize(users, {includeReferencedComponents: true})).toStrictEqual([
{
__component: 'User',
id: 'user12',
email: '12@user.com',
reference: 12,
fullName: 'User 12',
accessLevel: 1,
tags: [],
location: {country: 'France', city: 'Paris'},
pastLocations: [
{country: 'France', city: 'Nice'},
{country: 'Japan', city: 'Tokyo'}
],
picture: {
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-3.png'
},
pastPictures: [
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-2.png'
},
{
__component: 'Picture',
type: 'PNG',
url: 'https://pictures.com/12-1.png'
}
],
organization: {
__component: 'Organization',
id: 'org2',
slug: 'organization-2',
name: 'Organization 2'
},
emailIsVerified: true,
createdOn: {__date: CREATED_ON.toISOString()},
updatedOn: {__undefined: true}
}
]);
// --- With an attribute selector ---
users = await User.fork().find({accessLevel: 3}, {email: true});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11', email: '11@user.com'},
{__component: 'User', id: 'user13', email: '13@user.com'}
]);
users = await User.fork().find({emailIsVerified: false}, {email: true});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1', email: '1@user.com'},
{__component: 'User', id: 'user13', email: '13@user.com'}
]);
// --- With a query involving two attributes ---
users = await User.fork().find({accessLevel: 3, emailIsVerified: true}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user11'}]);
// --- With 'sort' ---
users = await User.fork().find({}, {accessLevel: true}, {sort: {accessLevel: 'asc'}});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1', accessLevel: 0},
{__component: 'User', id: 'user12', accessLevel: 1},
{__component: 'User', id: 'user11', accessLevel: 3},
{__component: 'User', id: 'user13', accessLevel: 3}
]);
users = await User.fork().find({}, {accessLevel: true}, {sort: {accessLevel: 'desc'}});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11', accessLevel: 3},
{__component: 'User', id: 'user13', accessLevel: 3},
{__component: 'User', id: 'user12', accessLevel: 1},
{__component: 'User', id: 'user1', accessLevel: 0}
]);
users = await User.fork().find(
{},
{reference: true, accessLevel: true},
{sort: {accessLevel: 'asc', reference: 'desc'}}
);
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1', reference: 1, accessLevel: 0},
{__component: 'User', id: 'user12', reference: 12, accessLevel: 1},
{__component: 'User', id: 'user13', reference: 13, accessLevel: 3},
{__component: 'User', id: 'user11', reference: 11, accessLevel: 3}
]);
// --- With 'skip' ---
users = await User.fork().find({}, {}, {skip: 2});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user12'},
{__component: 'User', id: 'user13'}
]);
// --- With 'limit' ---
users = await User.fork().find({}, {}, {limit: 2});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'}
]);
// --- With 'skip' and 'limit' ---
users = await User.fork().find({}, {}, {skip: 1, limit: 2});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user12'}
]);
// --- With 'sort', 'skip', and 'limit' ---
users = await User.fork().find({}, {}, {sort: {id: 'desc'}, skip: 1, limit: 2});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user12'},
{__component: 'User', id: 'user11'}
]);
await expect(User.fork().find({unknownAttribute: 1})).rejects.toThrow(
"An unknown attribute was specified in a query (component: 'User', attribute: 'unknownAttribute')"
);
// === Advanced queries ===
// --- With a basic operator ---
// - '$equal' -
users = await User.fork().find({accessLevel: {$equal: 0}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
await expect(User.fork().find({accessLevel: {$equal: /0/}}, {})).rejects.toThrow(
"Expected a scalar value of the operator '$equal', but received a value of type 'RegExp' (query: '{\"accessLevel\":{\"$equal\":{}}}')"
);
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
// - '$notEqual' -
users = await User.fork().find({accessLevel: {$notEqual: 3}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// - '$greaterThan' -
users = await User.fork().find({accessLevel: {$greaterThan: 3}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({accessLevel: {$greaterThan: 2}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// - '$greaterThanOrEqual' -
users = await User.fork().find({accessLevel: {$greaterThanOrEqual: 3}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// - '$lessThan' -
users = await User.fork().find({accessLevel: {$lessThan: 1}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
// - '$lessThanOrEqual' -
users = await User.fork().find({accessLevel: {$lessThanOrEqual: 1}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// - '$in' -
users = await User.fork().find({accessLevel: {$in: []}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({accessLevel: {$in: [2, 4, 5]}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({accessLevel: {$in: [0, 1]}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// --- With two basic operators ---
users = await User.fork().find({accessLevel: {$greaterThan: 1, $lessThan: 3}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({accessLevel: {$greaterThanOrEqual: 0, $lessThan: 2}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// --- With an impossible expression ---
users = await User.fork().find({accessLevel: {$greaterThan: 1, $equal: 1}}, {});
expect(serialize(users)).toStrictEqual([]);
// --- With a string operator ---
// - '$includes' -
users = await User.fork().find({email: {$includes: '.org'}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({email: {$includes: '2'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user12'}]);
await expect(User.fork().find({email: {$includes: 2}}, {})).rejects.toThrow(
"Expected a string as value of the operator '$includes', but received a value of type 'number' (query: '{\"email\":{\"$includes\":2}}')"
);
// - '$startsWith' -
users = await User.fork().find({email: {$startsWith: '2'}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({email: {$startsWith: '1@'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
// - '$endsWith' -
users = await User.fork().find({location: {city: {$endsWith: 'town'}}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({location: {city: {$endsWith: 'ris'}}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// - '$matches' -
users = await User.fork().find({location: {country: {$matches: /usa/}}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({location: {country: {$matches: /usa/i}}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'}
]);
await expect(User.fork().find({location: {country: {$matches: 'usa'}}})).rejects.toThrow(
'Expected a regular expression as value of the operator \'$matches\', but received a value of type \'string\' (query: \'{"country":{"$matches":"usa"}}\')'
);
// --- With a logical operator ---
// - '$not' -
users = await User.fork().find({createdOn: {$not: CREATED_ON}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({accessLevel: {$not: {$lessThan: 3}}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
if (User.hasStore() && User.getStore() instanceof MongoDBStore) {
// TODO: Make the following test passes with a MemoryStore
users = await User.fork().find({tags: {$not: {$in: ['admin']}}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
}
// - '$and' -
users = await User.fork().find({$and: [{tags: 'owner'}, {emailIsVerified: false}]}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({$and: [{tags: 'admin'}, {emailIsVerified: true}]}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user11'}]);
// - '$or' -
users = await User.fork().find(
{$or: [{accessLevel: {$lessThan: 0}}, {accessLevel: {$greaterThan: 3}}]},
{}
);
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({$or: [{accessLevel: 0}, {accessLevel: 1}]}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
// - '$nor' -
users = await User.fork().find(
{$nor: [{emailIsVerified: false}, {emailIsVerified: true}]},
{}
);
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({$nor: [{accessLevel: 0}, {accessLevel: 1}]}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// --- With an object attribute ---
users = await User.fork().find({location: {country: 'Japan'}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({location: {country: 'France'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user12'}]);
users = await User.fork().find({location: {country: 'USA'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'}
]);
users = await User.fork().find({location: {country: 'USA', city: 'Paris'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
users = await User.fork().find({location: undefined}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user13'}]);
// --- With an array attribute ---
// - '$some' -
users = await User.fork().find({tags: {$some: 'angel'}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({tags: {$some: 'blocked'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
users = await User.fork().find({tags: {$some: 'admin'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// '$some' should be implicit
users = await User.fork().find({tags: 'admin'}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// '$includes' should be replaced by '$some' when '$some' is missing
users = await User.fork().find({tags: {$includes: 'admin'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
// When '$some' is present, '$includes' remains a string operator
users = await User.fork().find({tags: {$some: {$includes: 'lock'}}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
// - '$every' -
users = await User.fork().find({tags: {$every: 'admin'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user12'},
{__component: 'User', id: 'user13'}
]);
// - '$length' -
users = await User.fork().find({tags: {$length: 3}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({tags: {$length: 0}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user12'}]);
users = await User.fork().find({tags: {$length: 2}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'}
]);
// --- With an array of object attribute ---
users = await User.fork().find({pastLocations: {country: 'Canada'}}, {});
expect(serialize(users)).toStrictEqual([]);
users = await User.fork().find({pastLocations: {country: 'Japan'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user12'}]);
users = await User.fork().find({pastLocations: {country: 'France'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user12'}
]);
users = await User.fork().find({pastLocations: {country: 'USA', city: 'Nice'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
users = await User.fork().find({pastLocations: {city: undefined}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user11'}]);
// --- With an array of embedded components ---
// - '$some' -
users = await User.fork().find({pastPictures: {type: 'JPEG'}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
users = await User.fork().find({pastPictures: {type: 'PNG'}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user12'}
]);
// - '$length' -
users = await User.fork().find({pastPictures: {$length: 0}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user13'}]);
users = await User.fork().find({pastPictures: {$length: 1}}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user11'}]);
users = await User.fork().find({pastPictures: {$length: 2}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user12'}
]);
users = await User.fork().find({pastPictures: {$length: 3}}, {});
expect(serialize(users)).toStrictEqual([]);
// --- With a component specified as query ---
let UserFork = User.fork();
const user = await UserFork.get('user1', {email: true});
users = await UserFork.find(user, {fullName: true});
expect(users).toHaveLength(1);
expect(users[0]).toBe(user);
expect(serialize(user)).toStrictEqual({
__component: 'User',
id: 'user1',
email: '1@user.com',
fullName: 'User 1'
});
// --- With an a referenced component specified in a query ---
UserFork = User.fork();
const organization = await UserFork.Organization.get('org2');
users = await UserFork.find({organization}, {});
expect(serialize(users)).toStrictEqual([
{
__component: 'User',
id: 'user11'
},
{
__component: 'User',
id: 'user12'
}
]);
// --- With an array of referenced components specified in a query (using '$in') ---
UserFork = User.fork();
const organization1 = UserFork.Organization.instantiate({id: 'org1'});
const organization2 = UserFork.Organization.instantiate({id: 'org2'});
users = await UserFork.find({organization: {$in: [organization1, organization2]}}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1'},
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user12'}
]);
});
test('count()', async () => {
const User = await userClassProvider();
if (!(User.hasStore() || User.getRemoteComponent() !== undefined)) {
return await expect(User.fork().count()).rejects.toThrow(
"To be able to execute the count() method, a storable component should be registered in a store or have an exposed count() remote method (component: 'User')"
);
}
// === Simple queries ===
expect(await User.fork().count()).toBe(4);
expect(await User.fork().count({fullName: 'User 12'})).toBe(1);
expect(await User.fork().count({accessLevel: 3})).toBe(2);
expect(await User.fork().count({emailIsVerified: false})).toBe(2);
expect(await User.fork().count({accessLevel: 3, emailIsVerified: true})).toBe(1);
// === Advanced queries ===
// --- With an array attribute ---
expect(await User.fork().count({tags: {$some: 'angel'}})).toBe(0);
expect(await User.fork().count({tags: {$some: 'blocked'}})).toBe(1);
expect(await User.fork().count({tags: {$some: 'admin'}})).toBe(2);
expect(await User.fork().count({tags: 'admin'})).toBe(2);
// --- With a component specified as query ---
const UserFork = User.fork();
const user = UserFork.instantiate({reference: 1});
expect(await UserFork.count(user)).toBe(1);
});
});
}
});
describe('Loaders', () => {
test('getStorableAttributesWithLoader()', async () => {
const User = getUserClass();
const attributes = Array.from(User.prototype.getStorableAttributesWithLoader());
expect(attributes).toHaveLength(1);
expect(attributes[0].getName()).toBe('firstName');
});
test('getStorableComputedAttributes()', async () => {
const User = getUserClass();
const attributes = Array.from(User.prototype.getStorableComputedAttributes());
expect(attributes).toHaveLength(1);
expect(attributes[0].getName()).toBe('firstName');
});
test('load()', async () => {
const User = getUserClass();
let user = User.fork().instantiate({id: 'user1'});
await user.load({});
expect(user.getAttribute('firstName').isSet()).toBe(false);
user = User.fork().instantiate({id: 'user1'});
await user.load({firstName: true});
expect(user.firstName).toBe('User');
user = User.fork().instantiate({id: 'user1'});
await user.load({fullName: true, firstName: true});
expect(user.serialize()).toStrictEqual({
__component: 'User',
id: 'user1',
fullName: 'User 1',
firstName: 'User'
});
});
function getUserClass() {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
@loader(async function (this: User) {
await this.load({fullName: true});
const firstName = this.fullName.split(' ')[0];
return firstName;
})
@attribute('string')
firstName!: string;
}
const store = new MemoryStore({initialCollections: getInitialCollections()});
store.registerRootComponent(User);
return User;
}
});
describe('Finders', () => {
test('getStorablePropertiesWithFinder()', async () => {
const User = getUserClass();
const properties = Array.from(User.prototype.getStorablePropertiesWithFinder());
expect(properties).toHaveLength(2);
expect(properties[0].getName()).toBe('hasNoAccess');
expect(properties[1].getName()).toBe('hasAccessLevel');
});
test('getStorableComputedAttributes()', async () => {
const User = getUserClass();
const attributes = Array.from(User.prototype.getStorableComputedAttributes());
expect(attributes).toHaveLength(1);
expect(attributes[0].getName()).toBe('hasNoAccess');
});
test('find()', async () => {
const User = getUserClass();
let users = await User.fork().find({hasNoAccess: true}, {});
expect(serialize(users)).toStrictEqual([{__component: 'User', id: 'user1'}]);
users = await User.fork().find({hasNoAccess: true}, {hasNoAccess: true});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user1', accessLevel: 0, hasNoAccess: true}
]);
users = await User.fork().find({hasAccessLevel: 3}, {});
expect(serialize(users)).toStrictEqual([
{__component: 'User', id: 'user11'},
{__component: 'User', id: 'user13'}
]);
});
function getUserClass() {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
@loader(async function (this: User) {
await this.load({accessLevel: true});
return this.accessLevel === 0;
})
@finder(async function () {
return {accessLevel: 0};
})
@attribute('boolean?')
hasNoAccess?: string;
@finder(async function (accessLevel) {
return {accessLevel};
})
@method()
async hasAccessLevel(accessLevel: number) {
await this.load({accessLevel: true});
return this.accessLevel === accessLevel;
}
}
const store = new MemoryStore({initialCollections: getInitialCollections()});
store.registerRootComponent(User);
return User;
}
});
describe('Hooks', () => {
test('getStorableAttributesWithHook()', async () => {
const User = getUserClass();
const getAttributesWithHook = (
storable: StorableComponent,
name: StorableAttributeHookName,
{
attributeSelector = true,
setAttributesOnly = false
}: {attributeSelector?: AttributeSelector; setAttributesOnly?: boolean} = {}
) =>
Array.from(
storable.getStorableAttributesWithHook(name, {attributeSelector, setAttributesOnly})
).map((attribute) => attribute.getName());
expect(getAttributesWithHook(User.prototype, 'beforeLoad')).toEqual(['email', 'fullName']);
const user = User.fork().instantiate({id: 'user1'});
expect(getAttributesWithHook(user, 'beforeLoad', {setAttributesOnly: true})).toEqual([]);
await user.load();
expect(getAttributesWithHook(user, 'beforeLoad', {setAttributesOnly: true})).toEqual([
'email',
'fullName'
]);
expect(
getAttributesWithHook(user, 'beforeLoad', {
attributeSelector: {fullName: true},
setAttributesOnly: true
})
).toEqual(['fullName']);
expect(
getAttributesWithHook(user, 'beforeLoad', {attributeSelector: {}, setAttributesOnly: true})
).toEqual([]);
});
test('beforeLoad() and afterLoad()', async () => {
const User = getUserClass();
const user = User.fork().instantiate({id: 'user1'});
expect(hookTracker.get(user, 'beforeLoadHasBeenCalled')).toBeUndefined();
expect(hookTracker.get(user, 'afterLoadHasBeenCalled')).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('email'), 'beforeLoadHasBeenCalled')
).toBeUndefined();
expect(hookTracker.get(user.getAttribute('email'), 'afterLoadHasBeenCalled')).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'beforeLoadHasBeenCalled')
).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'afterLoadHasBeenCalled')
).toBeUndefined();
await user.load();
expect(hookTracker.get(user, 'beforeLoadHasBeenCalled')).toBe(true);
expect(hookTracker.get(user, 'afterLoadHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('email'), 'beforeLoadHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('email'), 'afterLoadHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('fullName'), 'beforeLoadHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('fullName'), 'afterLoadHasBeenCalled')).toBe(true);
});
test('beforeSave() and afterSave()', async () => {
const User = getUserClass();
const user = await User.fork().get('user1');
user.fullName = 'User 1 (modified)';
expect(hookTracker.get(user, 'beforeSaveHasBeenCalled')).toBeUndefined();
expect(hookTracker.get(user, 'afterSaveHasBeenCalled')).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('email'), 'beforeSaveHasBeenCalled')
).toBeUndefined();
expect(hookTracker.get(user.getAttribute('email'), 'afterSaveHasBeenCalled')).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'beforeSaveHasBeenCalled')
).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'afterSaveHasBeenCalled')
).toBeUndefined();
await user.save();
expect(hookTracker.get(user, 'beforeSaveHasBeenCalled')).toBe(true);
expect(hookTracker.get(user, 'afterSaveHasBeenCalled')).toBe(true);
expect(
hookTracker.get(user.getAttribute('email'), 'beforeSaveHasBeenCalled')
).toBeUndefined();
expect(hookTracker.get(user.getAttribute('email'), 'afterSaveHasBeenCalled')).toBeUndefined();
expect(hookTracker.get(user.getAttribute('fullName'), 'beforeSaveHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('fullName'), 'afterSaveHasBeenCalled')).toBe(true);
});
test('beforeDelete() and afterDelete()', async () => {
const User = getUserClass();
const user = await User.fork().get('user1');
expect(hookTracker.get(user, 'beforeDeleteHasBeenCalled')).toBeUndefined();
expect(hookTracker.get(user, 'afterDeleteHasBeenCalled')).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('email'), 'beforeDeleteHasBeenCalled')
).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('email'), 'afterDeleteHasBeenCalled')
).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'beforeDeleteHasBeenCalled')
).toBeUndefined();
expect(
hookTracker.get(user.getAttribute('fullName'), 'afterDeleteHasBeenCalled')
).toBeUndefined();
await user.delete();
expect(hookTracker.get(user, 'beforeDeleteHasBeenCalled')).toBe(true);
expect(hookTracker.get(user, 'afterDeleteHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('email'), 'beforeDeleteHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('email'), 'afterDeleteHasBeenCalled')).toBe(true);
expect(hookTracker.get(user.getAttribute('fullName'), 'beforeDeleteHasBeenCalled')).toBe(
true
);
expect(hookTracker.get(user.getAttribute('fullName'), 'afterDeleteHasBeenCalled')).toBe(true);
});
function getUserClass() {
class Picture extends BasePicture {}
class Organization extends BaseOrganization {}
class User extends BaseUser {
@provide() static Picture = Picture;
@provide() static Organization = Organization;
@secondaryIdentifier('string', {
beforeLoad(this: User) {
hookTracker.set(this.getAttribute('email'), 'beforeLoadHasBeenCalled', true);
},
afterLoad(this: User) {
hookTracker.set(this.getAttribute('email'), 'afterLoadHasBeenCalled', true);
},
beforeSave(this: User) {
hookTracker.set(this.getAttribute('email'), 'beforeSaveHasBeenCalled', true);
},
afterSave(this: User) {
hookTracker.set(this.getAttribute('email'), 'afterSaveHasBeenCalled', true);
},
beforeDelete(this: User) {
hookTracker.set(this.getAttribute('email'), 'beforeDeleteHasBeenCalled', true);
},
afterDelete(this: User) {
hookTracker.set(this.getAttribute('email'), 'afterDeleteHasBeenCalled', true);
}
})
email!: string;
@attribute('string', {
beforeLoad(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'beforeLoadHasBeenCalled', true);
},
afterLoad(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'afterLoadHasBeenCalled', true);
},
beforeSave(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'beforeSaveHasBeenCalled', true);
},
afterSave(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'afterSaveHasBeenCalled', true);
},
beforeDelete(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'beforeDeleteHasBeenCalled', true);
},
afterDelete(this: User) {
hookTracker.set(this.getAttribute('fullName'), 'afterDeleteHasBeenCalled', true);
}
})
fullName!: string;
async beforeLoad(attributeSelector: AttributeSelector) {
await super.beforeLoad(attributeSelector);
hookTracker.set(this, 'beforeLoadHasBeenCalled', true);
}
async afterLoad(attributeSelector: AttributeSelector) {
await super.afterLoad(attributeSelector);
hookTracker.set(this, 'afterLoadHasBeenCalled', true);
}
async beforeSave(attributeSelector: AttributeSelector) {
await super.beforeSave(attributeSelector);
hookTracker.set(this, 'beforeSaveHasBeenCalled', true);
}
async afterSave(attributeSelector: AttributeSelector) {
await super.afterSave(attributeSelector);
hookTracker.set(this, 'afterSaveHasBeenCalled', true);
}
async beforeDelete(attributeSelector: AttributeSelector) {
await super.beforeDelete(attributeSelector);
hookTracker.set(this, 'beforeDeleteHasBeenCalled', true);
}
async afterDelete(attributeSelector: AttributeSelector) {
await super.afterDelete(attributeSelector);
hookTracker.set(this, 'afterDeleteHasBeenCalled', true);
}
}
const store = new MemoryStore({initialCollections: getInitialCollections()});
store.registerRootComponent(User);
return User;
}
const hookTrackerMap = new WeakMap();
const hookTracker = {
get(target: object, name: string) {
return hookTrackerMap.get(target)?.[name];
},
set(target: object, name: string, value: any) {
let tracker = hookTrackerMap.get(target);
if (tracker === undefined) {
tracker = {};
hookTrackerMap.set(target, tracker);
}
tracker[name] = value;
}
};
});
});
================================================
FILE: packages/integration-testing/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/memory-navigator/README.md
================================================
# @layr/memory-navigator
Provides a navigation system for a Layr app that is not running in a browser (e.g., an Electron app or a React Native app).
## Installation
```
npm install @layr/memory-navigator
```
## License
MIT
================================================
FILE: packages/memory-navigator/package.json
================================================
{
"name": "@layr/memory-navigator",
"version": "2.0.59",
"description": "Provides a navigation system for a Layr app that is not running in a browser (e.g., an Electron app or a React Native app)",
"keywords": [
"layr",
"navigator",
"navigation",
"memory"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/memory-navigator",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/navigator": "^2.0.55",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6"
}
}
================================================
FILE: packages/memory-navigator/src/index.ts
================================================
export * from './memory-navigator';
================================================
FILE: packages/memory-navigator/src/memory-navigator.ts
================================================
import {Navigator, NavigatorOptions, normalizeURL} from '@layr/navigator';
export type MemoryNavigatorOptions = NavigatorOptions & {
initialURLs?: string[];
initialIndex?: number;
};
/**
* *Inherits from [`Navigator`](https://layrjs.com/docs/v2/reference/navigator).*
*
* A [`Navigator`](https://layrjs.com/docs/v2/reference/navigator) that keeps the navigation history in memory. Useful in tests and non-browser environments like [React Native](https://reactnative.dev/).
*
* #### Usage
*
* Create a `MemoryNavigator` instance and register some [routable components](https://layrjs.com/docs/v2/reference/routable#routable-component-class) into it.
*/
export class MemoryNavigator extends Navigator {
_urls: URL[];
_index: number;
/**
* Creates a [`MemoryNavigator`](https://layrjs.com/docs/v2/reference/memory-navigator).
*
* @param [options.initialURLs] An array of URLs to populate the initial navigation history (default: `[]`).
* @param [options.initialIndex] A number specifying the current entry's index in the navigation history (default: the index of the last entry in the navigation history).
*
* @returns The [`MemoryNavigator`](https://layrjs.com/docs/v2/reference/memory-navigator) instance that was created.
*
* @category Creation
*/
constructor(options: MemoryNavigatorOptions = {}) {
const {initialURLs = [], initialIndex = initialURLs.length - 1, ...otherOptions} = options;
super(otherOptions);
this._urls = initialURLs.map(normalizeURL);
this._index = initialIndex;
}
// === Current Location ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#current-location) class.
*
* @category Current Location
*/
_getCurrentURL() {
if (this._index === -1) {
throw new Error('The navigator has no current URL');
}
return this._urls[this._index];
}
// === Navigation ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#navigation) class.
*
* @category Navigation
*/
_navigate(url: URL) {
this._urls.splice(this._index + 1);
this._urls.push(url);
this._index++;
}
_redirect(url: URL) {
if (this._index === -1) {
throw new Error('The navigator has no current URL');
}
this._urls.splice(this._index);
this._urls.push(url);
}
_reload(_url: URL | undefined): void {
throw new Error(`The method 'reload() is not available in a memory navigator`);
}
_go(delta: number) {
let index = this._index;
index += delta;
if (index < 0 || index > this._urls.length - 1) {
throw new Error('Cannot go to an entry that does not exist in the navigator history');
}
this._index = index;
}
_getHistoryLength() {
return this._urls.length;
}
_getHistoryIndex() {
return this._index;
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
}
================================================
FILE: packages/memory-navigator/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/memory-store/README.md
================================================
# @layr/memory-store
A Layr store using memory (useful for testing).
## Installation
```
npm install @layr/memory-store
```
## License
MIT
================================================
FILE: packages/memory-store/package.json
================================================
{
"name": "@layr/memory-store",
"version": "2.0.82",
"description": "A Layr store using memory (useful for testing)",
"keywords": [
"layr",
"store",
"memory"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/memory-store",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/storable": "^2.0.76",
"@layr/store": "^2.0.81",
"core-helpers": "^1.0.8",
"lodash": "^4.17.21",
"sort-on": "^4.1.1",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/lodash": "^4.14.191"
}
}
================================================
FILE: packages/memory-store/src/index.ts
================================================
export * from './memory-store';
================================================
FILE: packages/memory-store/src/memory-store.ts
================================================
import {
Store,
CreateDocumentParams,
ReadDocumentParams,
UpdateDocumentParams,
DeleteDocumentParams,
FindDocumentsParams,
CountDocumentsParams,
MigrateCollectionParams,
MigrateCollectionResult,
Document,
Expression,
Path
} from '@layr/store';
import type {Operator, SortDescriptor} from '@layr/storable';
import type {NormalizedIdentifierDescriptor} from '@layr/component';
import pull from 'lodash/pull';
import get from 'lodash/get';
import set from 'lodash/set';
import unset from 'lodash/unset';
import sortOn from 'sort-on';
type Collection = Document[];
type CollectionMap = {[name: string]: Collection};
/**
* *Inherits from [`Store`](https://layrjs.com/docs/v2/reference/store).*
*
* A [`Store`](https://layrjs.com/docs/v2/reference/store) that uses the memory to "persist" its registered [storable components](https://layrjs.com/docs/v2/reference/storable#storable-component-class). Since the stored data is wiped off every time the execution environment is restarted, a `MemoryStore` shouldn't be used for a real app.
*
* #### Usage
*
* Create a `MemoryStore` instance, register some [storable components](https://layrjs.com/docs/v2/reference/storable#storable-component-class) into it, and then use any [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class)'s method to load, save, delete, or find components from the store.
*
* See an example of use in the [`MongoDBStore`](https://layrjs.com/docs/v2/reference/mongodb-store) class.
*/
export class MemoryStore extends Store {
/**
* Creates a [`MemoryStore`](https://layrjs.com/docs/v2/reference/memory-store).
*
* @param [options.initialCollections] A plain object specifying the initial data that should be populated into the store. The shape of the objet should be `{[collectionName]: documents}` where `collectionName` is the name of a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) class, and `documents` is an array of serialized storable component instances.
*
* @returns The [`MemoryStore`](https://layrjs.com/docs/v2/reference/memory-store) instance that was created.
*
* @example
* ```
* // Create an empty memory store
* const store = new MemoryStore();
*
* // Create a memory store with some initial data
* const store = new MemoryStore({
* User: [
* {
* __component: 'User',
* id: 'xyz789',
* email: 'user@domain.com'
* }
* ],
* Movie: [
* {
* __component: 'Movie',
* id: 'abc123',
* title: 'Inception'
* }
* ]
* });
* ```
*
* @category Creation
*/
constructor(options: {initialCollections?: CollectionMap} = {}) {
const {initialCollections = {}, ...otherOptions} = options;
super(otherOptions);
this._collections = initialCollections;
}
// === Component Registration ===
/**
* See the methods that are inherited from the [`Store`](https://layrjs.com/docs/v2/reference/store#component-registration) class.
*
* @category Component Registration
*/
// === Collections ===
_collections: CollectionMap;
_getCollection(name: string) {
let collection = this._collections[name];
if (collection === undefined) {
collection = [];
this._collections[name] = collection;
}
return collection;
}
// === Documents ===
async createDocument({collectionName, identifierDescriptor, document}: CreateDocumentParams) {
const collection = this._getCollection(collectionName);
const existingDocument = await this._readDocument({collection, identifierDescriptor});
if (existingDocument !== undefined) {
return false;
}
collection.push(document);
return true;
}
async readDocument({
collectionName,
identifierDescriptor
}: ReadDocumentParams): Promise {
const collection = this._getCollection(collectionName);
const document = await this._readDocument({collection, identifierDescriptor});
return document;
}
async _readDocument({
collection,
identifierDescriptor
}: {
collection: Collection;
identifierDescriptor: NormalizedIdentifierDescriptor;
}): Promise {
const [[identifierName, identifierValue]] = Object.entries(identifierDescriptor);
const document = collection.find((document) => document[identifierName] === identifierValue);
return document;
}
async updateDocument({
collectionName,
identifierDescriptor,
documentPatch
}: UpdateDocumentParams) {
const collection = this._getCollection(collectionName);
const existingDocument = await this._readDocument({collection, identifierDescriptor});
if (existingDocument === undefined) {
return false;
}
const {$set, $unset} = documentPatch;
if ($set !== undefined) {
for (const [path, value] of Object.entries($set)) {
set(existingDocument, path, value);
}
}
if ($unset !== undefined) {
for (const [path, value] of Object.entries($unset)) {
if (value) {
unset(existingDocument, path);
}
}
}
return true;
}
async deleteDocument({collectionName, identifierDescriptor}: DeleteDocumentParams) {
const collection = this._getCollection(collectionName);
const document = await this._readDocument({collection, identifierDescriptor});
if (document === undefined) {
return false;
}
pull(collection, document);
return true;
}
async findDocuments({
collectionName,
expressions,
sort,
skip,
limit
}: FindDocumentsParams): Promise {
const collection = this._getCollection(collectionName);
const documents = await this._findDocuments({collection, expressions, sort, skip, limit});
return documents;
}
async _findDocuments({
collection,
expressions,
sort,
skip,
limit
}: {
collection: Collection;
expressions: Expression[];
sort?: SortDescriptor;
skip?: number;
limit?: number;
}): Promise {
let documents = filterDocuments(collection, expressions);
documents = sortDocuments(documents, sort);
documents = skipDocuments(documents, skip);
documents = limitDocuments(documents, limit);
return documents;
}
async countDocuments({collectionName, expressions}: CountDocumentsParams) {
const collection = this._getCollection(collectionName);
const documents = await this._findDocuments({collection, expressions});
return documents.length;
}
// === Migration ===
async migrateCollection({collectionName}: MigrateCollectionParams) {
const result: MigrateCollectionResult = {
name: collectionName,
createdIndexes: [],
droppedIndexes: []
};
return result;
}
}
function filterDocuments(documents: Document[], expressions: Expression[]) {
if (expressions.length === 0) {
return documents; // Optimization
}
return documents.filter((document) => documentIsMatchingExpressions(document, expressions));
}
function documentIsMatchingExpressions(document: Document, expressions: Expression[]) {
for (const [path, operator, operand] of expressions) {
const attributeValue = path !== '' ? get(document, path) : document;
if (evaluateExpression(attributeValue, operator, operand, {path}) === false) {
return false;
}
}
return true;
}
function evaluateExpression(
attributeValue: any,
operator: Operator,
operand: any,
{path}: {path: Path}
) {
// --- Basic operators ---
if (operator === '$equal') {
return attributeValue?.valueOf() === operand?.valueOf();
}
if (operator === '$notEqual') {
return attributeValue?.valueOf() !== operand?.valueOf();
}
if (operator === '$greaterThan') {
return attributeValue > operand;
}
if (operator === '$greaterThanOrEqual') {
return attributeValue >= operand;
}
if (operator === '$lessThan') {
return attributeValue < operand;
}
if (operator === '$lessThanOrEqual') {
return attributeValue <= operand;
}
if (operator === '$in') {
return operand.includes(attributeValue);
}
// --- String operators ---
if (operator === '$includes') {
if (typeof attributeValue !== 'string') {
return false;
}
return attributeValue.includes(operand);
}
if (operator === '$startsWith') {
if (typeof attributeValue !== 'string') {
return false;
}
return attributeValue.startsWith(operand);
}
if (operator === '$endsWith') {
if (typeof attributeValue !== 'string') {
return false;
}
return attributeValue.endsWith(operand);
}
if (operator === '$matches') {
if (typeof attributeValue !== 'string') {
return false;
}
return operand.test(attributeValue);
}
// --- Array operators ---
if (operator === '$some') {
if (!Array.isArray(attributeValue)) {
return false;
}
const subdocuments = attributeValue;
const subexpressions = operand;
return subdocuments.some((subdocument) =>
documentIsMatchingExpressions(subdocument, subexpressions)
);
}
if (operator === '$every') {
if (!Array.isArray(attributeValue)) {
return false;
}
const subdocuments = attributeValue;
const subexpressions = operand;
return subdocuments.every((subdocument) =>
documentIsMatchingExpressions(subdocument, subexpressions)
);
}
if (operator === '$length') {
if (!Array.isArray(attributeValue)) {
return false;
}
return attributeValue.length === operand;
}
// --- Logical operators ---
if (operator === '$not') {
const subexpressions = operand;
return !documentIsMatchingExpressions(attributeValue, subexpressions);
}
if (operator === '$and') {
const andSubexpressions = operand as any[];
return andSubexpressions.every((subexpressions) =>
documentIsMatchingExpressions(attributeValue, subexpressions)
);
}
if (operator === '$or') {
const orSubexpressions = operand as any[];
return orSubexpressions.some((subexpressions) =>
documentIsMatchingExpressions(attributeValue, subexpressions)
);
}
if (operator === '$nor') {
const norSubexpressions = operand as any[];
return !norSubexpressions.some((subexpressions) =>
documentIsMatchingExpressions(attributeValue, subexpressions)
);
}
throw new Error(
`A query contains an operator that is not supported (operator: '${operator}', path: '${path}')`
);
}
function sortDocuments(documents: Document[], sort: SortDescriptor | undefined) {
if (sort === undefined) {
return documents;
}
const properties = Object.entries(sort).map(([name, direction]) => {
let property = name;
if (direction.toLowerCase() === 'desc') {
property = `-${property}`;
}
return property;
});
return sortOn(documents, properties);
}
function skipDocuments(documents: Document[], skip: number | undefined) {
if (skip === undefined) {
return documents;
}
return documents.slice(skip);
}
function limitDocuments(documents: Document[], limit: number | undefined) {
if (limit === undefined) {
return documents;
}
return documents.slice(0, limit);
}
================================================
FILE: packages/memory-store/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/mongodb-store/README.md
================================================
# @layr/mongodb-store
A Layr store for MongoDB.
## Installation
```
npm install @layr/mongodb-store
```
## License
MIT
================================================
FILE: packages/mongodb-store/package.json
================================================
{
"name": "@layr/mongodb-store",
"version": "2.0.82",
"description": "A Layr store for MongoDB",
"keywords": [
"layr",
"store",
"persistence",
"storage",
"mongodb"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/mongodb-store",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/storable": "^2.0.76",
"@layr/store": "^2.0.81",
"core-helpers": "^1.0.8",
"debug": "^4.3.4",
"lodash": "^4.17.21",
"microbatcher": "^2.0.8",
"mongodb": "^4.13.0",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/debug": "^4.1.7",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191",
"mongodb-memory-server": "^8.11.0"
}
}
================================================
FILE: packages/mongodb-store/src/index.ts
================================================
export * from './mongodb-store';
================================================
FILE: packages/mongodb-store/src/mongodb-store.test.ts
================================================
import {Component, provide} from '@layr/component';
import {
Storable,
StorableComponent,
primaryIdentifier,
secondaryIdentifier,
attribute
} from '@layr/storable';
import {MongoMemoryServer} from 'mongodb-memory-server';
import {MongoDBStore} from './mongodb-store';
describe('MongoDBStore', () => {
describe('Migration', () => {
let movieClass: typeof StorableComponent;
let server: MongoMemoryServer;
let store: MongoDBStore;
beforeEach(async () => {
class Person extends Storable(Component) {
@primaryIdentifier() id!: string;
@attribute('string') fullName!: string;
}
class Movie extends Storable(Component) {
@provide() static Person = Person;
@primaryIdentifier() id!: string;
@secondaryIdentifier() slug!: string;
@attribute('string') title!: string;
@attribute('number') year!: number;
@attribute('Person') director!: Person;
}
movieClass = Movie;
server = await MongoMemoryServer.create();
const connectionString = server.getUri();
store = new MongoDBStore(connectionString);
store.registerRootComponent(Movie);
await store.connect();
});
afterEach(async () => {
await store?.disconnect();
await server?.stop();
});
test('migrateStorables()', async () => {
let result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: ['slug [unique]', 'director.id'], droppedIndexes: []},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: [], droppedIndexes: []},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.deleteProperty('slug');
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: [], droppedIndexes: ['slug [unique]']},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.setIndex({title: 'asc'});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: ['title'], droppedIndexes: []},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.setIndex({title: 'asc'}, {isUnique: true});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: ['title [unique]'], droppedIndexes: ['title']},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.deleteIndex({title: 'asc'});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: [], droppedIndexes: ['title [unique]']},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.setIndex({year: 'desc', title: 'asc'}, {isUnique: true});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: ['year (desc) + title [unique]'], droppedIndexes: []},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
movieClass.prototype.setIndex({year: 'asc', id: 'asc'});
result = await store.migrateStorables({silent: true});
expect(result).toStrictEqual({
collections: [
{name: 'Movie', createdIndexes: ['year + _id'], droppedIndexes: []},
{name: 'Person', createdIndexes: [], droppedIndexes: []}
]
});
});
});
describe('Document operations', () => {
let server: MongoMemoryServer;
let store: MongoDBStore;
beforeEach(async () => {
server = await MongoMemoryServer.create({instance: {storageEngine: 'wiredTiger'}});
const connectionString = server.getUri();
store = new MongoDBStore(connectionString);
await store.connect();
await store.migrateCollection({
collectionName: 'Movie',
collectionSchema: {
indexes: [
{attributes: {_id: 'asc'}, isPrimary: true, isUnique: true},
{attributes: {slug: 'asc'}, isPrimary: false, isUnique: true},
{attributes: {title: 'asc'}, isPrimary: false, isUnique: false},
{attributes: {year: 'desc'}, isPrimary: false, isUnique: false},
{attributes: {year: 'desc', title: 'asc'}, isPrimary: false, isUnique: true},
{attributes: {tags: 'asc'}, isPrimary: false, isUnique: false}
]
},
silent: true
});
});
afterEach(async () => {
await store?.disconnect();
await server?.stop();
});
test('createDocument()', async () => {
expect(
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie1',
slug: 'inception',
title: 'Inception',
year: 2010,
tags: ['action', 'drama']
}
})
).toBe(true);
expect(
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie1',
slug: 'inception-2',
title: 'Inception 2',
year: 2010,
tags: ['action', 'drama']
}
})
).toBe(false);
await expect(
store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie2',
slug: 'inception',
title: 'Inception',
year: 2010,
tags: ['action', 'drama']
}
})
).rejects.toThrow(
"A duplicate key error occurred while creating a MongoDB document (collection: 'Movie', index: 'slug [unique]')"
);
await expect(
store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie2',
slug: 'inception-2',
title: 'Inception',
year: 2010,
tags: ['action', 'drama']
}
})
).rejects.toThrow(
"A duplicate key error occurred while creating a MongoDB document (collection: 'Movie', index: 'year (desc) + title [unique]')"
);
});
test('updateDocument()', async () => {
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie1',
slug: 'inception',
title: 'Inception',
year: 2010,
tags: ['action', 'drama']
}
});
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie2'},
document: {
__component: 'Movie',
_id: 'movie2',
slug: 'inception-2',
title: 'Inception 2',
year: 2020,
tags: ['action', 'drama']
}
});
expect(
await store.updateDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie2'},
documentPatch: {$set: {year: 2021}}
})
).toBe(true);
expect(
await store.updateDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie3'},
documentPatch: {$set: {year: 2021}}
})
).toBe(false);
await expect(
store.updateDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie2'},
documentPatch: {$set: {slug: 'inception'}}
})
).rejects.toThrow(
"A duplicate key error occurred while updating a MongoDB document (collection: 'Movie', index: 'slug [unique]')"
);
await expect(
store.updateDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie2'},
documentPatch: {$set: {title: 'Inception', year: 2010}}
})
).rejects.toThrow(
"A duplicate key error occurred while updating a MongoDB document (collection: 'Movie', index: 'year (desc) + title [unique]')"
);
});
test('findDocuments()', async () => {
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie1'},
document: {
__component: 'Movie',
_id: 'movie1',
slug: 'inception',
title: 'Inception',
year: 2010,
tags: ['action', 'adventure', 'sci-fi']
}
});
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie2'},
document: {
__component: 'Movie',
_id: 'movie2',
slug: 'forrest-gump',
title: 'Forrest Gump',
year: 1994,
tags: ['drama', 'romance']
}
});
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie3'},
document: {
__component: 'Movie',
_id: 'movie3',
slug: 'leon',
title: 'Léon',
year: 1994,
tags: ['action', 'crime', 'drama']
}
});
await store.createDocument({
collectionName: 'Movie',
identifierDescriptor: {_id: 'movie4'},
document: {
__component: 'Movie',
_id: 'movie4',
slug: 'unknown',
title: 'Unknown',
year: 0,
tags: []
}
});
expect(
await store.findDocuments({
collectionName: 'Movie',
expressions: [],
projection: {_id: 1},
sort: {_id: 'asc'}
})
).toStrictEqual([{_id: 'movie1'}, {_id: 'movie2'}, {_id: 'movie3'}, {_id: 'movie4'}]);
// --- $in ---
expect(
await store.findDocuments({
collectionName: 'Movie',
// @ts-ignore
expressions: [['tags', '$in', ['romance']]],
projection: {_id: 1},
sort: {_id: 'asc'}
})
).toStrictEqual([{_id: 'movie2'}]);
expect(
await store.findDocuments({
collectionName: 'Movie',
// @ts-ignore
expressions: [['tags', '$in', ['action']]],
projection: {_id: 1},
sort: {_id: 'asc'}
})
).toStrictEqual([{_id: 'movie1'}, {_id: 'movie3'}]);
// --- $not $in ---
expect(
await store.findDocuments({
collectionName: 'Movie',
// @ts-ignore
expressions: [['tags', '$not', [['', '$in', ['action']]]]],
projection: {_id: 1},
sort: {_id: 'asc'}
})
).toStrictEqual([{_id: 'movie2'}, {_id: 'movie4'}]);
expect(
await store.findDocuments({
collectionName: 'Movie',
// @ts-ignore
expressions: [['tags', '$not', [['', '$in', ['action', 'drama']]]]],
projection: {_id: 1},
sort: {_id: 'asc'}
})
).toStrictEqual([{_id: 'movie4'}]);
});
});
});
================================================
FILE: packages/mongodb-store/src/mongodb-store.ts
================================================
import {
Store,
CreateDocumentParams,
ReadDocumentParams,
UpdateDocumentParams,
DeleteDocumentParams,
FindDocumentsParams,
CountDocumentsParams,
MigrateCollectionParams,
MigrateCollectionResult,
Document,
Expression,
Path,
Operand
} from '@layr/store';
import type {
StorableComponent,
Query,
SortDescriptor,
SortDirection,
Operator
} from '@layr/storable';
import {ensureComponentInstance} from '@layr/component';
import {MongoClient, Db, Collection, Filter, FindOptions} from 'mongodb';
import {Microbatcher, Operation} from 'microbatcher';
import {hasOwnProperty, assertIsObjectLike} from 'core-helpers';
import isEmpty from 'lodash/isEmpty';
import mapKeys from 'lodash/mapKeys';
import mapValues from 'lodash/mapValues';
import escapeRegExp from 'lodash/escapeRegExp';
import groupBy from 'lodash/groupBy';
import debugModule from 'debug';
const debug = debugModule('layr:mongodb-store');
// To display the debug log, set this environment:
// DEBUG=layr:mongodb-store DEBUG_DEPTH=10
const MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME = '_id';
const MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_INDEX_NAME = '_id_';
/**
* *Inherits from [`Store`](https://layrjs.com/docs/v2/reference/store).*
*
* A [`Store`](https://layrjs.com/docs/v2/reference/store) that uses a [MongoDB](https://www.mongodb.com/) database to persist its registered [storable components](https://layrjs.com/docs/v2/reference/storable#storable-component-class).
*
* #### Usage
*
* Create a `MongoDBStore` instance, register some [storable components](https://layrjs.com/docs/v2/reference/storable#storable-component-class) into it, and then use any [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class)'s method to load, save, delete, or find components from the store.
*
* For example, let's build a simple `Backend` that provides a `Movie` component.
*
* First, let's define the components that we are going to use:
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '@layr/storable';
*
* class Movie extends Storable(Component) {
* @primaryIdentifier() id;
*
* @attribute() title = '';
* }
*
* class Backend extends Component {
* ﹫provide() static Movie = Movie;
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '@layr/storable';
*
* class Movie extends Storable(Component) {
* @primaryIdentifier() id!: string;
*
* @attribute() title = '';
* }
*
* class Backend extends Component {
* ﹫provide() static Movie = Movie;
* }
* ```
*
* Next, let's create a `MongoDBStore` instance, and let's register the `Backend` component as the root component of the store:
*
* ```
* import {MongoDBStore} from '﹫layr/mongodb-store';
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerRootComponent(Backend);
* ```
*
* Finally, we can interact with the store by calling some [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) methods:
*
* ```
* let movie = new Movie({id: 'abc123', title: 'Inception'});
*
* // Save the movie to the store
* await movie.save();
*
* // Get the movie from the store
* movie = await Movie.get('abc123');
* movie.title; // => 'Inception'
*
* // Modify the movie, and save it to the store
* movie.title = 'Inception 2';
* await movie.save();
*
* // Find the movies that have a title starting with 'Inception'
* const movies = await Movie.find({title: {$startsWith: 'Inception'}});
* movies.length; // => 1 (one movie found)
* movies[0].title; // => 'Inception 2'
* movies[0] === movie; // => true (thanks to the identity mapping)
*
* // Delete the movie from the store
* await movie.delete();
* ```
*/
export class MongoDBStore extends Store {
private _connectionString: string;
private _poolSize: number;
/**
* Creates a [`MongoDBStore`](https://layrjs.com/docs/v2/reference/mongodb-store).
*
* @param connectionString The [connection string](https://docs.mongodb.com/manual/reference/connection-string/) of the MongoDB database to use.
* @param [options.poolSize] A number specifying the maximum size of the connection pool (default: `1`).
*
* @returns The [`MongoDBStore`](https://layrjs.com/docs/v2/reference/mongodb-store) instance that was created.
*
* @example
* ```
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
* ```
*
* @category Creation
*/
constructor(connectionString: string, options: {poolSize?: number} = {}) {
if (typeof connectionString !== 'string') {
throw new Error(
`Expected a 'connectionString' to create a MongoDBStore, but received a value of type '${typeof connectionString}'`
);
}
if (connectionString.length === 0) {
throw new Error(
`Expected a 'connectionString' to create a MongoDBStore, but received an empty string`
);
}
const {poolSize = 1, ...otherOptions} = options;
super(otherOptions);
this._connectionString = connectionString;
this._poolSize = poolSize;
}
getURL() {
return this._connectionString;
}
// === Component Registration ===
/**
* See the methods that are inherited from the [`Store`](https://layrjs.com/docs/v2/reference/store#component-registration) class.
*
* @category Component Registration
*/
// === Connection ===
/**
* Initiates a connection to the MongoDB database.
*
* Since this method is called automatically when you interact with the store through any of the [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) methods, you shouldn't have to call it manually.
*
* @category Connection to MongoDB
*/
async connect() {
await this._connectClient();
}
/**
* Closes the connection to the MongoDB database. Unless you are building a tool that uses a store for an ephemeral duration, you shouldn't have to call this method.
*
* @category Connection to MongoDB
*/
async disconnect() {
await this._disconnectClient();
}
// === Documents ===
async createDocument({collectionName, document}: CreateDocumentParams) {
const collection = await this._getCollection(collectionName);
try {
const {acknowledged} = await debugCall(
async () => {
const {acknowledged} = await collection.insertOne(document);
return {acknowledged};
},
'db.%s.insertOne(%o)',
collectionName,
document
);
return acknowledged;
} catch (error: any) {
if (error.name === 'MongoServerError' && error.code === 11000) {
const matches = error.message.match(/ index: (.*) dup key/);
if (matches === null) {
throw error;
}
const indexName = matches[1];
if (indexName === MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_INDEX_NAME) {
return false; // The document already exists
}
throw Object.assign(
new Error(
`A duplicate key error occurred while creating a MongoDB document (collection: '${collectionName}', index: '${indexName}')`
),
{code: 'DUPLICATE_KEY_ERROR', collectionName, indexName}
);
}
throw error;
}
}
async readDocument({
collectionName,
identifierDescriptor,
projection
}: ReadDocumentParams): Promise {
const collection = await this._getCollection(collectionName);
const query = identifierDescriptor;
const options = {projection};
const document: Document | null = await debugCall(
async () => await batchableFindOne(collection, query, options),
'db.%s.batchableFindOne(%o, %o)',
collectionName,
query,
options
);
if (document === null) {
return undefined;
}
return document;
}
async updateDocument({
collectionName,
identifierDescriptor,
documentPatch
}: UpdateDocumentParams) {
const collection = await this._getCollection(collectionName);
const filter = identifierDescriptor;
const {matchedCount} = await debugCall(
async () => {
try {
const {matchedCount, modifiedCount} = await collection.updateOne(filter, documentPatch);
return {matchedCount, modifiedCount};
} catch (error: any) {
if (error.name === 'MongoServerError' && error.code === 11000) {
const matches = error.message.match(/ index: (.*) dup key/);
if (matches === null) {
throw error;
}
const indexName = matches[1];
throw Object.assign(
new Error(
`A duplicate key error occurred while updating a MongoDB document (collection: '${collectionName}', index: '${indexName}')`
),
{code: 'DUPLICATE_KEY_ERROR', collectionName, indexName}
);
}
throw error;
}
},
'db.%s.updateOne(%o, %o)',
collectionName,
filter,
documentPatch
);
return matchedCount === 1;
}
async deleteDocument({collectionName, identifierDescriptor}: DeleteDocumentParams) {
const collection = await this._getCollection(collectionName);
const filter = identifierDescriptor;
const {deletedCount} = await debugCall(
async () => {
const {deletedCount} = await collection.deleteOne(filter);
return {deletedCount};
},
'db.%s.deleteOne(%o)',
collectionName,
filter
);
return deletedCount === 1;
}
async findDocuments({
collectionName,
expressions,
projection,
sort,
skip,
limit
}: FindDocumentsParams): Promise {
const collection = await this._getCollection(collectionName);
const mongoQuery = buildMongoQuery(expressions);
const mongoSort = buildMongoSort(sort);
const options = {projection};
const documents: Document[] = await debugCall(
async () => {
const cursor = collection.find(mongoQuery, options);
if (mongoSort !== undefined) {
cursor.sort(mongoSort);
}
if (skip !== undefined) {
cursor.skip(skip);
}
if (limit !== undefined) {
cursor.limit(limit);
}
const documents = await cursor.toArray();
return documents;
},
'db.%s.find(%o, %o)',
collectionName,
mongoQuery,
options
);
return documents;
}
async countDocuments({collectionName, expressions}: CountDocumentsParams) {
const collection = await this._getCollection(collectionName);
const query = buildMongoQuery(expressions);
const documentsCount = await debugCall(
async () => {
const documentsCount = await collection.countDocuments(query);
return documentsCount;
},
'db.%s.countDocuments(%o)',
collectionName,
query
);
return documentsCount;
}
// === Serialization ===
toDocument(storable: typeof StorableComponent | StorableComponent, value: Value) {
let document = super.toDocument(storable, value);
if (typeof document === 'object') {
const primaryIdentifierAttributeName = ensureComponentInstance(storable)
.getPrimaryIdentifierAttribute()
.getName();
document = mapKeys(document as any, (_, name) =>
name === primaryIdentifierAttributeName ? MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME : name
) as Value;
}
return document;
}
fromDocument(
storable: typeof StorableComponent | StorableComponent,
document: Document
): Document {
let serializedStorable = super.fromDocument(storable, document);
if (typeof serializedStorable === 'object') {
const primaryIdentifierAttributeName = ensureComponentInstance(storable)
.getPrimaryIdentifierAttribute()
.getName();
serializedStorable = mapKeys(serializedStorable, (_, name) =>
name === MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME ? primaryIdentifierAttributeName : name
);
}
return serializedStorable;
}
// === Migration ===
/**
* See the methods that are inherited from the [`Store`](https://layrjs.com/docs/v2/reference/store#migration) class.
*
* @category Migration
*/
async migrateCollection({
collectionName,
collectionSchema,
silent = false
}: MigrateCollectionParams) {
const result: MigrateCollectionResult = {
name: collectionName,
createdIndexes: [],
droppedIndexes: []
};
const database = await this._getDatabase();
let collection: Collection;
let collectionHasBeenCreated: boolean;
const collections = await database
.listCollections({name: collectionName}, {nameOnly: true})
.toArray();
if (collections.length === 0) {
if (!silent) {
console.log(`Creating collection: '${collectionName}'`);
}
collection = await database.createCollection(collectionName);
collectionHasBeenCreated = true;
} else {
collection = database.collection(collectionName);
collectionHasBeenCreated = false;
}
const existingIndexNames: string[] = (await collection.indexes()).map(
(index: any) => index.name
);
const indexesToEnsure: any[] = [];
for (const index of collectionSchema.indexes) {
let indexName = '';
let indexSpec: any = {};
for (let [name, direction] of Object.entries(index.attributes)) {
const directionString = direction.toLowerCase();
if (indexName !== '') {
indexName += ' + ';
}
indexName += name;
if (directionString === 'desc') {
indexName += ' (desc)';
}
indexSpec[name] = directionString === 'desc' ? -1 : 1;
}
if (index.isUnique) {
indexName += ' [unique]';
}
if (indexName === `${MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME} [unique]`) {
indexName = MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_INDEX_NAME;
}
indexesToEnsure.push({name: indexName, spec: indexSpec, isUnique: index.isUnique});
}
const indexesToCreate = indexesToEnsure.filter(
(index) => !existingIndexNames.includes(index.name)
);
const indexNamesToDrop = existingIndexNames.filter(
(name) =>
name !== MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_INDEX_NAME &&
!indexesToEnsure.some((index) => index.name === name)
);
if (indexesToCreate.length !== 0 || indexNamesToDrop.length !== 0) {
if (!collectionHasBeenCreated && !silent) {
console.log(`Migrating collection: '${collectionName}'`);
}
}
for (const name of indexNamesToDrop) {
if (!silent) {
console.log(`- Dropping index: '${name}'`);
}
await collection.dropIndex(name);
result.droppedIndexes.push(name);
}
for (const index of indexesToCreate) {
if (!silent) {
console.log(`- Creating index: '${index.name}'`);
}
await collection.createIndex(index.spec, {name: index.name, unique: index.isUnique});
result.createdIndexes.push(index.name);
}
return result;
}
// === MongoDB client ===
private _client: MongoClient | undefined;
private async _getClient() {
await this._connectClient();
return this._client!;
}
private _connectClientPromise: Promise | undefined;
private _connectClient() {
// This method memoize the ongoing promise to allow concurrent execution
if (this._connectClientPromise !== undefined) {
return this._connectClientPromise;
}
this._connectClientPromise = (async () => {
try {
if (this._client === undefined) {
debug(`Connecting to MongoDB Server (connectionString: ${this._connectionString})...`);
this._client = await MongoClient.connect(
this._fixConnectionString(this._connectionString),
{
maxPoolSize: this._poolSize
}
);
debug(`Connected to MongoDB Server (connectionString: ${this._connectionString})`);
}
} finally {
this._connectClientPromise = undefined;
}
})();
return this._connectClientPromise;
}
private _fixConnectionString(connectionString: string) {
// Fix an issue when localhost resolves to an IPv6 loopback address (::1)
//
// It happens in the following environment:
// - macOS v13.0.1
// - Node.js v18.12.1
const connectionStringURL = new URL(connectionString);
if (connectionStringURL.hostname !== 'localhost') {
return connectionString;
}
connectionStringURL.hostname = '127.0.0.1';
return connectionStringURL.toString();
}
private async _disconnectClient() {
if (this._connectClientPromise !== undefined) {
// If the connection is ongoing, let's wait it finishes before disconnecting
try {
await this._connectClientPromise;
} catch {
// NOOP
}
}
const client = this._client;
if (client !== undefined) {
// Unset `this._client` and `this._db` early to avoid issue in case of concurrent execution
this._client = undefined;
this._db = undefined;
debug(`Disconnecting from MongoDB Server (connectionString: ${this._connectionString})...`);
await client.close();
debug(`Disconnected from MongoDB Server (connectionString: ${this._connectionString})`);
}
}
private _db: Db | undefined;
private async _getDatabase() {
if (!this._db) {
const client = await this._getClient();
this._db = client.db();
}
return this._db;
}
private _collections: {[name: string]: Collection} | undefined;
private async _getCollection(name: string) {
if (this._collections === undefined) {
this._collections = Object.create(null);
}
if (this._collections![name] === undefined) {
const database = await this._getDatabase();
this._collections![name] = database.collection(name);
}
return this._collections![name];
}
}
function buildMongoQuery(expressions: Expression[]) {
const query: Query = {};
for (const [path, operator, value] of expressions) {
let subquery: Query;
if (path !== '') {
subquery = query[path];
if (subquery === undefined) {
subquery = {};
query[path] = subquery;
}
} else {
subquery = query;
}
const [actualOperator, actualValue] = handleOperator(operator, value, {path});
subquery[actualOperator] = actualValue;
}
return query;
}
function handleOperator(
operator: Operator,
value: Operand,
{path}: {path: Path}
): [Operator, unknown] {
// --- Basic operators ---
if (operator === '$equal') {
return ['$eq', value];
}
if (operator === '$notEqual') {
return ['$ne', value];
}
if (operator === '$greaterThan') {
return ['$gt', value];
}
if (operator === '$greaterThanOrEqual') {
return ['$gte', value];
}
if (operator === '$lessThan') {
return ['$lt', value];
}
if (operator === '$lessThanOrEqual') {
return ['$lte', value];
}
if (operator === '$in') {
return ['$in', value];
}
// --- String operators ---
if (operator === '$includes') {
return ['$regex', escapeRegExp(value as string)];
}
if (operator === '$startsWith') {
return ['$regex', `^${escapeRegExp(value as string)}`];
}
if (operator === '$endsWith') {
return ['$regex', `${escapeRegExp(value as string)}$`];
}
if (operator === '$matches') {
return ['$regex', value];
}
// --- Array operators ---
if (operator === '$some') {
const subexpressions = value as Expression[];
const subquery = buildMongoQuery(subexpressions);
return ['$elemMatch', subquery];
}
if (operator === '$every') {
// TODO: Make it works for complex queries (regexps, array of objects, etc.)
const subexpressions = value as Expression[];
const subquery = buildMongoQuery(subexpressions);
return ['$not', {$elemMatch: {$not: subquery}}];
}
if (operator === '$length') {
return ['$size', value];
}
// --- Logical operators ---
if (operator === '$not') {
const subexpressions = value as Expression[];
const subquery = buildMongoQuery(subexpressions);
return ['$not', subquery];
}
if (operator === '$and') {
const andSubexpressions = value as Expression[][];
const andSubqueries = andSubexpressions.map((subexpressions) =>
buildMongoQuery(subexpressions)
);
return ['$and', andSubqueries];
}
if (operator === '$or') {
const orSubexpressions = value as Expression[][];
const orSubqueries = orSubexpressions.map((subexpressions) => buildMongoQuery(subexpressions));
return ['$or', orSubqueries];
}
if (operator === '$nor') {
const norSubexpressions = value as Expression[][];
const norSubqueries = norSubexpressions.map((subexpressions) =>
buildMongoQuery(subexpressions)
);
return ['$nor', norSubqueries];
}
throw new Error(
`A query contains an operator that is not supported (operator: '${operator}', path: '${path}')`
);
}
function buildMongoSort(sort: SortDescriptor | undefined) {
if (sort === undefined || isEmpty(sort)) {
return undefined;
}
return mapValues(sort, (direction: SortDirection) =>
direction.toLowerCase() === 'desc' ? -1 : 1
);
}
async function debugCall(
func: () => Promise,
message: string,
...params: unknown[]
): Promise {
let result;
let error;
try {
result = await func();
} catch (err) {
error = err;
}
if (error !== undefined) {
debug(`${message} => Error`, ...params);
throw error;
}
debug(`${message} => %o`, ...params, result);
return result as Result;
}
const findOneBatcher = Symbol('batcher');
interface FindOneOperation extends Operation {
params: [filter: Filter, options: FindOptions];
}
function batchableFindOne(
collection: Collection & {[findOneBatcher]?: Microbatcher},
query: Filter,
options: FindOptions
) {
assertIsObjectLike(query);
if (collection[findOneBatcher] === undefined) {
collection[findOneBatcher] = new Microbatcher(function (operations) {
const operationGroups = groupBy(operations, ({params: [query, options]}) => {
if (
hasOwnProperty(query, MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME) &&
Object.keys(query).length === 1
) {
// 'query' has a single '_id' attribute
query = {[MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME]: '___???___'};
}
return JSON.stringify([query, options]);
});
for (const operations of Object.values(operationGroups)) {
if (operations.length > 1) {
// Multiple `findOne()` that can be transformed into a single `find()`
const ids = operations.map(
(operation) => operation.params[0][MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME]
);
const options = operations[0].params[1]; // All 'options' objects should be identical
collection
.find({[MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME]: {$in: ids}}, options)
.toArray()
.then(
(documents) => {
for (const {
params: [query],
resolve
} of operations) {
const document = documents.find(
(document) =>
document[MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME] ===
query[MONGODB_PRIMARY_IDENTIFIER_ATTRIBUTE_NAME]
);
resolve(document !== undefined ? document : null);
}
},
(error) => {
for (const {reject} of operations) {
reject(error);
}
}
);
} else {
// Single `findOne()`
const {
params: [query, options],
resolve,
reject
} = operations[0];
collection.findOne(query, options).then(resolve, reject);
}
}
});
}
return collection[findOneBatcher]!.batch(query, options);
}
================================================
FILE: packages/mongodb-store/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/navigator/README.md
================================================
# @layr/navigator
A base class for implementing Layr navigators.
## Installation
```
npm install @layr/navigator
```
## License
MIT
================================================
FILE: packages/navigator/package.json
================================================
{
"name": "@layr/navigator",
"version": "2.0.55",
"description": "A base class for implementing Layr navigators",
"keywords": [
"layr",
"navigator",
"navigation",
"base",
"class"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/navigator",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/observable": "^1.0.16",
"core-helpers": "^1.0.8",
"possibly-async": "^1.0.7",
"qs": "^6.11.0",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5",
"@types/qs": "^6.9.7"
}
}
================================================
FILE: packages/navigator/src/index.ts
================================================
export * from './navigator';
export * from './utilities';
================================================
FILE: packages/navigator/src/navigator.ts
================================================
import {Observable} from '@layr/observable';
import {assertNoUnknownOptions} from 'core-helpers';
import {possiblyAsync} from 'possibly-async';
import {isNavigatorInstance, normalizeURL, stringifyURL, parseQuery} from './utilities';
declare global {
interface Function {
matchURL: (url: URL | string) => {identifiers: any; params: any} | undefined;
generateURL: (params?: any, options?: URLOptions) => string;
generatePath: () => string;
generateQueryString: (params?: any) => string;
navigate: (params?: any, options?: URLOptions & NavigationOptions) => Promise | undefined;
redirect: (params?: any, options?: URLOptions & NavigationOptions) => Promise | undefined;
reload: (params?: any, options?: URLOptions) => void;
isActive: () => boolean;
Link: (props: {params?: any; hash?: string; [key: string]: any}) => any;
}
}
export type URLOptions = {hash?: string};
export type NavigationOptions = {silent?: boolean; defer?: boolean};
type NavigatorPlugin = (navigator: Navigator) => void;
type AddressableMethodWrapper = (receiver: any, method: Function, params: any) => any;
type CustomRouteDecorator = (method: Function) => void;
export type NavigatorOptions = {
plugins?: NavigatorPlugin[];
};
/**
* *Inherits from [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class).*
*
* An abstract class from which classes such as [`BrowserNavigator`](https://layrjs.com/docs/v2/reference/browser-navigator) or [`MemoryNavigator`](https://layrjs.com/docs/v2/reference/memory-navigator) are constructed. Unless you build a custom navigator, you probably won't have to use this class directly.
*/
export abstract class Navigator extends Observable(Object) {
constructor(options: NavigatorOptions = {}) {
super();
const {plugins, ...otherOptions} = options;
assertNoUnknownOptions(otherOptions);
if (plugins !== undefined) {
this.applyPlugins(plugins);
}
this.mount();
}
mount() {
// Override this method to implement custom mount logic
}
unmount() {
// Override this method to implement custom unmount logic
}
// === Current Location ===
/**
* Returns the current URL of the navigator.
*
* @returns A string.
*
* @example
* ```
* // See the definition of `navigator` in the `findRouteByURL()` example
*
* navigator.navigate('/movies/inception?showDetails=1#actors');
* navigator.getCurrentURL(); // => /movies/inception?showDetails=1#actors'
* ```
*
* @category Current Location
*/
getCurrentURL() {
return stringifyURL(this._getCurrentURL());
}
abstract _getCurrentURL(): URL;
/**
* Returns the path of the current URL.
*
* @returns A string.
*
* @example
* ```
* // See the definition of `navigator` in the `findRouteByURL()` example
*
* navigator.navigate('/movies/inception?showDetails=1#actors');
* navigator.getCurrentPath(); // => '/movies/inception'
* ```
*
* @category Current Location
*/
getCurrentPath() {
return this._getCurrentURL().pathname;
}
/**
* Returns an object representing the query of the current URL.
*
* The [`qs`](https://github.com/ljharb/qs) package is used under the hood to parse the query.
*
* @returns A plain object.
*
* @example
* ```
* // See the definition of `navigator` in the `findRouteByURL()` example
*
* navigator.navigate('/movies/inception?showDetails=1#actors');
* navigator.getCurrentQuery(); // => {showDetails: '1'}
* ```
*
* @category Current Location
*/
getCurrentQuery() {
return parseQuery(this._getCurrentURL().search);
}
/**
* Returns the hash (i.e., the [fragment identifier](https://en.wikipedia.org/wiki/URI_fragment)) contained in the current URL. If the current URL doesn't contain a hash, returns `undefined`.
*
* @returns A string or `undefined`.
*
* @example
* ```
* // See the definition of `navigator` in the `findRouteByURL()` example
*
* navigator.navigate('/movies/inception?showDetails=1#actors');
* navigator.getCurrentHash(); // => 'actors'
*
* navigator.navigate('/movies/inception?showDetails=1#actors');
* navigator.getCurrentHash(); // => 'actors'
*
* navigator.navigate('/movies/inception?showDetails=1#');
* navigator.getCurrentHash(); // => undefined
*
* navigator.navigate('/movies/inception?showDetails=1');
* navigator.getCurrentHash(); // => undefined
* ```
*
* @category Current Location
*/
getCurrentHash() {
let hash = this._getCurrentURL().hash;
if (hash.startsWith('#')) {
hash = hash.slice(1);
}
if (hash === '') {
return undefined;
}
return hash;
}
// === Navigation ===
/**
* Navigates to a URL.
*
* The specified URL is added to the navigator's history.
*
* The observers of the navigator are automatically called.
*
* Note that instead of using this method, you can use the handy `navigate()` shortcut function that you get when you define a route with the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @example
* ```
* navigator.navigate('/movies/inception');
*
* // Same as above, but in a more idiomatic way:
* Movie.Viewer.navigate({slug: 'inception});
* ```
*
* @category Navigation
* @possiblyasync
*/
navigate(url: string | URL, options: NavigationOptions = {}) {
const {silent = false, defer = true} = options;
this._navigate(normalizeURL(url));
if (silent) {
return;
}
return possiblyDeferred(defer, () => {
this.callObservers();
});
}
abstract _navigate(url: URL): void;
/**
* Redirects to a URL.
*
* The specified URL replaces the current entry of the navigator's history.
*
* The observers of the navigator are automatically called.
*
* Note that instead of using this method, you can use the handy `redirect()` shortcut function that you get when you define a route with the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @example
* ```
* navigator.redirect('/sign-in');
*
* // Same as above, but in a more idiomatic way:
* Session.SignIn.redirect();
* ```
*
* @category Navigation
* @possiblyasync
*/
redirect(url: string | URL, options: NavigationOptions = {}) {
const {silent = false, defer = true} = options;
this._redirect(normalizeURL(url));
if (silent) {
return;
}
return possiblyDeferred(defer, () => {
this.callObservers();
});
}
abstract _redirect(url: URL): void;
/**
* Reloads the execution environment with the specified URL.
*
* Note that instead of using this method, you can use the handy `reload()` shortcut function that you get when you define a route with the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
*
* @example
* ```
* navigator.reload('/');
*
* // Same as above, but in a more idiomatic way:
* Frontend.Home.reload();
* ```
*
* @category Navigation
*/
reload(url?: string | URL) {
const normalizedURL = url !== undefined ? normalizeURL(url) : undefined;
this._reload(normalizedURL);
}
abstract _reload(url: URL | undefined): void;
/**
* Move forwards or backwards through the navigator's history.
*
* The observers of the navigator are automatically called.
*
* @param delta A number representing the position in the navigator's history to which you want to move, relative to the current entry. A negative value moves backwards, a positive value moves forwards.
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @example
* ```
* navigator.go(-2); // Move backwards by two entries of the navigator's history
*
* navigator.go(-1); // Equivalent of calling `navigator.goBack()`
*
* navigator.go(1); // Equivalent of calling `navigator.goForward()`
*
* navigator.go(2); // Move forward two entries of the navigator's history
* ```
*
* @category Navigation
* @possiblyasync
*/
go(delta: number, options: NavigationOptions = {}) {
const {silent = false, defer = true} = options;
return possiblyAsync(this._go(delta), () => {
if (silent) {
return;
}
return possiblyDeferred(defer, () => {
this.callObservers();
});
});
}
abstract _go(delta: number): void;
/**
* Go back to the previous entry in the navigator's history.
*
* This method is the equivalent of calling `navigator.go(-1)`.
*
* The observers of the navigator are automatically called.
*
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @category Navigation
* @possiblyasync
*/
goBack(options: NavigationOptions = {}) {
return this.go(-1, options);
}
/**
* Go back to the first entry in the navigator's history.
*
* The observers of the navigator are automatically called.
*
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @category Navigation
* @possiblyasync
*/
goBackToRoot(options: NavigationOptions = {}) {
const index = this.getHistoryIndex();
if (index < 1) {
return undefined;
}
return this.go(-index, options);
}
/**
* Go forward to the next entry in the navigator's history.
*
* This method is the equivalent of calling `navigator.go(1)`.
*
* The observers of the navigator are automatically called.
*
* @param [options.silent] A boolean specifying whether the navigator's observers should *not* be called (default: `false`).
* @param [options.defer] A boolean specifying whether the calling of the navigator's observers should be deferred to the next tick (default: `true`).
*
* @category Navigation
* @possiblyasync
*/
goForward(options: NavigationOptions = {}) {
return this.go(1, options);
}
/**
* Returns the number of entries in the navigator's history.
*
* @category Navigation
*/
getHistoryLength() {
return this._getHistoryLength();
}
abstract _getHistoryLength(): number;
/**
* Returns the current index in the navigator's history.
*
* @category Navigation
*/
getHistoryIndex() {
return this._getHistoryIndex();
}
abstract _getHistoryIndex(): number;
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Customization ===
applyPlugins(plugins: NavigatorPlugin[]) {
for (const plugin of plugins) {
plugin(this);
}
}
_addressableMethodWrappers: AddressableMethodWrapper[] = [];
addAddressableMethodWrapper(methodWrapper: AddressableMethodWrapper) {
// TODO: Support multiple addressable method wrappers
if (this._addressableMethodWrappers.length > 0) {
throw new Error('You cannot add more than one addressable method wrapper');
}
this._addressableMethodWrappers.push(methodWrapper);
}
callAddressableMethodWrapper(receiver: any, method: Function, params: any) {
// TODO: Support multiple addressable method wrappers
if (this._addressableMethodWrappers.length === 1) {
const methodWrapper = this._addressableMethodWrappers[0];
return methodWrapper(receiver, method, params);
} else {
return method.call(receiver, params);
}
}
_customRouteDecorators: CustomRouteDecorator[] = [];
addCustomRouteDecorator(decorator: CustomRouteDecorator) {
this._customRouteDecorators.push(decorator);
}
applyCustomRouteDecorators(routable: any, method: Function) {
for (const customRouteDecorator of this._customRouteDecorators) {
customRouteDecorator.call(routable, method);
}
}
Link!: (props: any) => any;
// === Utilities ===
static isNavigator(value: any): value is Navigator {
return isNavigatorInstance(value);
}
}
function possiblyDeferred(defer: boolean, func: Function) {
if (!defer) {
func();
return;
}
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
func();
} catch (error) {
reject(error);
return;
}
resolve();
}, 0);
});
}
================================================
FILE: packages/navigator/src/utilities.test.ts
================================================
import {normalizeURL, stringifyURL, parseQuery, stringifyQuery} from './utilities';
describe('Utilities', () => {
test('normalizeURL() and stringifyURL()', async () => {
let url = 'https://username:password@domain.com:80/path?query=1#fragment';
let normalizedURL = normalizeURL(url);
expect(normalizedURL).toBeInstanceOf(URL);
expect(stringifyURL(normalizedURL)).toBe(url);
expect(normalizeURL(normalizedURL)).toBe(normalizedURL);
expect(stringifyURL(normalizeURL('/movies'))).toBe('/movies');
expect(stringifyURL(normalizeURL('movies'))).toBe('/movies');
expect(normalizeURL('https://localhost').pathname).toBe('/');
expect(normalizeURL('https://localhost/').pathname).toBe('/');
expect(normalizeURL('capacitor://localhost').pathname).toBe('/');
expect(normalizeURL('capacitor://localhost/').pathname).toBe('/');
// @ts-expect-error
expect(() => normalizeURL(123)).toThrow(
"Expected a string or a URL instance, but received a value of type 'number'"
);
expect(() => normalizeURL('https://?')).toThrow(
"The specified URL is invalid (URL: 'https://?')"
);
});
test('parseQuery() and stringifyQuery()', async () => {
expect(parseQuery('')).toEqual({});
expect(parseQuery('?')).toEqual({});
expect(parseQuery('category=drama&sortBy=title')).toEqual({category: 'drama', sortBy: 'title'});
expect(parseQuery('?category=drama&sortBy=title')).toEqual({
category: 'drama',
sortBy: 'title'
});
expect(stringifyQuery(undefined)).toEqual('');
expect(stringifyQuery({})).toEqual('');
expect(stringifyQuery({category: 'drama', sortBy: 'title'})).toEqual(
'category=drama&sortBy=title'
);
});
});
================================================
FILE: packages/navigator/src/utilities.ts
================================================
import qs from 'qs';
import {getTypeOf} from 'core-helpers';
import type {Navigator} from './navigator';
const INTERNAL_LAYR_BASE_URL = 'http://internal.layr';
/**
* Returns whether the specified value is a navigator class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isNavigatorClass(value: any): value is typeof Navigator {
return typeof value?.isNavigator === 'function';
}
/**
* Returns whether the specified value is a navigator instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isNavigatorInstance(value: any): value is Navigator {
return typeof value?.constructor?.isNavigator === 'function';
}
export function assertIsNavigatorInstance(value: any): asserts value is Navigator {
if (!isNavigatorInstance(value)) {
throw new Error(
`Expected a navigator instance, but received a value of type '${getTypeOf(value)}'`
);
}
}
export function normalizeURL(url: URL | string) {
if (url instanceof URL) {
return fixCapacitorURL(url);
}
if (typeof url !== 'string') {
throw new Error(
`Expected a string or a URL instance, but received a value of type '${getTypeOf(url)}'`
);
}
try {
return fixCapacitorURL(new URL(url, `${INTERNAL_LAYR_BASE_URL}/`));
} catch (error) {
throw new Error(`The specified URL is invalid (URL: '${url}')`);
}
}
function fixCapacitorURL(url: URL) {
if (url.protocol === 'capacitor:' && url.pathname === '') {
// Fix an issue where to root URL of a Capacitor app is 'capacitor://localhost'
// and `pathname` is an empty string
url = new URL(url.toString());
url.pathname = '/';
}
return url;
}
export function stringifyURL(url: URL) {
if (!(url instanceof URL)) {
throw new Error(`Expected a URL instance, but received a value of type '${getTypeOf(url)}'`);
}
let urlString = url.toString();
if (urlString.startsWith(INTERNAL_LAYR_BASE_URL)) {
urlString = urlString.slice(INTERNAL_LAYR_BASE_URL.length);
}
return urlString;
}
export function parseQuery(queryString: string) {
if (queryString.startsWith('?')) {
queryString = queryString.slice(1);
}
return qs.parse(queryString) as T;
}
export function stringifyQuery(query: object | undefined) {
return qs.stringify(query);
}
================================================
FILE: packages/navigator/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/observable/README.md
================================================
# @layr/observable
Observe JavaScript objects, arrays, or your own classes.
## Installation
```
npm install @layr/observable
```
## License
MIT
================================================
FILE: packages/observable/package.json
================================================
{
"name": "@layr/observable",
"version": "1.0.16",
"description": "Observe JavaScript objects, arrays, or your own classes",
"keywords": [
"observe",
"observer",
"notify",
"changes",
"object",
"array",
"class"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/observable",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"core-helpers": "^1.0.8",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5"
}
}
================================================
FILE: packages/observable/src/index.ts
================================================
export * from './observable';
================================================
FILE: packages/observable/src/observable.test.ts
================================================
import {
Observable,
createObservable,
isObservable,
canBeObserved,
isEmbeddable,
ObservableType
} from './observable';
describe('Observable', () => {
describe('Observable array', () => {
let originalArray: any[];
let observableArray: any[] & ObservableType;
beforeEach(() => {
originalArray = [3, 2, 1];
observableArray = createObservable(originalArray);
});
it('Should be an array', () => {
expect(Array.isArray(originalArray)).toBe(true);
expect(Array.isArray(observableArray)).toBe(true);
});
it('Should be an observable', () => {
expect(isObservable(originalArray)).toBe(false);
expect(isObservable(observableArray)).toBe(true);
});
it('Should be embeddable', () => {
expect(isEmbeddable(observableArray)).toBe(true);
});
it('Should be usable as the target of a new observable', () => {
const newObservableArray = createObservable(observableArray);
expect(isObservable(newObservableArray)).toBe(true);
});
it('Should be equal to the original array', () => {
expect(observableArray).toEqual(originalArray);
});
it('Should produce the same JSON as the original array', () => {
expect(JSON.stringify(observableArray)).toBe(JSON.stringify(originalArray));
});
it('Should call observers when callObservers() is called', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.callObservers();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when changing an item', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray[0] = 1;
expect(observer).toHaveBeenCalled();
});
it('Should not call observers when setting an item with the same value', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray[0] = 3;
expect(observer).not.toHaveBeenCalled();
});
it('Should call observers when changing array length', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.length = 0;
expect(observer).toHaveBeenCalled();
});
it(`Shouldn't call removed observers`, () => {
const observer1 = jest.fn();
observableArray.addObserver(observer1);
const observer2 = jest.fn();
observableArray.addObserver(observer2);
observableArray[0] = 4;
const numberOfCalls1 = observer1.mock.calls.length;
const numberOfCalls2 = observer2.mock.calls.length;
expect(numberOfCalls1).not.toBe(0);
expect(numberOfCalls2).not.toBe(0);
observableArray.removeObserver(observer1);
observableArray[0] = 5;
expect(observer1.mock.calls.length).toBe(numberOfCalls1);
expect(observer2.mock.calls.length).not.toBe(numberOfCalls1);
});
describe('Observable item', () => {
it('Should call observers when updating an observable item', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
const observableItem = createObservable([] as any[]);
observableArray[0] = observableItem;
expect(observer).toHaveBeenCalledTimes(1);
observableItem[0] = 1;
expect(observer).toHaveBeenCalledTimes(2);
const observableItem2 = createObservable([] as any[]);
observableArray.push(observableItem2);
expect(observer).toHaveBeenCalledTimes(3);
observableItem2[0] = 1;
expect(observer).toHaveBeenCalledTimes(4);
});
it(`Should stop calling observers when an observable item has been removed`, () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
const observableItem = createObservable([] as any[]);
observableArray[3] = observableItem;
expect(observer).toHaveBeenCalledTimes(1);
observableItem[0] = 1;
expect(observer).toHaveBeenCalledTimes(2);
observableArray[3] = undefined;
expect(observer).toHaveBeenCalledTimes(3);
observableItem[0] = 2;
expect(observer).toHaveBeenCalledTimes(3);
observableArray[3] = observableItem;
expect(observer).toHaveBeenCalledTimes(4);
observableItem[0] = 3;
expect(observer).toHaveBeenCalledTimes(5);
observableArray.pop();
expect(observer).toHaveBeenCalledTimes(7);
observableItem[0] = 4;
expect(observer).toHaveBeenCalledTimes(7);
});
it('Should observe existing items', () => {
const observableArray = createObservable([createObservable([1])]);
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
observableArray[0][0] = 2;
expect(observer).toHaveBeenCalledTimes(1);
});
it('Should make existing items observable when possible', () => {
const observableArray = createObservable([[1]]);
expect(isObservable(observableArray[0])).toBe(true);
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
observableArray[0][0] = 2;
expect(observer).toHaveBeenCalledTimes(1);
observableArray[0] = [3];
expect(isObservable(observableArray[0])).toBe(true);
expect(observer).toHaveBeenCalledTimes(2);
observableArray[0][0] = 4;
expect(observer).toHaveBeenCalledTimes(3);
});
});
describe('Mutator methods', () => {
it('Should call observers when using copyWithin()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.copyWithin(0, 1);
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using fill()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.fill(0);
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using pop()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.pop();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using push()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.push(4);
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using reverse()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.reverse();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using shift()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.shift();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using sort()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.sort();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using splice()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.splice(0, 1);
expect(observer).toHaveBeenCalled();
});
it('Should call observers when using unshift()', () => {
const observer = jest.fn();
observableArray.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableArray.unshift(4);
expect(observer).toHaveBeenCalled();
});
});
});
describe('Observable object', () => {
let originalObject: {[key: string]: any};
let observableObject: {[key: string]: any} & ObservableType;
beforeEach(() => {
originalObject = {
id: 1
};
observableObject = createObservable(originalObject);
});
it('Should be an object', () => {
expect(typeof originalObject).toEqual('object');
expect(typeof observableObject).toEqual('object');
});
it('Should be an observable', () => {
expect(isObservable(originalObject)).toBe(false);
expect(isObservable(observableObject)).toBe(true);
});
it('Should be embeddable', () => {
expect(isEmbeddable(observableObject)).toBe(true);
});
it('Should be usable as the target of a new observable', () => {
const newCustomObservable = createObservable(observableObject);
expect(isObservable(newCustomObservable)).toBe(true);
});
it('Should be equal to the original object', () => {
expect(observableObject).toEqual(originalObject);
});
it('Should produce the same JSON as the original object', () => {
expect(JSON.stringify(observableObject)).toBe(JSON.stringify(originalObject));
});
it('Should call observers when callObservers() is called', () => {
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObject.callObservers();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when changing an attribute', () => {
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObject.id = 2;
expect(observer).toHaveBeenCalled();
});
it('Should not call observers when setting an attribute with the same value', () => {
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObject.id = 1;
expect(observer).not.toHaveBeenCalled();
});
it(`Shouldn't call removed observers`, () => {
const observer1 = jest.fn();
observableObject.addObserver(observer1);
const observer2 = jest.fn();
observableObject.addObserver(observer2);
observableObject.id = 2;
const numberOfCalls1 = observer1.mock.calls.length;
const numberOfCalls2 = observer2.mock.calls.length;
expect(numberOfCalls1).not.toBe(0);
expect(numberOfCalls2).not.toBe(0);
observableObject.removeObserver(observer1);
observableObject.id = 3;
expect(observer1.mock.calls.length).toBe(numberOfCalls1);
expect(observer2.mock.calls.length).not.toBe(numberOfCalls1);
});
describe('Observable attribute', () => {
it('Should call observers when updating an observable attribute', () => {
const observableAttribute = createObservable({id: 1});
observableObject.attribute = observableAttribute;
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableAttribute.id = 2;
expect(observer).toHaveBeenCalled();
});
it(`Should stop calling observers when an observable attribute has been removed`, () => {
const observableAttribute = createObservable({} as any);
observableObject.attribute = observableAttribute;
const observer = jest.fn();
observableObject.addObserver(observer);
delete observableObject.attribute;
const numberOfCalls = observer.mock.calls.length;
expect(numberOfCalls).not.toBe(0);
observableAttribute.id = 2;
expect(observer.mock.calls.length).toBe(numberOfCalls);
});
it('Should observe existing attributes', () => {
const observableObject = createObservable({innerObject: createObservable({id: 1})});
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
observableObject.innerObject.id = 2;
expect(observer).toHaveBeenCalledTimes(1);
});
it('Should make existing attributes observable when possible', () => {
const observableObject = createObservable({innerObject: {id: 1}});
expect(isObservable(observableObject.innerObject)).toBe(true);
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
observableObject.innerObject.id = 2;
expect(observer).toHaveBeenCalledTimes(1);
observableObject.innerObject = {id: 3};
expect(isObservable(observableObject.innerObject)).toBe(true);
expect(observer).toHaveBeenCalledTimes(2);
observableObject.innerObject.id = 4;
expect(observer).toHaveBeenCalledTimes(3);
});
});
describe('Forked observable object', () => {
let observableObjectFork: {[key: string]: any} & ObservableType;
beforeEach(() => {
observableObjectFork = Object.create(observableObject);
});
it('Should not be an observable', () => {
expect(isObservable(observableObjectFork)).toBe(false);
});
it('Should not have a method such as addObserver()', () => {
expect(observableObjectFork.addObserver).toBeUndefined();
expect(observableObjectFork.removeObserver).toBeUndefined();
expect(observableObjectFork.callObservers).toBeUndefined();
expect(observableObjectFork.isObservable).toBeUndefined();
});
it('Should allow changing an attribute without changing the original observable', () => {
observableObjectFork.id = 2;
expect(observableObjectFork.id).toBe(2);
expect(observableObject.id).toBe(1);
});
it('Should not call the observers of the original observable when changing an attribute', () => {
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObjectFork.id = 2;
expect(observer).not.toHaveBeenCalled();
});
it('Should be able to become an observable', () => {
expect(isObservable(observableObjectFork)).toBe(false);
const observableObservableObjectFork = createObservable(observableObjectFork);
expect(isObservable(observableObservableObjectFork)).toBe(true);
const objectObserver = jest.fn();
observableObject.addObserver(objectObserver);
const objectForkObserver = jest.fn();
observableObservableObjectFork.addObserver(objectForkObserver);
expect(objectObserver).not.toHaveBeenCalled();
expect(objectForkObserver).not.toHaveBeenCalled();
observableObservableObjectFork.id = 2;
expect(objectForkObserver).toHaveBeenCalled();
expect(objectObserver).not.toHaveBeenCalled();
});
});
});
describe('Custom observable', () => {
class BaseCustomObservable extends Observable(Object) {
_id: number | undefined;
constructor({id}: {id?: number} = {}) {
super();
this._id = id;
}
static _limit: number;
static get limit() {
return this._limit;
}
static set limit(limit) {
this._limit = limit;
this.callObservers();
}
get id() {
return this._id;
}
set id(id) {
this._id = id;
this.callObservers();
}
}
describe('Observable class', () => {
let CustomObservable: typeof BaseCustomObservable;
beforeEach(() => {
CustomObservable = class CustomObservable extends BaseCustomObservable {};
});
it('Should be an observable', () => {
expect(isObservable(CustomObservable)).toBe(true);
});
it('Should be usable as the target of a new observable', () => {
const NewCustomObservable = createObservable(CustomObservable);
expect(isObservable(NewCustomObservable)).toBe(true);
});
it('Should call observers when callObservers() is called', () => {
const observer = jest.fn();
CustomObservable.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
CustomObservable.callObservers();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when changing an attribute', () => {
const observer = jest.fn();
CustomObservable.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
CustomObservable.limit = 2;
expect(observer).toHaveBeenCalled();
});
it(`Shouldn't call removed observers`, () => {
const observer1 = jest.fn();
CustomObservable.addObserver(observer1);
const observer2 = jest.fn();
CustomObservable.addObserver(observer2);
CustomObservable.limit = 2;
const numberOfCalls1 = observer1.mock.calls.length;
const numberOfCalls2 = observer2.mock.calls.length;
expect(numberOfCalls1).not.toBe(0);
expect(numberOfCalls2).not.toBe(0);
CustomObservable.removeObserver(observer1);
CustomObservable.limit = 3;
expect(observer1.mock.calls.length).toBe(numberOfCalls1);
expect(observer2.mock.calls.length).not.toBe(numberOfCalls1);
});
});
describe('Observable instance', () => {
let customObservable: BaseCustomObservable;
beforeEach(() => {
customObservable = new BaseCustomObservable({id: 1});
});
it('Should be an observable', () => {
expect(isObservable(customObservable)).toBe(true);
});
it('Should be usable as the target of a new observable', () => {
const newCustomObservable = createObservable(customObservable);
expect(isObservable(newCustomObservable)).toBe(true);
});
it('Should call observers when callObservers() is called', () => {
const observer = jest.fn();
customObservable.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
customObservable.callObservers();
expect(observer).toHaveBeenCalled();
});
it('Should call observers when changing an attribute', () => {
const observer = jest.fn();
customObservable.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
customObservable.id = 2;
expect(observer).toHaveBeenCalled();
});
it(`Shouldn't call removed observers`, () => {
const observer1 = jest.fn();
customObservable.addObserver(observer1);
const observer2 = jest.fn();
customObservable.addObserver(observer2);
customObservable.id = 2;
const numberOfCalls1 = observer1.mock.calls.length;
const numberOfCalls2 = observer2.mock.calls.length;
expect(numberOfCalls1).not.toBe(0);
expect(numberOfCalls2).not.toBe(0);
customObservable.removeObserver(observer1);
customObservable.id = 3;
expect(observer1.mock.calls.length).toBe(numberOfCalls1);
expect(observer2.mock.calls.length).not.toBe(numberOfCalls1);
});
});
});
describe('Observable object referencing a non-embeddable object', () => {
let originalObject: {[key: string]: any};
let observableObject: {[key: string]: any} & ObservableType;
beforeEach(() => {
originalObject = {};
observableObject = createObservable(originalObject);
});
it("Shouldn't call referrer's observers", () => {
class NonEmbeddable extends Observable(Object) {
static isEmbedded() {
return false;
}
}
const nonEmbeddable = new NonEmbeddable();
expect(isEmbeddable(nonEmbeddable)).toBe(false);
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).toHaveBeenCalledTimes(0);
observableObject.nonEmbeddable = nonEmbeddable;
expect(observer).toHaveBeenCalledTimes(1);
nonEmbeddable.callObservers();
expect(observer).toHaveBeenCalledTimes(1);
});
});
describe('Observer payload', () => {
it('Should allow to specify a payload when calling observers', () => {
const observableObject = createObservable({} as any);
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObject.callObservers({source: 'server'});
expect(observer.mock.calls[0][0].source).toBe('server');
});
});
describe('Unobservable value', () => {
it('Should not be possible to observe a primitive', () => {
expect(canBeObserved(true)).toBe(false);
expect(canBeObserved(1)).toBe(false);
expect(canBeObserved('Hello')).toBe(false);
expect(canBeObserved(new Date())).toBe(false);
expect(canBeObserved(undefined)).toBe(false);
expect(canBeObserved(null)).toBe(false);
// @ts-expect-error
expect(() => createObservable('Hello')).toThrow(
'Cannot create an observable from a target that is not an object, an array, or a function'
);
});
});
describe('Observable with a circular reference', () => {
it('Should not loop indefinitely', () => {
const observableObject = createObservable({} as any);
const observer = jest.fn();
observableObject.addObserver(observer);
expect(observer).not.toHaveBeenCalled();
observableObject.circularReference = observableObject;
expect(observer).toHaveBeenCalled();
});
});
});
================================================
FILE: packages/observable/src/observable.ts
================================================
import {hasOwnProperty, Constructor, isClass, getTypeOf, isPlainObject} from 'core-helpers';
export type ObservableType = {
addObserver(observer: Observer): void;
removeObserver(observer: Observer): void;
callObservers(...args: any[]): void;
isObservable(value: any): boolean;
};
export type Observer = ObserverFunction | ObservableType;
export type ObserverFunction = (...args: any[]) => void;
export type ObserverPayload = {[key: string]: unknown};
/**
* Brings observability to any class.
*
* This mixin is used to construct several Layr's classes such as [`Component`](https://layrjs.com/docs/v2/reference/component) or [`Attribute`](https://layrjs.com/docs/v2/reference/attribute). So, in most cases, you'll have the capabilities provided by this mixin without having to call it.
*
* #### Usage
*
* Call the `Observable()` mixin with any class to construct an [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class. Then, you can add some observers by using the [`addObserver()`](https://layrjs.com/docs/v2/reference/observable#add-observer-dual-method) method, and trigger their execution anytime by using the [`callObservers()`](https://layrjs.com/docs/v2/reference/observable#call-observers-dual-method) method.
*
* For example, let's define a `Movie` class using the `Observable()` mixin:
*
* ```
* // JS
*
* import {Observable} from '@layr/observable';
*
* class Movie extends Observable(Object) {
* get title() {
* return this._title;
* }
*
* set title(title) {
* this._title = title;
* this.callObservers();
* }
* }
* ```
*
* ```
* // TS
*
* import {Observable} from '@layr/observable';
*
* class Movie extends Observable(Object) {
* _title?: string;
*
* get title() {
* return this._title;
* }
*
* set title(title: string) {
* this._title = title;
* this.callObservers();
* }
* }
* ```
*
* Next, we can create a `Movie` instance, and observe it:
*
* ```
* const movie = new Movie();
*
* movie.addObserver(() => {
* console.log('The movie's title has changed');
* })
* ```
*
* And now, every time we change the title of `movie`, its observer will be automatically executed:
*
* ```
* movie.title = 'Inception';
*
* // Should display:
* // 'The movie's title has changed'
* ```
*
* > Note that the same result could have been achieved by using a Layr [`Component`](https://layrjs.com/docs/v2/reference/component):
* >
* > ```
* > // JS
* >
* > import {Component, attribute} from '@layr/component';
* >
* > class Movie extends Component {
* > @attribute('string?') title;
* > }
* > ```
* >
* > ```
* > // TS
* >
* > import {Component, attribute} from '@layr/component';
* >
* > class Movie extends Component {
* > @attribute('string?') title?: string;
* > }
* > ```
*
* ### Observable class {#observable-class}
*
* An `Observable` class is constructed by calling the `Observable()` mixin ([see above](https://layrjs.com/docs/v2/reference/observable#observable-mixin)).
* @mixin
*/
export function Observable(Base: T) {
if (!isClass(Base)) {
throw new Error(
`The Observable mixin should be applied on a class (received type: '${getTypeOf(Base)}')`
);
}
if (typeof (Base as any).isObservable === 'function') {
return Base as T & typeof Observable;
}
const Observable = class extends Base {
/**
* Adds an observer to the current class or instance.
*
* @param observer A function that will be automatically executed when the [`callObservers()`](https://layrjs.com/docs/v2/reference/observable#call-observers-dual-method) method is called. Alternatively, you can specify an observable for which the observers should be executed, and doing so, you can connect an observable to another observable.
*
* @example
* ```
* Movie.addObserver(() => {
* // A `Movie` class observer
* });
*
* const movie = new Movie();
*
* movie.addObserver(() => {
* // A `Movie` instance observer
* });
*
* const actor = new Actor();
*
* // Connect `actor` to `movie` so that when `callObservers()` is called on `actor`,
* // then `callObservers()` is automatically called on `movie`
* actor.addObserver(movie);
* ```
*
* @category Methods
*/
static get addObserver() {
return this.prototype.addObserver;
}
/**
* Adds an observer to the current class or instance.
*
* @param observer A function that will be automatically executed when the [`callObservers()`](https://layrjs.com/docs/v2/reference/observable#call-observers-dual-method) method is called. Alternatively, you can specify an observable for which the observers should be executed, and doing so, you can connect an observable to another observable.
*
* @example
* ```
* Movie.addObserver(() => {
* // A `Movie` class observer
* });
*
* const movie = new Movie();
*
* movie.addObserver(() => {
* // A `Movie` instance observer
* });
*
* const actor = new Actor();
*
* // Connect `actor` to `movie` so that when `callObservers()` is called on `actor`,
* // then `callObservers()` is automatically called on `movie`
* actor.addObserver(movie);
* ```
*
* @category Methods
*/
addObserver(observer: Observer) {
this.__getObservers().add(observer);
}
/**
* Removes an observer from the current class or instance.
*
* @param observer A function or a connected observable.
*
* @example
* ```
* const observer = () => {
* // ...
* }
*
* // Add `observer` to the `Movie` class
* Movie.addObserver(observer);
*
* // Remove `observer` from to the `Movie` class
* Movie.removeObserver(observer);
*
* const movie = new Movie();
* const actor = new Actor();
*
* // Connect `actor` to `movie`
* actor.addObserver(movie);
*
* // Remove the connection between `actor` and `movie`
* actor.removeObserver(movie);
* ```
*
* @category Methods
*/
static get removeObserver() {
return this.prototype.removeObserver;
}
/**
* Removes an observer from the current class or instance.
*
* @param observer A function or a connected observable.
*
* @example
* ```
* const observer = () => {
* // ...
* }
*
* // Add `observer` to the `Movie` class
* Movie.addObserver(observer);
*
* // Remove `observer` from to the `Movie` class
* Movie.removeObserver(observer);
*
* const movie = new Movie();
* const actor = new Actor();
*
* // Connect `actor` to `movie`
* actor.addObserver(movie);
*
* // Remove the connection between `actor` and `movie`
* actor.removeObserver(movie);
* ```
*
* @category Methods
*/
removeObserver(observer: Observer) {
this.__getObservers().remove(observer);
}
/**
* Calls the observers of the current class or instance.
*
* @param [payload] An optional object to pass to the observers when they are executed.
*
* @example
* ```
* const movie = new Movie();
*
* movie.addObserver((payload) => {
* console.log('Observer called with:', payload);
* });
*
* movie.callObservers();
*
* // Should display:
* // 'Observer called with: undefined'
*
* movie.callObservers({changes: ['title']});
*
* // Should display:
* // 'Observer called with: {changes: ['title']}'
* ```
*
* @category Methods
*/
static get callObservers() {
return this.prototype.callObservers;
}
/**
* Calls the observers of the current class or instance.
*
* @param [payload] An optional object to pass to the observers when they are executed.
*
* @example
* ```
* const movie = new Movie();
*
* movie.addObserver((payload) => {
* console.log('Observer called with:', payload);
* });
*
* movie.callObservers();
*
* // Should display:
* // 'Observer called with: undefined'
*
* movie.callObservers({changes: ['title']});
*
* // Should display:
* // 'Observer called with: {changes: ['title']}'
* ```
*
* @category Methods
*/
callObservers(payload?: ObserverPayload) {
this.__getObservers().call(payload);
}
static __observers?: ObserverSet;
__observers?: ObserverSet;
static get __getObservers() {
return this.prototype.__getObservers;
}
__getObservers() {
if (!hasOwnProperty(this, '__observers')) {
Object.defineProperty(this, '__observers', {value: new ObserverSet()});
}
return this.__observers!;
}
static get isObservable() {
return this.prototype.isObservable;
}
isObservable(value: any): value is ObservableType {
return isObservable(value);
}
};
return Observable;
}
/**
* Returns an observable from an existing object or array.
*
* The returned observable is observed deeply. So, for example, if an object contains a nested object, modifying the nested object will trigger the execution of the parent's observers.
*
* The returned observable provides the same methods as an [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) instance:
*
* - [`addObserver()`](https://layrjs.com/docs/v2/reference/observable#add-observer-dual-method)
* - [`removeObserver()`](https://layrjs.com/docs/v2/reference/observable#remove-observer-dual-method)
* - [`callObservers()`](https://layrjs.com/docs/v2/reference/observable#call-observers-dual-method)
*
* @param target A JavaScript plain object or array that you want to observe.
*
* @returns An observable objet or array.
*
* @example
* ```
* import {createObservable} from '@layr/observable';
*
* // Create an observable `movie`
* const movie = createObservable({
* title: 'Inception',
* genres: ['drama'],
* details: {duration: 120}
* });
*
* // Add an observer
* movie.addObserver(() => {
* // ...
* });
*
* // Then, any of the following changes on `movie` will call the observer:
* movie.title = 'Inception 2';
* delete movie.title;
* movie.year = 2010;
* movie.genres.push('action');
* movie.genres[1] = 'sci-fi';
* movie.details.duration = 125;
* ```
*
* @category Bringing Observability to an Object or an Array
*/
export function createObservable(target: T) {
if (!canBeObserved(target)) {
throw new Error(
`Cannot create an observable from a target that is not an object, an array, or a function`
);
}
if (isObservable(target)) {
return target;
}
const observers = new ObserverSet();
const handleAddObserver = function (observer: Observer) {
observers.add(observer);
};
const handleRemoveObserver = function (observer: Observer) {
observers.remove(observer);
};
const handleCallObservers = function (payload?: ObserverPayload) {
observers.call(payload);
};
const handleIsObservable = function (value: any) {
return isObservable(value);
};
let observable: T & ObservableType;
const handler = {
has(target: object, key: string | number | symbol) {
if (
key === 'addObserver' ||
key === 'removeObserver' ||
key === 'callObservers' ||
key === 'isObservable'
) {
return true;
}
return Reflect.has(target, key);
},
get(target: object, key: string | number | symbol, receiver?: any) {
if (receiver === observable) {
if (key === 'addObserver') {
return handleAddObserver;
}
if (key === 'removeObserver') {
return handleRemoveObserver;
}
if (key === 'callObservers') {
return handleCallObservers;
}
if (key === 'isObservable') {
return handleIsObservable;
}
}
return Reflect.get(target, key, receiver);
},
set(target: object, key: string | number | symbol, newValue: any, receiver?: any) {
if (
key === 'addObserver' ||
key === 'removeObserver' ||
key === 'callObservers' ||
key === 'isObservable'
) {
throw new Error(
`Cannot set a property named 'addObserver', 'removeObserver', 'callObservers' or 'isObservable' in an observed object`
);
}
if (canBeObserved(newValue) && !isObservable(newValue) && isEmbeddable(newValue)) {
newValue = createObservable(newValue);
}
const previousValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, newValue, receiver);
if (receiver === observable && newValue?.valueOf() !== previousValue?.valueOf()) {
if (isObservable(previousValue) && isEmbeddable(previousValue)) {
previousValue.removeObserver(handleCallObservers);
}
if (isObservable(newValue) && isEmbeddable(newValue)) {
newValue.addObserver(handleCallObservers);
}
handleCallObservers();
}
return result;
},
deleteProperty(target: object, key: string | number | symbol) {
if (
key === 'addObserver' ||
key === 'removeObserver' ||
key === 'callObservers' ||
key === 'isObservable'
) {
throw new Error(
`Cannot delete a property named 'addObserver', 'removeObserver', 'callObservers' or 'isObservable' in an observed object`
);
}
const previousValue = Reflect.get(target, key);
if (isObservable(previousValue) && isEmbeddable(previousValue)) {
previousValue.removeObserver(handleCallObservers);
}
const result = Reflect.deleteProperty(target, key);
handleCallObservers();
return result;
}
};
observable = new Proxy(target, handler) as T & ObservableType;
const observeExistingValue = function (key: string | number, value: unknown) {
if (canBeObserved(value) && !isObservable(value) && isEmbeddable(value)) {
value = createObservable(value);
(target as any)[key] = value;
}
if (isObservable(value)) {
value.addObserver(observable);
}
};
if (Array.isArray(target)) {
for (let index = 0; index < target.length; index++) {
observeExistingValue(index, target[index]);
}
} else if (isPlainObject(target)) {
for (const [key, value] of Object.entries(target)) {
observeExistingValue(key, value);
}
}
return observable;
}
export class ObserverSet {
_observers: Observer[];
constructor() {
this._observers = [];
}
add(observer: Observer) {
if (!(typeof observer === 'function' || isObservable(observer))) {
throw new Error(`Cannot add an observer that is not a function or an observable`);
}
this._observers.push(observer);
}
remove(observer: Observer) {
if (!(typeof observer === 'function' || isObservable(observer))) {
throw new Error(`Cannot remove an observer that is not a function or an observable`);
}
const index = this._observers.indexOf(observer);
if (index !== -1) {
this._observers.splice(index, 1);
}
}
call({
_observerStack = new Set(),
...payload
}: ObserverPayload & {_observerStack?: Set} = {}) {
for (const observer of this._observers) {
if (_observerStack.has(observer)) {
continue; // Avoid looping indefinitely when a circular reference is encountered
}
_observerStack.add(observer);
try {
if (isObservable(observer)) {
observer.callObservers({_observerStack, ...payload});
} else {
observer({_observerStack, ...payload});
}
} finally {
_observerStack.delete(observer);
}
}
}
}
/**
* Returns whether the specified value is observable. When a value is observable, you can use any the following methods on it: [`addObserver()`](https://layrjs.com/docs/v2/reference/observable#add-observer-dual-method), [`removeObserver()`](https://layrjs.com/docs/v2/reference/observable#remove-observer-dual-method), and [`callObservers()`](https://layrjs.com/docs/v2/reference/observable#call-observers-dual-method).
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isObservable(value: any): value is ObservableType {
return typeof value?.isObservable === 'function';
}
export function canBeObserved(value: any): value is object {
return (
(typeof value === 'object' && value !== null && !(value instanceof Date)) ||
typeof value === 'function'
);
}
export function isEmbeddable(value: any) {
const isEmbedded = value?.constructor?.isEmbedded;
if (typeof isEmbedded === 'function' && isEmbedded() === false) {
return false;
}
return true;
}
================================================
FILE: packages/observable/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/react-integration/README.md
================================================
# @layr/react-integration
React integration for Layr.
## Installation
```
npm install @layr/react-integration
```
## License
MIT
================================================
FILE: packages/react-integration/package.json
================================================
{
"name": "@layr/react-integration",
"version": "2.0.146",
"description": "React integration for Layr",
"keywords": [
"layr",
"react",
"integration",
"hook",
"navigator-plugin"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/react-integration",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/browser-navigator": "^2.0.67",
"@layr/component": "^2.0.51",
"@layr/memory-navigator": "^2.0.59",
"@layr/navigator": "^2.0.55",
"@layr/observable": "^1.0.16",
"@layr/routable": "^2.0.113",
"@layr/utilities": "^1.0.9",
"core-helpers": "^1.0.8",
"lodash": "^4.17.21",
"tslib": "^2.4.1"
},
"peerDependencies": {
"react": ">=16.13.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/lodash": "^4.14.191",
"@types/react": "^16.14.34"
}
}
================================================
FILE: packages/react-integration/src/components.tsx
================================================
import {callRouteByURL, RoutableComponent, assertIsRoutableClass} from '@layr/routable';
import {Navigator} from '@layr/navigator';
import {BrowserNavigator} from '@layr/browser-navigator';
import {formatError} from '@layr/utilities';
import React, {useRef, useState, useEffect, useContext} from 'react';
import {useForceUpdate} from './hooks';
import {BrowserNavigatorPlugin} from './plugins';
/**
* A React component providing sensible defaults for a web app.
*
* You should use this component once at the top of your app.
*
* Note that if you use [Boostr](https://boostr.dev/) to manage your app development, this component will be automatically mounted, so you don't have to use it explicitly in your code.
*
* The main point of this component is to provide the default behavior of high-level hooks such as [`useData()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) or [`useAction()`](https://layrjs.com/docs/v2/reference/react-integration#use-action-react-hook):
*
* - `useData()` will render `null` while the `getter()` function is running, and, in the case an error is thrown, a `` containing an error message will be rendered.
* - `useAction()` will prevent the user from interacting with any UI element in the browser page while the `handler()` function is running, and, in the case an error is thrown, the browser's `alert()` function will be called to display the error message.
*
* @examplelink See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* @category React Components
* @reactcomponent
*/
export function BrowserRootView({
children,
...customization
}: {children: React.ReactNode} & Partial
) {
const previousCustomization = useContext(CustomizationContext);
if (previousCustomization !== undefined) {
throw new Error("An app shouldn't have more than one RootView");
}
const actionView = useRef(null);
return (
null,
errorRenderer: (error) => {
console.error(error);
return {formatError(error)}
;
},
actionWrapper: async (actionHandler, args) => {
actionView.current!.open();
try {
return await actionHandler(...args);
} finally {
actionView.current!.close();
}
},
errorNotifier: async (error) => {
alert(formatError(error));
},
...customization
}}
>
{children}
);
}
export const NavigatorContext = React.createContext(undefined);
/**
* A React component providing a [`BrowserNavigator`](https://layrjs.com/docs/v2/reference/browser-navigator#browser-navigator-class) to your app.
*
* You should use this component once at the top of your app after the [`BrowserRootView`](https://layrjs.com/docs/v2/reference/react-integration#browser-root-view-react-component) component.
*
* Note that if you use [Boostr](https://boostr.dev/) to manage your app development, this component will be automatically mounted, so you don't have to use it explicitly in your code.
*
* @param props.rootComponent The root Layr component of your app. Note that this Layr component should be [`Routable`](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
*
* @example
* ```
* // JS
*
* import React, {Fragment} from 'react';
* import ReactDOM from 'react-dom';
* import {Component} from '@layr/component';
* import {Routable} from '@layr/routable';
* import {BrowserRootView, BrowserNavigatorView, layout, page} from '@layr/react-integration';
*
* class Application extends Routable(Component) {
* // `@layout('/')` is a shortcut for `@wrapper('/') @view()`
* ﹫layout('/') static MainLayout({children}) {
* return (
* <>
*
* My App
*
*
* {children()} // Renders the subcomponents using this layout
* >
* );
* }
*
* // `@page('[/]')` is a shortcut for `@route('[/]') @view()`
* ﹫page('[/]') static HomePage() {
* return Hello, World!
;
* }
* }
*
* // Note that you don't need the following code when you use Boostr
* ReactDOM.render(
*
*
* ,
* // Your `index.html` page should contain `
`
* document.getElementById('root')
* );
* ```
*
* @example
* ```
* // TS
*
* import React, {Fragment} from 'react';
* import ReactDOM from 'react-dom';
* import {Component} from '@layr/component';
* import {Routable} from '@layr/routable';
* import {BrowserRootView, BrowserNavigatorView, layout, page} from '@layr/react-integration';
*
* class Application extends Routable(Component) {
* // `@layout('/')` is a shortcut for `@wrapper('/') @view()`
* ﹫layout('/') static MainLayout({children}: {children: () => any}) {
* return (
* <>
*
* My App
*
*
* {children()} // Renders the subcomponents using this layout
* >
* );
* }
*
* // `@page('[/]')` is a shortcut for `@route('[/]') @view()`
* ﹫page('[/]') static HomePage() {
* return Hello, World!
;
* }
* }
*
* // Note that you don't need the following code when you use Boostr
* ReactDOM.render(
*
*
* ,
* // Your `index.html` page should contain `
`
* document.getElementById('root')
* );
* ```
*
* @category React Components
* @reactcomponent
*/
export function BrowserNavigatorView({rootComponent}: {rootComponent: RoutableComponent}) {
assertIsRoutableClass(rootComponent);
const navigatorRef = useRef();
if (navigatorRef.current === undefined) {
navigatorRef.current = new BrowserNavigator({plugins: [BrowserNavigatorPlugin()]});
rootComponent.registerNavigator(navigatorRef.current);
}
const [isReady, setIsReady] = useState(false);
const forceUpdate = useForceUpdate();
useEffect(() => {
navigatorRef.current!.addObserver(forceUpdate);
setIsReady(true);
return function () {
navigatorRef.current!.removeObserver(forceUpdate);
navigatorRef.current!.unmount();
};
}, []);
if (!isReady) {
return null;
}
return (
{callRouteByURL(rootComponent, navigatorRef.current.getCurrentURL())}
);
}
/**
* A hook allowing you to get the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#navigator-class) used in your app.
*
* @returns A [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#navigator-class) instance.
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import {Routable} from '﹫layr/routable';
* import React from 'react';
* import {view, useNavigator} from '﹫layr/react-integration';
*
* import logo from '../assets/app-logo.svg';
*
* class Application extends Routable(Component) {
* // ...
*
* ﹫view() static LogoView() {
* const navigator = useNavigator();
*
* return { navigator.navigate('/); }} />;
* }
* }
* ```
*
* @category High-Level Hooks
* @reacthook
*/
export function useNavigator() {
const navigator = useContext(NavigatorContext);
if (navigator === undefined) {
throw new Error(
"Couldn't get a navigator. Please make sure you have included a NavigatorView at the top of your React component tree."
);
}
return navigator;
}
export type Customization = {
dataPlaceholder: () => JSX.Element | null;
errorRenderer: (error: Error) => JSX.Element | null;
actionWrapper: (actionHandler: (...args: any[]) => Promise, args: any[]) => Promise;
errorNotifier: (error: Error) => Promise;
};
export const CustomizationContext = React.createContext(undefined);
export function useCustomization() {
const customization = useContext(CustomizationContext);
if (customization === undefined) {
throw new Error(
"Couldn't get the current customization. Please make sure you have included a RootView at the top of your React component tree."
);
}
return customization;
}
/**
* A React component allowing you to customize the behavior of high-level hooks such as [`useData()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) or [`useAction()`](https://layrjs.com/docs/v2/reference/react-integration#use-action-react-hook).
*
* @param [props.dataPlaceholder] A function returning a React element (or `null`) that is rendered while the `getter()` function of the [`useData()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) hook is running. A typical use case is to render a spinner.
* @param [props.errorRenderer] A function returning a React element (or `null`) that is rendered when the `getter()` function of the [`useData()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) hook throws an error. The `errorRenderer()` function receives the error as first parameter. A typical use case is to render an error message.
* @param [props.actionWrapper] An asynchronous function allowing you to wrap the `handler()` function of the [`useAction()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) hook. The `actionWrapper()` function receives the `handler()` function as first parameter, should execute it, and return its result. A typical use case is to lock the screen while the `handler()` function is running so the user cannot interact with any UI element.
* @param [props.errorNotifier] An asynchronous function that is executed when the `handler()` function of the [`useAction()`](https://layrjs.com/docs/v2/reference/react-integration#use-data-react-hook) hook throws an error. The `errorNotifier()` function receives the error as first parameter. A typical use case is to display an error alert dialog.
*
* @example
* ```
* {
* // Renders a custom `LoadingSpinner` component
* return ;
* }}
* errorRenderer={(error) => {
* // Renders a custom `ErrorMessage` component
* return {error} ;
* }}
* actionWrapper={async (actionHandler, args) => {
* // Do whatever you want here (e.g., custom screen locking)
* try {
* return await actionHandler(...args);
* } finally {
* // Do whatever you want here (e.g., custom screen unlocking)
* }
* }}
* errorNotifier={async (error) => {
* // Calls a custom `alert()` asynchronous function
* await alert(error.message);
* }}
* >
*
*
* ```
*
* @category React Components
* @reactcomponent
*/
export function Customizer({
children,
...customization
}: Partial & {children: React.ReactNode}) {
const previousCustomization = useCustomization();
return (
{children}
);
}
export class BrowserActionView extends React.Component<
{children?: React.ReactNode},
{count: number; activeElement: Element | null}
> {
state = {
count: 0,
activeElement: null
};
open() {
this.setState(({count, activeElement}) => {
count++;
if (count === 1) {
activeElement = document.activeElement;
setTimeout(() => {
if (typeof (activeElement as any)?.blur === 'function') {
(activeElement as any).blur();
}
}, 0);
}
return {count, activeElement};
});
}
close() {
this.setState(({count, activeElement}) => {
count--;
if (count === 0) {
const savedActiveElement = activeElement;
setTimeout(() => {
if (typeof (savedActiveElement as any)?.focus === 'function') {
(savedActiveElement as any).focus();
}
}, 0);
activeElement = null;
}
return {count, activeElement};
});
}
render() {
if (this.state.count === 0) {
return null;
}
if (this.props.children !== undefined) {
return this.props.children;
}
return (
);
}
}
================================================
FILE: packages/react-integration/src/decorators.tsx
================================================
import {Component, isComponentClassOrInstance} from '@layr/component';
import {
RoutableComponent,
route,
RouteOptions,
wrapper,
WrapperOptions,
Pattern
} from '@layr/routable';
import {PlainObject, hasOwnProperty} from 'core-helpers';
import {useObserve} from './hooks';
type ViewOption = {observe?: boolean};
/**
* Decorates a method of a Layr [component](https://layrjs.com/docs/v2/reference/component) so it be can used as a React component.
*
* Like any React component, the method can receive some properties as first parameter and return some React elements to render.
*
* The decorator binds the method to a specific component, so when the method is executed by React (via, for example, a reference included in a [JSX expression](https://reactjs.org/docs/introducing-jsx.html)), it has access to the bound component through `this`.
*
* Also, the decorator observes the attributes of the bound component, so when the value of an attribute changes, the React component is automatically re-rendered.
*
* @example
* ```
* import {Component, attribute} from '﹫layr/component';
* import React from 'react';
* import ReactDOM from 'react-dom';
* import {view} from '﹫layr/react-integration';
*
* class Person extends Component {
* ﹫attribute('string') firstName = '';
*
* ﹫attribute('string') lastName = '';
*
* ﹫view() FullName() {
* return {`${this.firstName} ${this.fullName}`} ;
* }
* }
*
* const person = new Person({firstName: 'Alan', lastName: 'Turing'});
*
* ReactDOM.render( , document.getElementById('root'));
* ```
*
* @category Decorators
* @decorator
*/
export function view(options: ViewOption = {}) {
const {observe = true} = options;
return function (
target: typeof Component | Component,
name: string,
descriptor: PropertyDescriptor
) {
const {value: ReactComponent, configurable, enumerable} = descriptor;
if (
!(
isComponentClassOrInstance(target) &&
typeof ReactComponent === 'function' &&
enumerable === false
)
) {
throw new Error(
`@view() should be used to decorate a component method (property: '${name}')`
);
}
return {
configurable,
enumerable,
get(this: (typeof Component | Component) & {__boundReactComponents: PlainObject}) {
if (!hasOwnProperty(this, '__boundReactComponents')) {
Object.defineProperty(this, '__boundReactComponents', {value: Object.create(null)});
}
let BoundReactComponent = this.__boundReactComponents[name];
if (BoundReactComponent === undefined) {
BoundReactComponent = (...args: any[]) => {
if (observe) {
useObserve(this);
}
return ReactComponent.apply(this, args);
};
BoundReactComponent.displayName = this.describeComponentProperty(name);
Object.defineProperty(BoundReactComponent, '__isView', {value: true});
this.__boundReactComponents[name] = BoundReactComponent;
}
return BoundReactComponent;
}
};
};
}
/**
* A convenience decorator that combines the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) and [`@view()`](https://layrjs.com/docs/v2/reference/react-integration#view-decorator) decorators.
*
* Typically, you should use this decorator to implement the pages of your app.
*
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the route.
* @param [options] An object specifying the options to pass to the `Route`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the route is created.
*
* @examplelink See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* @category Decorators
* @decorator
*/
export function page(pattern: Pattern, options: ViewOption & RouteOptions = {}) {
return function (
target: typeof RoutableComponent | RoutableComponent,
name: string,
descriptor: PropertyDescriptor
) {
descriptor = view(options)(target, name, descriptor);
descriptor = route(pattern, options)(target, name, descriptor);
return descriptor;
};
}
/**
* A convenience decorator that combines the [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) and [`@view()`](https://layrjs.com/docs/v2/reference/react-integration#view-decorator) decorators.
*
* Typically, you should use this decorator to implement the layouts of your app.
*
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the wrapper.
* @param [options] An object specifying the options to pass to the `Wrapper`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the wrapper is created.
*
* @examplelink See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* @category Decorators
* @decorator
*/
export function layout(pattern: Pattern, options: ViewOption & WrapperOptions = {}) {
return function (
target: typeof RoutableComponent | RoutableComponent,
name: string,
descriptor: PropertyDescriptor
) {
descriptor = view(options)(target, name, descriptor);
descriptor = wrapper(pattern, options)(target, name, descriptor);
return descriptor;
};
}
================================================
FILE: packages/react-integration/src/hooks.ts
================================================
import {ObservableType, isObservable} from '@layr/observable';
import {useState, useEffect, useCallback, useRef, useMemo, DependencyList} from 'react';
import {AsyncFunction, getTypeOf} from 'core-helpers';
import {useCustomization, Customization} from './components';
/**
* A convenience hook for loading data asynchronously and rendering a React element using the loaded data.
*
* The `getter()` asynchronous function is called when the React component is rendered for the first time and when a change is detected in its `dependencies`.
*
* While the `getter()` function is running, the `useData()` hook returns the result of the nearest `dataPlaceholder()` function, which can be defined in any parent component thanks to the [`Customizer`](https://layrjs.com/docs/v2/reference/react-integration#customizer-react-component) component.
*
* Once the `getter()` function is executed, the `useData()` hook returns the result of the `renderer()` function which is called with the result of the `getter()` function as first parameter.
*
* If an error occurs during the `getter()` function execution, the `useData()` hook returns the result of the nearest `errorRenderer()` function, which can be defined in any parent component thanks to the [`Customizer`](https://layrjs.com/docs/v2/reference/react-integration#customizer-react-component) component.
*
* @param getter An asynchronous function for loading data.
* @param renderer A function which is called with the result of the `getter()` function as first parameter and a `refresh()` function as second parameter. You can call the `refresh()` function to force the re-execution of the `getter()` function. The `renderer()` function should return a React element (or `null`).
* @param [dependencies] An array of values on which the `getter()` function depends (default: `[]`).
* @param [options.dataPlaceholder] A custom `dataPlaceholder()` function.
* @param [options.errorRenderer] A custom `errorRenderer()` function.
*
* @returns A React element (or `null`).
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import React from 'react';
* import {view, useData} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
*
* ﹫view() static List() {
* return useData(
* async () => {
* // Return some articles from the backend
* },
*
* (articles) => {
* return articles.map((article) => (
* {article.title}
* ));
* }
* );
* }
* }
* ```
*
* @category High-Level Hooks
* @reacthook
*/
export function useData(
getter: () => Promise,
renderer: (data: Result, refresh: () => void) => JSX.Element | null,
deps: DependencyList = [],
options: {
dataPlaceholder?: Customization['dataPlaceholder'];
errorRenderer?: Customization['errorRenderer'];
} = {}
) {
const {dataPlaceholder, errorRenderer} = {...useCustomization(), ...options};
const [data, isExecuting, error, refresh] = useAsyncMemo(getter, deps);
if (isExecuting) {
return dataPlaceholder();
}
if (error !== undefined) {
return errorRenderer(error);
}
return renderer(data!, refresh);
}
/**
* A convenience hook for executing some asynchronous actions.
*
* The specified `handler()` asynchronous function is wrapped so that:
*
* - When running, the screen is locked to prevent the user from interacting with any UI element. You can customize the screen locking mechanism in any parent component thanks to the [Customizer's `actionWrapper()`](https://layrjs.com/docs/v2/reference/react-integration#customizer-react-component) prop.
* - In case an error is thrown, an error alert dialog is displayed. You can customize the error alert dialog in any parent component thanks to the [Customizer's `errorNotifier()`](https://layrjs.com/docs/v2/reference/react-integration#customizer-react-component) prop.
*
* @param handler An asynchronous function implementing the action.
* @param [dependencies] An array of values on which the `handler()` function depends (default: `[]`).
* @param [options.actionWrapper] A custom `actionWrapper()` function.
* @param [options.errorNotifier] A custom `errorNotifier()` function.
*
* @returns An asynchronous function wrapping the specified `handler()`.
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import React from 'react';
* import {view, useAction} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
*
* ﹫view() EditView() {
* const save = useAction(async () => {
* // Save the edited article to the backend
* });
*
* return (
*
* );
* }
* }
* ```
*
* @category High-Level Hooks
* @reacthook
*/
export function useAction(
handler: AsyncFunction,
deps: DependencyList = [],
options: {
actionWrapper?: Customization['actionWrapper'];
errorNotifier?: Customization['errorNotifier'];
} = {}
) {
const {actionWrapper, errorNotifier} = {...useCustomization(), ...options};
const action = useCallback(async (...args: Args) => {
try {
return (await actionWrapper(handler as (...args: any[]) => Promise, args)) as Result;
} catch (error: any) {
await errorNotifier(error);
throw error;
}
}, deps);
return action;
}
/**
* Makes a view dependent of an [observable](https://layrjs.com/docs/v2/reference/observable#observable-type) so the view is automatically re-rendered when the observable changes.
*
* @param observable An [observable](https://layrjs.com/docs/v2/reference/observable#observable-type) object.
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import {createObservable} from '﹫layr/observable';
* import React from 'react';
* import {view, useObserve} from '﹫layr/react-integration';
*
* const observableArray = createObservable([]);
*
* class MyComponent extends Component {
* ﹫view() static View() {
* useObserve(observableArray);
*
* return (
*
* {`observableArray's length: ${observableArray.length}`}
*
* );
* }
* }
*
* // Changing `observableArray` will re-render `MyComponent.View`
* observableArray.push('abc');
* ```
*
* @category High-Level Hooks
* @reacthook
*/
export function useObserve(observable: ObservableType) {
if (!isObservable(observable)) {
throw new Error(
`Expected an observable class or instance, but received a value of type '${getTypeOf(
observable
)}'`
);
}
const forceUpdate = useForceUpdate();
useEffect(
function () {
observable.addObserver(forceUpdate);
return function () {
observable.removeObserver(forceUpdate);
};
},
[observable]
);
}
/**
* Allows you to define an asynchronous callback and keep track of its execution.
*
* Plays the same role as the React built-in [`useCallback()`](https://reactjs.org/docs/hooks-reference.html#usecallback) hook but works with asynchronous callbacks.
*
* @param asyncCallback An asynchronous callback.
* @param [dependencies] An array of values on which the asynchronous callback depends (default: `[]`).
*
* @returns An array of the shape `[trackedCallback, isExecuting, error, result]` where `trackedCallback` is a function that you can call to execute the asynchronous callback, `isExecuting` is a boolean indicating whether the asynchronous callback is being executed, `error` is the error thrown by the asynchronous callback in case of failed execution, and `result` is the value returned by the asynchronous callback in case of succeeded execution.
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import React from 'react';
* import {view, useAsyncCallback} from '﹫layr/react-integration';
*
* class Article extends Component {
* ﹫view() UpvoteButton() {
* const [handleUpvote, isUpvoting, upvotingError] = useAsyncCallback(async () => {
* await this.upvote();
* });
*
* return (
*
* Upvote
* {upvotingError && ' An error occurred while upvoting the article.'}
*
* );
* }
* }
* ```
*
* @category Low-Level Hooks
* @reacthook
*/
export function useAsyncCallback(
asyncCallback: AsyncFunction,
deps: DependencyList = []
) {
const [state, setState] = useState<{isExecuting?: boolean; error?: any; result?: Result}>({});
const isMounted = useIsMounted();
const trackedCallback = useCallback(
async (...args: Args) => {
setState({isExecuting: true});
try {
const result = await asyncCallback(...args);
if (isMounted()) {
setState({result});
}
return result;
} catch (error) {
if (isMounted()) {
setState({error});
}
throw error;
}
},
[...deps]
);
return [trackedCallback, state.isExecuting === true, state.error, state.result] as const;
}
/**
* Memoizes the result of an asynchronous function execution and provides a "recompute function" that you can call to recompute the memoized result.
*
* The asynchronous function is executed one time when the React component is rendered for the first time, and each time a dependency is changed or the "recompute function" is called.
*
* Plays the same role as the React built-in [`useMemo()`](https://reactjs.org/docs/hooks-reference.html#usememo) hook but works with asynchronous functions and allows to recompute the memoized result.
*
* @param asyncFunc An asynchronous function to compute the memoized result.
* @param [dependencies] An array of values on which the memoized result depends (default: `[]`, which means that the memoized result will be recomputed only when the "recompute function" is called).
*
* @returns An array of the shape `[memoizedResult, isExecuting, error, recompute]` where `memoizedResult` is the result returned by the asynchronous function in case of succeeded execution, `isExecuting` is a boolean indicating whether the asynchronous function is being executed, `error` is the error thrown by the asynchronous function in case of failed execution, and `recompute` is a function that you can call to recompute the memoized result.
*
* @example
* ```
* import {Component} from '﹫layr/component';
* import React from 'react';
* import {view, useAsyncMemo} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
*
* ﹫view() static List() {
* const [articles, isLoading, loadingError, retryLoading] = useAsyncMemo(
* async () => {
* // Return some articles from the backend
* }
* );
*
* if (isLoading) {
* return Loading the articles...
;
* }
*
* if (loadingError) {
* return (
*
* An error occurred while loading the articles.
* Retry
*
* );
* }
*
* return articles.map((article) => (
* {article.title}
* ));
* }
* }
* ```
*
* @category Low-Level Hooks
* @reacthook
*/
export function useAsyncMemo(asyncFunc: () => Promise, deps: DependencyList = []) {
const [state, setState] = useState<{
result?: Result;
isExecuting?: boolean;
error?: any;
}>({isExecuting: true});
const [recomputeCount, setRecomputeCount] = useState(0);
const isMounted = useIsMounted();
useEffect(() => {
setState({isExecuting: true});
asyncFunc().then(
(result) => {
if (isMounted()) {
setState({result});
}
return result;
},
(error) => {
if (isMounted()) {
setState({error});
}
throw error;
}
);
}, [...deps, recomputeCount]);
const recompute = useCallback(() => {
setState({isExecuting: true});
setRecomputeCount((recomputeCount) => recomputeCount + 1);
}, []);
return [state.result, state.isExecuting === true, state.error, recompute] as const;
}
/**
* Memoizes the result of a function execution and provides a "recompute function" that you can call to recompute the memoized result.
*
* The function is executed one time when the React component is rendered for the first time, and each time a dependency is changed or the "recompute function" is called.
*
* Plays the same role as the React built-in [`useMemo()`](https://reactjs.org/docs/hooks-reference.html#usememo) hook but with the extra ability to recompute the memoized result.
*
* @param func A function to compute the memoized result.
* @param [dependencies] An array of values on which the memoized result depends (default: `[]`, which means that the memoized result will be recomputed only when the "recompute function" is called).
*
* @returns An array of the shape `[memoizedResult, recompute]` where `memoizedResult` is the result of the function execution, and `recompute` is a function that you can call to recompute the memoized result.
*
* @example
* ```
* import {Component, provide} from '﹫layr/component';
* import React, {useCallback} from 'react';
* import {view, useRecomputableMemo} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
* }
*
* class Blog extends Component {
* ﹫provide() static Article = Article;
*
* ﹫view() static CreateArticleView() {
* const [article, resetArticle] = useRecomputableMemo(() => new Article());
*
* const createArticle = useCallback(async () => {
* // Save the created article to the backend
* resetArticle();
* }, [article]);
*
* return (
*
* );
* }
* }
* ```
*
* @category Low-Level Hooks
* @reacthook
*/
export function useRecomputableMemo(func: () => Result, deps: DependencyList = []) {
const [recomputeCount, setRecomputeCount] = useState(0);
const result = useMemo(func, [...deps, recomputeCount]);
const recompute = useCallback(() => {
setRecomputeCount((recomputeCount) => recomputeCount + 1);
}, []);
return [result, recompute] as const;
}
/**
* Allows you to call an asynchronous function and keep track of its execution.
*
* The function is executed one time when the React component is rendered for the first time, and each time a dependency is changed or the "recall function" is called.
*
* @param asyncFunc The asynchronous function to call.
* @param [dependencies] An array of values on which the asynchronous function depends (default: `[]`, which means that the asynchronous will be recalled only when the "recall function" is called).
*
* @returns An array of the shape `[isExecuting, error, recall]` where `isExecuting` is a boolean indicating whether the asynchronous function is being executed, `error` is the error thrown by the asynchronous function in case of failed execution, and `recall` is a function that you can call to recall the asynchronous function.
*
* @example
* ```
* // JS
*
* import {Component, provide, attribute} from '﹫layr/component';
* import React from 'react';
* import {view, useAsyncCall} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
* }
*
* class Blog extends Component {
* ﹫provide() static Article = Article;
*
* ﹫attribute('Article[]?') static loadedArticles;
*
* ﹫view() static View() {
* const [isLoading, loadingError, retryLoading] = useAsyncCall(
* async () => {
* this.loadedArticles = await this.Article.find();
* }
* );
*
* if (isLoading) {
* return Loading the articles...
;
* }
*
* if (loadingError) {
* return (
*
* An error occurred while loading the articles.
* Retry
*
* );
* }
*
* return this.loadedArticles.map((article) => (
* {article.title}
* ));
* }
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component, provide, attribute} from '﹫layr/component';
* import React from 'react';
* import {view, useAsyncCall} from '﹫layr/react-integration';
*
* class Article extends Component {
* // ...
* }
*
* class Blog extends Component {
* ﹫provide() static Article = Article;
*
* ﹫attribute('Article[]?') static loadedArticles?: Article[];
*
* ﹫view() static View() {
* const [isLoading, loadingError, retryLoading] = useAsyncCall(
* async () => {
* this.loadedArticles = await this.Article.find();
* }
* );
*
* if (isLoading) {
* return Loading the articles...
;
* }
*
* if (loadingError) {
* return (
*
* An error occurred while loading the articles.
* Retry
*
* );
* }
*
* return this.loadedArticles!.map((article) => (
* {article.title}
* ));
* }
* }
* ```
*
* @category Low-Level Hooks
* @reacthook
*/
export function useAsyncCall(asyncFunc: () => Promise, deps: DependencyList = []) {
const [, isExecuting, error, recall] = useAsyncMemo(asyncFunc, deps);
return [isExecuting, error, recall] as const;
}
export function useIsMounted() {
const isMountedRef = useRef(false);
const isMounted = useCallback(() => {
return isMountedRef.current;
}, []);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return isMounted;
}
export function useForceUpdate() {
const [, setState] = useState({});
const isMounted = useIsMounted();
const forceUpdate = useCallback(() => {
if (isMounted()) {
setState({});
}
}, []);
return forceUpdate;
}
export function useDelay(duration = 100) {
const [isElapsed, setIsElapsed] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
setIsElapsed(true);
}, duration);
return () => {
clearTimeout(timeout);
};
}, []);
return [isElapsed] as const;
}
================================================
FILE: packages/react-integration/src/index.ts
================================================
/**
* @module react-integration
*
* Provides some React components, hooks, and decorators to simplify the use of [React](https://reactjs.org/) inside a Layr app.
*/
export * from './components';
export * from './decorators';
export * from './hooks';
export * from './plugins';
================================================
FILE: packages/react-integration/src/plugins.tsx
================================================
import {Navigator, normalizeURL} from '@layr/navigator';
import {BrowserNavigatorLinkProps} from '@layr/browser-navigator';
import React, {useMemo, useCallback, FunctionComponent} from 'react';
import {hasOwnProperty} from 'core-helpers';
export function BrowserNavigatorPlugin() {
return function (navigator: Navigator) {
navigator.addAddressableMethodWrapper(function (receiver, method, params) {
if (hasOwnProperty(method, '__isView')) {
return React.createElement(method as FunctionComponent, params);
} else {
return method.call(receiver, params);
}
});
navigator.addCustomRouteDecorator(function (method) {
method.Link = function ({params, hash, ...props}) {
const to = method.generateURL(params, {hash});
return navigator.Link({to, ...props});
};
});
Object.assign(navigator, {
Link(props: BrowserNavigatorLinkProps) {
const {to, className, activeClassName, style, activeStyle, ...otherProps} = props;
if ('onClick' in props) {
throw new Error(`The 'onClick' prop is not allowed in the 'Link' component`);
}
const handleClick = useCallback(
(event: React.MouseEvent) => {
if (!(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) {
event.preventDefault();
navigator.navigate(to, {defer: false});
}
},
[to]
);
const currentPath = navigator.getCurrentPath();
const linkPath = normalizeURL(to).pathname;
const isActive = linkPath === currentPath;
const {actualClassName, actualStyle} = useMemo(() => {
let actualClassName = className;
let actualStyle = style;
if (isActive) {
if (activeClassName !== undefined) {
const classes = actualClassName !== undefined ? [actualClassName] : [];
classes.push(activeClassName);
actualClassName = classes.join(' ');
}
if (activeStyle !== undefined) {
actualStyle = {...actualStyle, ...activeStyle};
}
}
return {actualClassName, actualStyle};
}, [isActive, className, activeClassName, style, activeStyle]);
return (
);
}
});
};
}
================================================
FILE: packages/react-integration/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/routable/.gitignore
================================================
.DS_STORE
node_modules
*.log
/dist
================================================
FILE: packages/routable/README.md
================================================
# @layr/routable
Makes the methods of your Layr components callable by URL.
## Installation
```
npm install @layr/routable
```
## License
MIT
================================================
FILE: packages/routable/package.json
================================================
{
"name": "@layr/routable",
"version": "2.0.113",
"description": "Makes the methods of your Layr components callable by URL",
"keywords": [
"layr",
"component",
"router",
"routable",
"route",
"method"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/routable",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"@layr/navigator": "^2.0.55",
"core-helpers": "^1.0.8",
"debug": "^4.3.4",
"lodash": "^4.17.21",
"possibly-async": "^1.0.7",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/debug": "^4.1.7",
"@types/http-errors": "^1.8.2",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191",
"http-errors": "^1.8.1"
}
}
================================================
FILE: packages/routable/src/addressable.ts
================================================
import {normalizeURL, parseQuery, stringifyQuery, URLOptions} from '@layr/navigator';
import {possiblyAsync} from 'possibly-async';
import isEmpty from 'lodash/isEmpty';
import {parsePattern, Pattern, PathMatcher, PathGenerator} from './pattern';
import {
serializeParam,
deserializeParam,
parseParamTypeSpecifier,
Params,
ParamTypeDescriptor
} from './param';
export type AddressableOptions = {
params?: Params;
aliases?: Pattern[];
filter?: Filter;
transformers?: Transformers;
};
export type Filter = (request: any) => boolean;
export type Transformers = {
input?: (params?: any, request?: any) => any;
output?: (result?: any, request?: any) => any;
error?: (error?: any, request?: any) => any;
};
/**
* An abstract class from which the classes [`Route`](https://layrjs.com/docs/v2/reference/route) and [`Wrapper`](https://layrjs.com/docs/v2/reference/wrapper) are constructed.
*
* An addressable is composed of:
*
* - A name matching a method of a [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
* - The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the addressable.
* - Some optional [URL parameters](https://layrjs.com/docs/v2/reference/addressable#url-parameters-type) associated with the addressable.
* - Some optional [URL pattern aliases](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) associated with the addressable.
*
* #### Usage
*
* Typically, you create a `Route` or a `Wrapper` and associate it to a routable component by using the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) or [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorators.
*
* See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*/
export abstract class Addressable {
_name: string;
_patterns: {
pattern: Pattern;
matcher: PathMatcher;
generator: PathGenerator;
wrapperGenerator: PathGenerator;
}[];
_isCatchAll: boolean;
_params: Record;
_filter: Filter | undefined;
_transformers: Transformers;
/**
* Creates an instance of [`Addressable`](https://layrjs.com/docs/v2/reference/addressable), which can represent a [`Route`](https://layrjs.com/docs/v2/reference/route) or a [`Wrapper`](https://layrjs.com/docs/v2/reference/wrapper).
*
* Typically, instead of using this constructor, you would rather use the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) or [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorators.
*
* @param name The name of the addressable.
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the addressable.
* @param [options.parameters] An optional object containing some [URL parameters](https://layrjs.com/docs/v2/reference/addressable#url-parameters-type).
* @param [options.aliases] An optional array containing some [URL pattern aliases](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type).
*
* @returns The [`Addressable`](https://layrjs.com/docs/v2/reference/addressable) instance that was created.
*
* @example
* ```
* const addressable = new Addressable('View', '/', {aliases: ['/home']});
* ```
*
* @category Creation
*/
constructor(name: string, pattern: Pattern, options: AddressableOptions = {}) {
const {params = {}, aliases = [], filter, transformers = {}} = options;
this._name = name;
this._params = Object.create(null);
for (const [name, typeSpecifier] of Object.entries(params)) {
this._params[name] = {
...parseParamTypeSpecifier(typeSpecifier),
specifier: typeSpecifier
};
}
this._patterns = [];
this._isCatchAll = false;
for (const patternOrAlias of [pattern, ...aliases]) {
const {matcher, generator, wrapperGenerator, isCatchAll} = parsePattern(patternOrAlias);
this._patterns.push({pattern: patternOrAlias, matcher, generator, wrapperGenerator});
if (isCatchAll) {
this._isCatchAll = true;
}
}
if (this._isCatchAll && this._patterns.length > 1) {
throw new Error(
`Couldn't create the addressable '${name}' (a catch-all addressable cannot have aliases)`
);
}
this._filter = filter;
this._transformers = transformers;
}
/**
* Returns the name of the addressable.
*
* @returns A string.
*
* @example
* ```
* const addressable = new Addressable('View', '/');
*
* addressable.getName(); // => 'View'
* ```
*
* @category Basic Methods
*/
getName() {
return this._name;
}
/**
* Returns the canonical URL pattern of the addressable.
*
* @returns An [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) string.
*
* @example
* ```
* const addressable = new Addressable('View', '/movies/:slug', {aliases: ['/films/:slug']});
*
* addressable.getPattern(); // => '/movies/:slug'
* ```
*
* @category Basic Methods
*/
getPattern() {
return this._patterns[0].pattern;
}
isCatchAll() {
return this._isCatchAll;
}
getParams() {
const params: Params = {};
for (const [name, descriptor] of Object.entries(this._params)) {
params[name] = descriptor.specifier;
}
return params;
}
/**
* Returns the URL pattern aliases of the addressable.
*
* @returns An array of [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) strings.
*
* @example
* ```
* const addressable = new Addressable('View', '/', {aliases: ['/home']});
*
* addressable.getAliases(); // => ['/home']
* ```
*
* @category Basic Methods
*/
getAliases() {
return this._patterns.slice(1).map(({pattern}) => pattern);
}
getFilter() {
return this._filter;
}
getTransformers() {
return this._transformers;
}
transformMethod(method: Function, request: any) {
const transformers = this._transformers;
if (isEmpty(transformers)) {
// OPTIMIZATION
return method;
}
return function (this: any, params: any) {
if (transformers.input !== undefined) {
params = transformers.input.call(this, params, request);
}
return possiblyAsync.invoke(
() => method.call(this, params),
(result) => {
if (transformers.output !== undefined) {
result = transformers.output.call(this, result, request);
}
return result;
},
(error) => {
if (transformers.error !== undefined) {
return transformers.error.call(this, error, request);
}
throw error;
}
);
};
}
/**
* Checks if the addressable matches the specified URL.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
*
* @returns If the addressable matches the specified URL, a plain object containing the identifiers and parameters included in the URL is returned. Otherwise, `undefined` is returned.
*
* @example
* ```
* const addressable = new Addressable('View', '/movies/:slug', {
* params: {showDetails: 'boolean?'}
* });
*
* addressable.matchURL('/movies/abc123');
* // => {identifiers: {slug: 'abc123'}, parameters: {showDetails: undefined}}
*
* addressable.matchURL('/movies/abc123?showDetails=1');
* // => {identifiers: {slug: 'abc123'}, parameters: {showDetails: true}}
*
* addressable.matchURL('/films'); // => undefined
* ```
*
* @category URL Matching and Generation
*/
matchURL(url: URL | string, request?: any) {
const {pathname: path, search: queryString} = normalizeURL(url);
const result = this.matchPath(path, request);
if (result !== undefined) {
const query: Record = parseQuery(queryString);
const params: Record = {};
for (const [name, descriptor] of Object.entries(this._params)) {
const queryValue = query[name];
const paramValue = deserializeParam(name, queryValue, descriptor);
params[name] = paramValue;
}
return {params, ...result};
}
return undefined;
}
matchPath(path: string, request?: any) {
for (const {matcher, wrapperGenerator} of this._patterns) {
const identifiers = matcher(path);
if (identifiers === undefined) {
continue;
}
if (this._filter !== undefined && !this._filter(request)) {
continue;
}
const wrapperPath = wrapperGenerator(identifiers);
return {identifiers, wrapperPath};
}
return undefined;
}
/**
* Generates an URL for the addressable.
*
* @param [identifiers] An optional object containing the identifiers to include in the generated URL.
* @param [params] An optional object containing the parameters to include in the generated URL.
* @param [options.hash] An optional string specifying a hash (i.e., a [fragment identifier](https://en.wikipedia.org/wiki/URI_fragment)) to include in the generated URL.
*
* @returns A string.
*
* @example
* ```
* const addressable = new Addressable('View', '/movies/:slug', {
* params: {showDetails: 'boolean?'}
* });
*
* addressable.generateURL({slug: 'abc123'}); // => '/movies/abc123'
*
* addressable.generateURL({slug: 'abc123'}, {showDetails: true});
* // => '/movies/abc123?showDetails=1'
*
* addressable.generateURL({slug: 'abc123'}, {showDetails: true}, {hash: 'actors'});
* // => '/movies/abc123?showDetails=1#actors'
*
* addressable.generateURL({}); // => Error (the slug parameter is mandatory)
* ```
*
* @category URL Matching and Generation
*/
generateURL(
identifiers?: Record,
params?: Record,
options?: URLOptions
) {
let url = this.generatePath(identifiers);
const queryString = this.generateQueryString(params);
if (queryString !== '') {
url += `?${queryString}`;
}
if (options?.hash) {
url += `#${options.hash}`;
}
return url;
}
generatePath(identifiers?: Record) {
return this._patterns[0].generator(identifiers);
}
generateQueryString(params: Record = {}) {
const query: Record = {};
for (const [name, descriptor] of Object.entries(this._params)) {
const paramValue = params[name];
const queryValue = serializeParam(name, paramValue, descriptor);
query[name] = queryValue;
}
return stringifyQuery(query);
}
/**
* @typedef URLPattern
*
* A string defining the canonical URL pattern (or an URL pattern alias) of an addressable.
*
* An URL pattern is composed of a *route pattern* (e.g., `'/movies'`) and can be prefixed with a *wrapper pattern*, which should be enclosed with square brackets (e.g., `'[/admin]'`).
*
* *Route patterns* and *wrapper patterns* can be composed of several *segments* separated by slashes (e.g., `'/movies/top-50'` or `'[/admin/movies]'`).
*
* A *segment* can be an arbitrary string (e.g., `'movies'`) or the name of a [component identifier attribute](https://layrjs.com/docs/v2/reference/identifier-attribute) (e.g., `'id'`) prefixed with a colon sign (`':'`). Note that a component identifier attribute can reference an identifier attribute of a related component (e.g., `'collection.id'`).
*
* Optionally, an URL pattern can be suffixed with wildcard character (`'*'`) to represent a catch-all URL.
*
* **Examples:**
*
* - `'/'`: Root URL pattern.
* - `'/movies'`: URL pattern without identifier attributes.
* - `'/movies/:id'`: URL pattern with one identifier attribute (`id`).
* - `'/collections/:collection.id/movies/:id'`: URL pattern with two identifier attributes (`collection.id` and `id`).
* - `[/]movies`: URL pattern composed of a wrapper pattern (`'[/]'`) and a route pattern (`'movies'`).
* - `'[/collections/:collection.id]/movies/:id'`: URL pattern composed of a wrapper pattern (`'[/collections/:collection.id]'`), a route pattern (`'/movies/:id'`), and two identifier attributes (`collection.id` and `id`).
* - `'/*'`: URL pattern that can match any URL. It can be helpful to display, for example, a "Not Found" page.
*
* @category Types
*/
/**
* @typedef URLParameters
*
* An object defining the URL parameters of an addressable.
*
* The object can contain some pairs of `name` and `type` where `name` should be an arbitrary string representing the name of an URL parameter and `type` should be a string representing its type.
*
* Currently, `type` can be one of the following strings:
*
* - `'boolean'`
* - `'number'`
* - `'string'`
* - `'Date'`
*
* Optionally, `type` can be suffixed with a question mark (`'?'`) to specify an optional URL parameter.
*
* **Examples:**
*
* - `{step: 'number'}
* - `{showDetails: 'boolean?'}`
* - `{page: 'number?', orderBy: 'string?'}`
*
* @category Types
*/
static isAddressable(value: any): value is Addressable {
return isAddressableInstance(value);
}
}
/**
* Returns whether the specified value is an [`Addressable`](https://layrjs.com/docs/v2/reference/addressable) class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isAddressableClass(value: any): value is typeof Addressable {
return typeof value?.isAddressable === 'function';
}
/**
* Returns whether the specified value is an [`Addressable`](https://layrjs.com/docs/v2/reference/addressable) instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isAddressableInstance(value: any): value is Addressable {
return typeof value?.constructor?.isAddressable === 'function';
}
================================================
FILE: packages/routable/src/decorators.test.ts
================================================
import {Component, provide, primaryIdentifier, attribute} from '@layr/component';
import createError from 'http-errors';
import {Routable, callRouteByURL} from './routable';
import {route, wrapper, httpRoute} from './decorators';
import {isRouteInstance} from './route';
import {isWrapperInstance} from './wrapper';
describe('Decorators', () => {
test('@route()', async () => {
class Studio extends Component {
@primaryIdentifier() id!: string;
}
class Movie extends Routable(Component) {
@provide() static Studio = Studio;
@primaryIdentifier() id!: string;
@attribute('Studio') studio!: Studio;
@route('/movies', {aliases: ['/films']}) static ListPage() {
return `All movies`;
}
// Use a getter to simulate the view() decorator
@route('/movies/:id', {aliases: ['/films/:id']}) get ItemPage() {
return function (this: Movie) {
return `Movie #${this.id}`;
};
}
// Use a getter to simulate the view() decorator
@route('/studios/:studio.id/movies/:id') get ItemWithStudioPage() {
return function (this: Movie) {
return `Movie #${this.id}`;
};
}
}
// --- Class routes ---
const listPageRoute = Movie.getRoute('ListPage');
expect(isRouteInstance(listPageRoute)).toBe(true);
expect(listPageRoute.getName()).toBe('ListPage');
expect(listPageRoute.getPattern()).toBe('/movies');
expect(listPageRoute.getAliases()).toEqual(['/films']);
expect(listPageRoute.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(listPageRoute.matchURL('/films')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(listPageRoute.generateURL()).toBe('/movies');
expect(Movie.ListPage.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.ListPage.matchURL('/films')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.ListPage.generateURL()).toBe('/movies');
// --- Prototype routes ---
const itemPageRoute = Movie.prototype.getRoute('ItemPage');
expect(isRouteInstance(itemPageRoute)).toBe(true);
expect(itemPageRoute.getName()).toBe('ItemPage');
expect(itemPageRoute.getPattern()).toBe('/movies/:id');
expect(itemPageRoute.getAliases()).toEqual(['/films/:id']);
expect(itemPageRoute.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(itemPageRoute.matchURL('/films/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(itemPageRoute.generateURL({id: 'abc123'})).toBe('/movies/abc123');
expect(Movie.prototype.ItemPage.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(Movie.prototype.ItemPage.matchURL('/films/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
const itemWithStudioPageRoute = Movie.prototype.getRoute('ItemWithStudioPage');
expect(itemWithStudioPageRoute.getName()).toBe('ItemWithStudioPage');
expect(itemWithStudioPageRoute.getPattern()).toBe('/studios/:studio.id/movies/:id');
expect(itemWithStudioPageRoute.getAliases()).toEqual([]);
expect(itemWithStudioPageRoute.matchURL('/studios/abc/movies/123')).toStrictEqual({
identifiers: {id: '123', studio: {id: 'abc'}},
params: {},
wrapperPath: undefined
});
expect(itemWithStudioPageRoute.generateURL({id: '123', studio: {id: 'abc'}})).toBe(
'/studios/abc/movies/123'
);
expect(Movie.prototype.ItemWithStudioPage.matchURL('/studios/abc/movies/123')).toStrictEqual({
identifiers: {id: '123', studio: {id: 'abc'}},
params: {},
wrapperPath: undefined
});
// --- Instance routes ---
const studio = new Studio({id: 'abc'});
const movie = new Movie({id: '123', studio});
expect(movie.ItemPage.generateURL()).toBe('/movies/123');
expect(movie.ItemWithStudioPage.generateURL()).toBe('/studios/abc/movies/123');
});
test('@wrapper()', async () => {
class Movie extends Routable(Component) {
@wrapper('/movies') static MainLayout() {}
@wrapper('[/movies]/:id') ItemLayout() {}
}
// --- Class wrappers ---
const mainLayoutWrapper = Movie.getWrapper('MainLayout');
expect(isWrapperInstance(mainLayoutWrapper)).toBe(true);
expect(mainLayoutWrapper.getName()).toBe('MainLayout');
expect(mainLayoutWrapper.getPattern()).toBe('/movies');
// --- Prototype wrappers ---
const itemLayoutWrapper = Movie.prototype.getWrapper('ItemLayout');
expect(isWrapperInstance(itemLayoutWrapper)).toBe(true);
expect(itemLayoutWrapper.getName()).toBe('ItemLayout');
expect(itemLayoutWrapper.getPattern()).toBe('[/movies]/:id');
});
test('@httpRoute()', async () => {
class Movie extends Routable(Component) {
@primaryIdentifier() id!: string;
@httpRoute('GET', '/movies/:id') async getMovie() {
if (this.id === 'abc123') {
return {id: 'abc123', title: 'Inception'};
}
throw createError(404, 'Movie not found', {
displayMessage: "Couldn't find a movie with the specified 'id'",
code: 'ITEM_NOT_FOUND'
});
}
@httpRoute('*', '/*') static routeNotFound() {
throw createError(404, 'Route not found');
}
}
expect(await callRouteByURL(Movie, '/movies/abc123', {method: 'GET'})).toStrictEqual({
status: 200,
headers: {'content-type': 'application/json'},
body: JSON.stringify({id: 'abc123', title: 'Inception'})
});
expect(await callRouteByURL(Movie, '/movies/def456', {method: 'GET'})).toStrictEqual({
status: 404,
headers: {'content-type': 'application/json'},
body: JSON.stringify({
message: 'Movie not found',
displayMessage: "Couldn't find a movie with the specified 'id'",
code: 'ITEM_NOT_FOUND'
})
});
expect(await callRouteByURL(Movie, '/films', {method: 'GET'})).toStrictEqual({
status: 404,
headers: {'content-type': 'application/json'},
body: JSON.stringify({
message: 'Route not found'
})
});
expect(() => {
// @ts-ignore
class Movie extends Routable(Component) {
// @ts-expect-error
@httpRoute('TRACE', '/trace') static trace() {}
}
}).toThrow(
"The HTTP method 'TRACE' is not supported by the @httpRoute() decorator (supported values are: 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', or '*')"
);
});
});
================================================
FILE: packages/routable/src/decorators.ts
================================================
import {Navigator, URLOptions, NavigationOptions} from '@layr/navigator';
import {hasOwnProperty} from 'core-helpers';
import type {RoutableComponent} from './routable';
import type {Route, RouteOptions} from './route';
import type {WrapperOptions} from './wrapper';
import type {Pattern} from './pattern';
import {isRoutableClassOrInstance} from './utilities';
/**
* Defines a [route](https://layrjs.com/docs/v2/reference/route) for a static or instance method in a [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
*
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the route.
* @param [options] An object specifying the options to pass to the `Route`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the route is created.
*
* @details
* **Shortcut functions:**
*
* In addition to defining a route, the decorator adds some shortcut functions to the decorated method so that you can interact with the route more easily.
*
* For example, if you define a `route` for a `View()` method you automatically get the following functions:
*
* - `View.matchURL(url)` is the equivalent of [`route.matchURL(url)`](https://layrjs.com/docs/v2/reference/addressable#match-url-instance-method).
* - `View.generateURL([params], [options])` is the equivalent of [`route.generateURL(component, [params], [options])`](https://layrjs.com/docs/v2/reference/addressable#generate-url-instance-method) where `component` is the [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class) associated with the `View()` method.
*
* If the defined `route` is controlled by a [`navigator`](https://layrjs.com/docs/v2/reference/navigator), you also get the following shortcut functions:
*
* - `View.navigate([params], [options])` is the equivalent of [`navigator.navigate(url, options)`](https://layrjs.com/docs/v2/reference/navigator#navigate-instance-method) where `url` is generated by calling `View.generateURL([params], [options])`.
* - `View.redirect([params], [options])` is the equivalent of [`navigator.redirect(url, options)`](https://layrjs.com/docs/v2/reference/navigator#redirect-instance-method) where `url` is generated by calling `View.generateURL([params], [options])`.
* - `View.reload([params], [options])` is the equivalent of [`navigator.reload(url)`](https://layrjs.com/docs/v2/reference/navigator#reload-instance-method) where `url` is generated by calling `View.generateURL([params], [options])`.
* - `View.isActive()` returns a boolean indicating whether the `route`'s URL (generated by calling `View.generateURL()`) matches the current `navigator`'s URL.
*
* Lastly, if the defined `route` is controlled by a [`navigator`](https://layrjs.com/docs/v2/reference/navigator) that is created by using the [`useBrowserNavigator()`](https://layrjs.com/docs/v2/reference/react-integration#use-browser-navigator-react-hook) React hook, you also get the following shortcut React component:
*
* - `View.Link({params, hash, ...props})` is the equivalent of [`navigator.Link({to, ...props})`](https://layrjs.com/docs/v2/reference/browser-navigator#link-instance-method) where `to` is generated by calling `View.generateURL(params, {hash})`.
*
* @examplelink See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* @category Decorators
* @decorator
*/
export function route(pattern: Pattern, options: RouteOptions = {}) {
return function (
target: typeof RoutableComponent | RoutableComponent,
name: string,
descriptor: PropertyDescriptor
) {
const {value: method, get: originalGet, configurable, enumerable} = descriptor;
if (
!(
isRoutableClassOrInstance(target) &&
(typeof method === 'function' || originalGet !== undefined) &&
enumerable === false
)
) {
throw new Error(
`@route() should be used to decorate a routable component method (property: '${name}')`
);
}
const route: Route = target.setRoute(name, pattern, options);
const decorate = function (
this: typeof RoutableComponent | RoutableComponent,
method: Function
) {
const component = this;
defineMethod(method, 'matchURL', function (url: URL | string) {
return route.matchURL(url);
});
defineMethod(method, 'generateURL', function (params?: any, options?: URLOptions) {
return route.generateURL(component, params, options);
});
defineMethod(method, 'generatePath', function () {
return route.generatePath(component);
});
defineMethod(method, 'generateQueryString', function (params?: any) {
return route.generateQueryString(params);
});
Object.defineProperty(method, '__isDecorated', {value: true});
};
const decorateWithNavigator = function (
this: typeof RoutableComponent | RoutableComponent,
method: Function,
navigator: Navigator
) {
defineMethod(
method,
'navigate',
function (this: Function, params?: any, options?: URLOptions & NavigationOptions) {
return navigator.navigate(this.generateURL(params, options), options);
}
);
defineMethod(
method,
'redirect',
function (this: Function, params?: any, options?: URLOptions & NavigationOptions) {
return navigator.redirect(this.generateURL(params, options), options);
}
);
defineMethod(method, 'reload', function (this: Function, params?: any, options?: URLOptions) {
navigator.reload(this.generateURL(params, options));
});
defineMethod(method, 'isActive', function (this: Function) {
return this.matchURL(navigator.getCurrentURL()) !== undefined;
});
navigator.applyCustomRouteDecorators(this, method);
Object.defineProperty(method, '__isDecoratedWithNavigator', {value: true});
};
const defineMethod = function (object: any, name: string, func: Function) {
Object.defineProperty(object, name, {
value: func,
writable: true,
enumerable: false,
configurable: true
});
};
const get = function (this: typeof RoutableComponent | RoutableComponent) {
// TODO: Don't assume that `originalGet` returns a bound method (like when @view() is used).
// We should return a bound method in any case to make instance routes work
// (see `decorator.test.ts`)
const actualMethod = originalGet !== undefined ? originalGet.call(this) : method;
if (typeof actualMethod !== 'function') {
throw new Error(`@route() can only be used on methods`);
}
if (!hasOwnProperty(actualMethod, '__isDecorated')) {
decorate.call(this, actualMethod);
}
if (!hasOwnProperty(actualMethod, '__isDecoratedWithNavigator')) {
const navigator = this.findNavigator();
if (navigator !== undefined) {
decorateWithNavigator.call(this, actualMethod, navigator);
}
}
return actualMethod;
};
return {get, configurable, enumerable};
};
}
/**
* Defines a [wrapper](https://layrjs.com/docs/v2/reference/wrapper) for a static or instance method in a [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
*
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the wrapper.
* @param [options] An object specifying the options to pass to the `Wrapper`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the wrapper is created.
*
* @examplelink See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* @category Decorators
* @decorator
*/
export function wrapper(pattern: Pattern, options: WrapperOptions = {}) {
return function (
target: typeof RoutableComponent | RoutableComponent,
name: string,
descriptor: PropertyDescriptor
) {
const {value: method, get, enumerable} = descriptor;
if (
!(
isRoutableClassOrInstance(target) &&
(typeof method === 'function' || get !== undefined) &&
enumerable === false
)
) {
throw new Error(
`@wrapper() should be used to decorate a routable component method (property: '${name}')`
);
}
target.setWrapper(name, pattern, options);
return descriptor;
};
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
const ANY_HTTP_METHOD = '*';
type HTTPMethod = typeof HTTP_METHODS[number];
type AnyHTTPMethod = typeof ANY_HTTP_METHOD;
export function basicHTTPRouteInputTransformer(params: any, _request: any) {
return params;
}
export function basicHTTPRouteOutputTransformer(result: any, request: any) {
let response: {status?: number; headers?: Record; body?: string | Buffer} = {};
if (result === undefined) {
response.status = 204; // No Content
} else {
if (request?.method === 'POST') {
response.status = 201; // Created
} else {
response.status = 200; // OK
}
if (Buffer.isBuffer(result)) {
response.headers = {'content-type': 'application/octet-stream'};
response.body = result;
} else {
response.headers = {'content-type': 'application/json'};
response.body = JSON.stringify(result);
}
}
return response;
}
export function basicHTTPRouteErrorTransformer(error: any, _request: any) {
const response: {status: number; headers: Record; body: string} = {
status: 500,
headers: {'content-type': 'application/json'},
body: JSON.stringify({
message: 'Internal server error'
})
};
if (typeof error !== 'object' || error === null) {
return response;
}
if (typeof error.status === 'number') {
response.status = error.status;
}
const expose: boolean = typeof error.expose === 'boolean' ? error.expose : response.status < 500;
if (expose) {
const body: Record = {};
body.message = typeof error.message === 'string' ? error.message : 'Unknown error';
for (const [key, value] of Object.entries(error)) {
if (key === 'status' || key === 'message' || value === undefined) {
continue;
}
body[key] = value;
}
response.body = JSON.stringify(body);
}
return response;
}
export function httpRoute(
httpMethod: HTTPMethod | AnyHTTPMethod,
pattern: Pattern,
options: RouteOptions = {}
) {
if (!(httpMethod === ANY_HTTP_METHOD || HTTP_METHODS.includes(httpMethod))) {
throw new Error(
`The HTTP method '${httpMethod}' is not supported by the @httpRoute() decorator (supported values are: ${HTTP_METHODS.map(
(method) => `'${method}'`
).join(', ')}, or '${ANY_HTTP_METHOD}')`
);
}
const {
filter = function (request) {
return httpMethod === '*'
? HTTP_METHODS.includes(request?.method)
: request?.method === httpMethod;
},
transformers = {},
...otherOptions
} = options;
const {
input = basicHTTPRouteInputTransformer,
output = basicHTTPRouteOutputTransformer,
error = basicHTTPRouteErrorTransformer
} = transformers;
return function (
target: typeof RoutableComponent | RoutableComponent,
name: string,
descriptor: PropertyDescriptor
) {
return route(pattern, {filter, transformers: {input, output, error}, ...otherOptions})(
target,
name,
descriptor
);
};
}
================================================
FILE: packages/routable/src/index.ts
================================================
export * from './addressable';
export * from './decorators';
export * from './param';
export * from './pattern';
export * from './routable';
export * from './route';
export * from './utilities';
export * from './wrapper';
================================================
FILE: packages/routable/src/js-tests/decorators.test.js
================================================
import {Component, provide, primaryIdentifier, attribute} from '@layr/component';
import createError from 'http-errors';
import {Routable, callRouteByURL} from '../routable';
import {route, wrapper, httpRoute} from '../decorators';
import {isRouteInstance} from '../route';
import {isWrapperInstance} from '../wrapper';
describe('Decorators', () => {
test('@route()', async () => {
class Studio extends Component {
@primaryIdentifier() id;
}
class Movie extends Routable(Component) {
@provide() static Studio = Studio;
@primaryIdentifier() id;
@attribute('Studio') studio;
@route('/movies', {aliases: ['/films']}) static ListPage() {
return `All movies`;
}
// Use a getter to simulate the view() decorator
@route('/movies/:id', {aliases: ['/films/:id']}) get ItemPage() {
return function () {
return `Movie #${this.id}`;
};
}
// Use a getter to simulate the view() decorator
@route('/studios/:studio.id/movies/:id') get ItemWithStudioPage() {
return function () {
return `Movie #${this.id}`;
};
}
}
// --- Class routes ---
const listPageRoute = Movie.getRoute('ListPage');
expect(isRouteInstance(listPageRoute)).toBe(true);
expect(listPageRoute.getName()).toBe('ListPage');
expect(listPageRoute.getPattern()).toBe('/movies');
expect(listPageRoute.getAliases()).toEqual(['/films']);
expect(listPageRoute.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(listPageRoute.matchURL('/films')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(listPageRoute.generateURL()).toBe('/movies');
expect(Movie.ListPage.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.ListPage.matchURL('/films')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.ListPage.generateURL()).toBe('/movies');
// --- Prototype routes ---
const itemPageRoute = Movie.prototype.getRoute('ItemPage');
expect(isRouteInstance(itemPageRoute)).toBe(true);
expect(itemPageRoute.getName()).toBe('ItemPage');
expect(itemPageRoute.getPattern()).toBe('/movies/:id');
expect(itemPageRoute.getAliases()).toEqual(['/films/:id']);
expect(itemPageRoute.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(itemPageRoute.matchURL('/films/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(itemPageRoute.generateURL({id: 'abc123'})).toBe('/movies/abc123');
expect(Movie.prototype.ItemPage.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(Movie.prototype.ItemPage.matchURL('/films/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
const itemWithStudioPageRoute = Movie.prototype.getRoute('ItemWithStudioPage');
expect(itemWithStudioPageRoute.getName()).toBe('ItemWithStudioPage');
expect(itemWithStudioPageRoute.getPattern()).toBe('/studios/:studio.id/movies/:id');
expect(itemWithStudioPageRoute.getAliases()).toEqual([]);
expect(itemWithStudioPageRoute.matchURL('/studios/abc/movies/123')).toStrictEqual({
identifiers: {id: '123', studio: {id: 'abc'}},
params: {},
wrapperPath: undefined
});
expect(itemWithStudioPageRoute.generateURL({id: '123', studio: {id: 'abc'}})).toBe(
'/studios/abc/movies/123'
);
expect(Movie.prototype.ItemWithStudioPage.matchURL('/studios/abc/movies/123')).toStrictEqual({
identifiers: {id: '123', studio: {id: 'abc'}},
params: {},
wrapperPath: undefined
});
// --- Instance routes ---
const studio = new Studio({id: 'abc'});
const movie = new Movie({id: '123', studio});
expect(movie.ItemPage.generateURL()).toBe('/movies/123');
expect(movie.ItemWithStudioPage.generateURL()).toBe('/studios/abc/movies/123');
});
test('@wrapper()', async () => {
class Movie extends Routable(Component) {
@wrapper('/movies') static MainLayout() {}
@wrapper('[/movies]/:id') ItemLayout() {}
}
// --- Class wrappers ---
const mainLayoutWrapper = Movie.getWrapper('MainLayout');
expect(isWrapperInstance(mainLayoutWrapper)).toBe(true);
expect(mainLayoutWrapper.getName()).toBe('MainLayout');
expect(mainLayoutWrapper.getPattern()).toBe('/movies');
// --- Prototype wrappers ---
const itemLayoutWrapper = Movie.prototype.getWrapper('ItemLayout');
expect(isWrapperInstance(itemLayoutWrapper)).toBe(true);
expect(itemLayoutWrapper.getName()).toBe('ItemLayout');
expect(itemLayoutWrapper.getPattern()).toBe('[/movies]/:id');
});
test('@httpRoute()', async () => {
class Movie extends Routable(Component) {
@primaryIdentifier() id;
@httpRoute('GET', '/movies/:id') async getMovie() {
if (this.id === 'abc123') {
return {id: 'abc123', title: 'Inception'};
}
throw createError(404, 'Movie not found', {
displayMessage: "Couldn't find a movie with the specified 'id'",
code: 'ITEM_NOT_FOUND'
});
}
@httpRoute('*', '/*') static routeNotFound() {
throw createError(404, 'Route not found');
}
}
expect(await callRouteByURL(Movie, '/movies/abc123', {method: 'GET'})).toStrictEqual({
status: 200,
headers: {'content-type': 'application/json'},
body: JSON.stringify({id: 'abc123', title: 'Inception'})
});
expect(await callRouteByURL(Movie, '/movies/def456', {method: 'GET'})).toStrictEqual({
status: 404,
headers: {'content-type': 'application/json'},
body: JSON.stringify({
message: 'Movie not found',
displayMessage: "Couldn't find a movie with the specified 'id'",
code: 'ITEM_NOT_FOUND'
})
});
expect(await callRouteByURL(Movie, '/films', {method: 'GET'})).toStrictEqual({
status: 404,
headers: {'content-type': 'application/json'},
body: JSON.stringify({
message: 'Route not found'
})
});
expect(() => {
class Movie extends Routable(Component) {
// @ts-expect-error
@httpRoute('TRACE', '/trace') static trace() {}
}
}).toThrow(
"The HTTP method 'TRACE' is not supported by the @httpRoute() decorator (supported values are: 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', or '*')"
);
});
});
================================================
FILE: packages/routable/src/js-tests/jsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2017",
"module": "CommonJS",
"checkJs": true,
"experimentalDecorators": true
}
}
================================================
FILE: packages/routable/src/param.ts
================================================
import {getTypeOf} from 'core-helpers';
export type Params = Record;
const PARAM_TYPE = ['boolean', 'number', 'string', 'Date'] as const;
export type ParamType = typeof PARAM_TYPE[number];
export type TypeSpecifier = `${ParamType}${'' | '?'}`;
export type ParamTypeDescriptor = {
type: ParamType;
isOptional: boolean;
specifier: TypeSpecifier;
};
export function serializeParam(name: string, value: any, typeDescriptor: ParamTypeDescriptor) {
const {type, isOptional, specifier} = typeDescriptor;
if (value === undefined) {
if (isOptional) {
return undefined;
}
throw new Error(
`A required route (or wrapper) parameter is missing (name: '${name}', type: '${specifier}')`
);
}
if (type === 'boolean') {
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
}
if (type === 'number') {
if (typeof value === 'number' && !isNaN(value)) {
return String(value);
}
}
if (type === 'string') {
if (typeof value === 'string') {
return value;
}
}
if (type === 'Date') {
if (value instanceof Date && !isNaN(value.valueOf())) {
return value.toISOString();
}
}
throw new Error(
`Couldn't serialize a route (or wrapper) parameter (name: '${name}', value: '${value}', expected type: '${specifier}', received type: '${getTypeOf(
value
)}')`
);
}
export function deserializeParam(
name: string,
value: string | undefined,
typeDescriptor: ParamTypeDescriptor
) {
const {type, isOptional, specifier} = typeDescriptor;
if (value === undefined) {
if (isOptional) {
return undefined;
}
throw new Error(
`A required route (or wrapper) parameter is missing (name: '${name}', type: '${specifier}')`
);
}
if (type === 'boolean') {
if (value === '0') {
return false;
}
if (value === '1') {
return true;
}
}
if (type === 'number') {
const result = Number(value);
if (!isNaN(result)) {
return result;
}
}
if (type === 'string') {
return value;
}
if (type === 'Date') {
const result = new Date(value);
if (!isNaN(result.valueOf())) {
return result;
}
}
throw new Error(
`Couldn't deserialize a route (or wrapper) parameter (name: '${name}', value: '${value}', type: '${specifier}')`
);
}
export function parseParamTypeSpecifier(typeSpecifier: TypeSpecifier) {
let type: ParamType;
let isOptional: boolean;
if (typeof typeSpecifier !== 'string') {
throw new Error(
`Couldn't parse a route (or wrapper) parameter type (expected a string, but received a value of type '${getTypeOf(
typeSpecifier
)}')`
);
}
if (typeSpecifier.endsWith('?')) {
type = typeSpecifier.slice(0, -1) as ParamType;
isOptional = true;
} else {
type = typeSpecifier as ParamType;
isOptional = false;
}
if (type.length === 0) {
throw new Error(
"Couldn't parse a route (or wrapper) parameter type (received an empty string)"
);
}
if (!PARAM_TYPE.includes(type)) {
throw new Error(
`Couldn't parse a route (or wrapper) parameter type ('${type}' is not a supported type)`
);
}
return {type, isOptional};
}
================================================
FILE: packages/routable/src/pattern.ts
================================================
import get from 'lodash/get';
import set from 'lodash/set';
import escapeRegExp from 'lodash/escapeRegExp';
export type Pattern = string;
export type PathMatcher = (path: string) => Record | undefined;
export type PathGenerator = (identifiers?: Record) => string | undefined;
const PATH_CHARACTER_REGEXP = /[A-Za-z0-9.\-_@/]/;
const IDENTIFIER_NAME_CHARACTER_REGEXP = /[A-Za-z0-9.]/;
const IDENTIFIER_VALUE_GROUP_PATTERN = '([^/]+)';
export function parsePattern(pattern: Pattern) {
const {wrapperParts, regularParts, isCatchAll} = splitPattern(pattern);
const allParts = [...wrapperParts, ...regularParts];
const regExpParts: string[] = [];
const identifierNames: string[] = [];
for (const part of allParts) {
if (part.length === 0) {
continue;
}
if (part.startsWith(':')) {
const name = part.slice(1);
identifierNames.push(name);
regExpParts.push(IDENTIFIER_VALUE_GROUP_PATTERN);
} else {
regExpParts.push(escapeRegExp(part));
}
}
if (isCatchAll) {
regExpParts.push('.*');
}
const regExp = new RegExp('^' + regExpParts.join('') + '$');
const matcher: PathMatcher = function (path) {
const matches = path.match(regExp);
if (matches === null) {
return undefined;
}
const identifiers: Record = {};
for (let index = 0; index < identifierNames.length; index++) {
const name = identifierNames[index];
const value = decodeURIComponent(matches[index + 1]);
set(identifiers, name, value);
}
return identifiers;
};
const generate = function (parts: string[], identifiers = {}) {
if (parts.length === 0) {
return undefined;
}
let path = '';
for (const part of parts) {
if (part.startsWith(':')) {
const name = part.slice(1);
const value = get(identifiers, name);
if (value === undefined || value === '') {
throw new Error(
`Couldn't build a route (or wrapper) path from the pattern '${pattern}' because the identifier '${name}' is missing`
);
}
path += encodeURIComponent(value);
} else {
path += part;
}
}
return path;
};
const generator: PathGenerator = function (identifiers) {
return generate(allParts, identifiers);
};
const wrapperGenerator: PathGenerator = function (identifiers) {
return generate(wrapperParts, identifiers);
};
return {matcher, generator, wrapperGenerator, isCatchAll};
}
function splitPattern(pattern: Pattern) {
let normalizedPattern = pattern.trim();
let isCatchAll: boolean;
const wildcardIndex = normalizedPattern.indexOf('*');
if (wildcardIndex !== -1) {
if (wildcardIndex !== normalizedPattern.length - 1) {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (a wildcard character '*' is only allowed at the end of the pattern)`
);
}
normalizedPattern = normalizedPattern.slice(0, -1);
isCatchAll = true;
} else {
isCatchAll = false;
}
const wrapperParts: string[] = [];
const regularParts: string[] = [];
let index = 0;
let partSection: 'wrapper' | 'regular' = 'regular';
let partType: 'path' | 'identifier' = 'path';
let partContent = '';
const flushPart = () => {
const parts = partSection === 'wrapper' ? wrapperParts : regularParts;
if (partType === 'path') {
parts.push(partContent);
} else {
if (partContent === '') {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (an identifier cannot be empty)`
);
}
parts.push(':' + partContent);
}
partContent = '';
};
while (index <= normalizedPattern.length - 1) {
const character = normalizedPattern[index++];
if (character === '[') {
if (partSection === 'regular') {
flushPart();
partSection = 'wrapper';
partType = 'path';
} else {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (encountered an unexpected opening wrapper delimiter '[')`
);
}
} else if (character === ']') {
if (partSection === 'wrapper') {
flushPart();
partSection = 'regular';
partType = 'path';
} else {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (encountered an unexpected closing wrapper delimiter ']')`
);
}
} else if (partType === 'path') {
if (PATH_CHARACTER_REGEXP.test(character)) {
partContent += character;
} else if (character === ':') {
flushPart();
partType = 'identifier';
} else {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (the character '${character}' cannot be used in a pattern)`
);
}
} else {
if (IDENTIFIER_NAME_CHARACTER_REGEXP.test(character)) {
partContent += character;
} else if (character !== ':') {
flushPart();
partContent = character;
partType = 'path';
} else {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (an identifier cannot be immediately followed by another identifier)`
);
}
}
}
flushPart();
if (partSection !== 'regular') {
throw new Error(
`Couldn't parse the route (or wrapper) pattern '${pattern}' (a closing wrapper delimiter ']' is missing)`
);
}
return {wrapperParts, regularParts, isCatchAll};
}
================================================
FILE: packages/routable/src/routable.test.ts
================================================
import {Component, provide, primaryIdentifier, attribute} from '@layr/component';
import {Routable, callRouteByURL} from './routable';
import {isRoutableClass, isRoutableInstance} from './utilities';
describe('Routable', () => {
test('Routable()', async () => {
class Movie extends Routable(Component) {}
expect(isRoutableClass(Movie)).toBe(true);
expect(isRoutableInstance(Movie.prototype)).toBe(true);
});
test('getRoute() and hasRoute()', async () => {
class Movie extends Routable(Component) {}
// --- Class routes ---
const listPageRoute = Movie.setRoute('ListPage', '/movies');
expect(Movie.getRoute('ListPage')).toBe(listPageRoute);
expect(Movie.hasRoute('HotPage')).toBe(false);
expect(() => Movie.getRoute('HotPage')).toThrow(
"The route 'HotPage' is missing (component: 'Movie')"
);
// --- Prototype routes ---
const itemPageRoute = Movie.prototype.setRoute('ItemPage', '/movies/:id');
expect(Movie.prototype.getRoute('ItemPage')).toBe(itemPageRoute);
expect(Movie.prototype.hasRoute('DetailsPage')).toBe(false);
expect(() => Movie.prototype.getRoute('DetailsPage')).toThrow(
"The route 'DetailsPage' is missing (component: 'Movie')"
);
});
test('setRoute()', async () => {
class Movie extends Routable(Component) {}
class ExtendedMovie extends Movie {}
// --- Class routes ---
expect(Movie.hasRoute('ListPage')).toBe(false);
const listPageRoute = Movie.setRoute('ListPage', '/movies');
expect(Movie.getRoute('ListPage')).toBe(listPageRoute);
// - Testing route inheritance -
expect(ExtendedMovie.hasRoute('HotPage')).toBe(false);
const hotPageRoute = ExtendedMovie.setRoute('HotPage', '/movies/hot');
expect(ExtendedMovie.getRoute('HotPage')).toBe(hotPageRoute);
expect(ExtendedMovie.getRoute('ListPage')).toBe(listPageRoute);
expect(Movie.hasRoute('HotPage')).toBe(false);
// --- Prototype routes ---
expect(Movie.prototype.hasRoute('ItemPage')).toBe(false);
const itemPageRoute = Movie.prototype.setRoute('ItemPage', '/movies/:id');
expect(Movie.prototype.getRoute('ItemPage')).toBe(itemPageRoute);
// - Testing route inheritance -
expect(ExtendedMovie.prototype.hasRoute('DetailsPage')).toBe(false);
const detailsPageRoute = ExtendedMovie.prototype.setRoute('DetailsPage', '/movies/:id/details');
expect(ExtendedMovie.prototype.getRoute('DetailsPage')).toBe(detailsPageRoute);
expect(ExtendedMovie.prototype.getRoute('ItemPage')).toBe(itemPageRoute);
expect(Movie.prototype.hasRoute('DetailsPage')).toBe(false);
});
test('findRouteByURL()', async () => {
class Movie extends Routable(Component) {}
// --- Class routes ---
const listPageRoute = Movie.setRoute('ListPage', '/movies');
const hotPageRoute = Movie.setRoute('HotPage', '/movies/hot');
expect(Movie.findRouteByURL('/movies')).toStrictEqual({
route: listPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.findRouteByURL('/movies/hot')).toStrictEqual({
route: hotPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.findRouteByURL('/films')).toBeUndefined();
// --- Prototype routes ---
const itemPageRoute = Movie.prototype.setRoute('ItemPage', '/movies/:id');
const detailsPageRoute = Movie.prototype.setRoute('DetailsPage', '/movies/:id/details');
expect(Movie.prototype.findRouteByURL('/movies/abc123')).toStrictEqual({
route: itemPageRoute,
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(Movie.prototype.findRouteByURL('/movies/abc123/details')).toStrictEqual({
route: detailsPageRoute,
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(Movie.prototype.findRouteByURL('/films/abc123')).toBeUndefined();
// --- Catch-all routes ---
const notFoundPageRoute = Movie.setRoute('NotFoundPage', '/*');
expect(Movie.findRouteByURL('/movies')).toStrictEqual({
route: listPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(Movie.findRouteByURL('/films')).toStrictEqual({
route: notFoundPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
// Let's make sure that a route defined after a catch-all route is working...
const actorListPageRoute = Movie.setRoute('ActorListPage', '/actors');
expect(Movie.findRouteByURL('/actors')).toStrictEqual({
route: actorListPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
// ... and that the catch-all route is working too
expect(Movie.findRouteByURL('/films')).toStrictEqual({
route: notFoundPageRoute,
identifiers: {},
params: {},
wrapperPath: undefined
});
});
test('callRouteByURL()', async () => {
class Studio extends Routable(Component) {
@primaryIdentifier() id!: string;
}
class Movie extends Routable(Component) {
@primaryIdentifier() id!: string;
@attribute('Studio') studio!: Studio;
static ListPage() {
return `All movies`;
}
ItemPage({showDetails = false}) {
return `Movie #${this.id}${showDetails ? ' (with details)' : ''}`;
}
ItemWithStudioPage() {
return `Studio #${this.studio.id} > Movie #${this.id}`;
}
}
class Application extends Routable(Component) {
@provide() static Studio = Studio;
@provide() static Movie = Movie;
static MainLayout({children}: {children: () => any}) {
return `[${children()}]`;
}
static echo({message = ''}: {message?: string}) {
if (message === 'GET:') {
throw new Error("'message' cannot be empty");
}
return message;
}
static async echoAsync({message = ''}: {message?: string}) {
if (message === 'GET:') {
throw new Error("'message' cannot be empty");
}
return message;
}
}
Application.setWrapper('MainLayout', '/');
// --- Class routes ---
Movie.setRoute('ListPage', '[/]movies');
expect(callRouteByURL(Application, '/movies')).toBe('[All movies]');
expect(() => callRouteByURL(Application, '/movies/hot')).toThrow(
"Couldn't find a route matching the specified URL (URL: '/movies/hot')"
);
// --- Prototype routes ---
Movie.prototype.setRoute('ItemPage', '[/]movies/:id', {params: {showDetails: 'boolean?'}});
expect(callRouteByURL(Application, '/movies/abc123')).toBe('[Movie #abc123]');
expect(callRouteByURL(Application, '/movies/abc123?showDetails=1')).toBe(
'[Movie #abc123 (with details)]'
);
expect(() => callRouteByURL(Application, '/movies/abc123/details')).toThrow(
"Couldn't find a route matching the specified URL (URL: '/movies/abc123/details')"
);
Movie.prototype.setRoute('ItemWithStudioPage', '[/]studios/:studio.id/movies/:id');
expect(callRouteByURL(Application, '/studios/abc123/movies/def456')).toBe(
'[Studio #abc123 > Movie #def456]'
);
// --- Route transformers ---
const transformers = {
input({message = ''}: {message?: string}, {method}: {method: string}) {
return {message: `${method}:${message}`};
},
output(result: string) {
return {
status: 200,
headers: {'content-type': 'application/json'},
body: JSON.stringify(result)
};
},
error(error: Error) {
return {
status: 400,
headers: {'content-type': 'application/json'},
body: JSON.stringify({message: error.message})
};
}
};
// - With a synchronous method -
Application.setRoute('echo', '/echo', {params: {message: 'string?'}, transformers});
expect(callRouteByURL(Application, '/echo?message=hello', {method: 'GET'})).toStrictEqual({
status: 200,
headers: {'content-type': 'application/json'},
body: '"GET:hello"'
});
expect(callRouteByURL(Application, '/echo', {method: 'GET'})).toStrictEqual({
status: 400,
headers: {'content-type': 'application/json'},
body: `{"message":"'message' cannot be empty"}`
});
// - With an asynchronous method -
Application.setRoute('echoAsync', '/echo-async', {params: {message: 'string?'}, transformers});
expect(
await callRouteByURL(Application, '/echo-async?message=hello', {method: 'GET'})
).toStrictEqual({
status: 200,
headers: {'content-type': 'application/json'},
body: '"GET:hello"'
});
expect(await callRouteByURL(Application, '/echo-async', {method: 'GET'})).toStrictEqual({
status: 400,
headers: {'content-type': 'application/json'},
body: `{"message":"'message' cannot be empty"}`
});
});
});
================================================
FILE: packages/routable/src/routable.ts
================================================
import {
Component,
isComponentClass,
assertIsComponentClass,
isComponentValueTypeInstance
} from '@layr/component';
import {Navigator, assertIsNavigatorInstance, normalizeURL} from '@layr/navigator';
import {hasOwnProperty, getTypeOf, Constructor} from 'core-helpers';
import debugModule from 'debug';
import {Route, RouteOptions} from './route';
import {Wrapper, WrapperOptions} from './wrapper';
import type {Pattern} from './pattern';
import {isRoutableClass, isRoutableInstance} from './utilities';
const debug = debugModule('layr:routable');
// To display the debug log, set this environment:
// DEBUG=layr:routable DEBUG_DEPTH=10
/**
* Extends a [`Component`](https://layrjs.com/docs/v2/reference/component) class with some routing capabilities.
*
* #### Usage
*
* Call `Routable()` with a [`Component`](https://layrjs.com/docs/v2/reference/component) class to construct a [`RoutableComponent`](https://layrjs.com/docs/v2/reference/routable#routable-component-class) class.
*
* Then, you can define some routes or wrappers into this class by using the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) or [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorators.
*
* See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*
* ### RoutableComponent class {#routable-component-class}
*
* *Inherits from [`Component`](https://layrjs.com/docs/v2/reference/component).*
*
* A `RoutableComponent` class is constructed by calling the `Routable()` mixin ([see above](https://layrjs.com/docs/v2/reference/routable#routable-mixin)).
*
* @mixin
*/
export function Routable>(Base: T) {
if (!isComponentClass(Base)) {
throw new Error(
`The Routable mixin should be applied on a component class (received type: '${getTypeOf(
Base
)}')`
);
}
if (typeof (Base as any).isRoutable === 'function') {
return Base as T & typeof Routable;
}
class Routable extends Base {
// === Component Methods ===
/**
* See the methods that are inherited from the [`Component`](https://layrjs.com/docs/v2/reference/component#creation) class.
*
* @category Component Methods
*/
// === Navigator ===
static __navigator: Navigator | undefined;
static registerNavigator(navigator: Navigator) {
assertIsNavigatorInstance(navigator);
Object.defineProperty(this, '__navigator', {value: navigator});
}
/**
* Returns the navigator in which the routable component is registered. If the routable component is not registered in a navigator, an error is thrown.
*
* @returns A [`Navigator`](https://layrjs.com/docs/v2/reference/navigator) instance.
*
* @example
* ```
* Article.getNavigator(); // => navigator instance
* ```
*
* @category Navigator
*/
static getNavigator() {
const navigator = this.findNavigator();
if (navigator === undefined) {
throw new Error(
`Couldn't find a navigator from the current routable component (${this.describeComponent()})`
);
}
return navigator;
}
/**
* Returns the navigator in which the routable component is registered. If the routable component is not registered in a navigator, an error is thrown.
*
* @returns A [`Navigator`](https://layrjs.com/docs/v2/reference/navigator) instance.
*
* @example
* ```
* Article.getNavigator(); // => navigator instance
* ```
*
* @category Navigator
*/
getNavigator() {
return (this.constructor as typeof Routable).getNavigator();
}
static findNavigator() {
let currentComponent: typeof Component = this;
while (true) {
if (isRoutableClass(currentComponent) && currentComponent.__navigator !== undefined) {
return currentComponent.__navigator;
}
const componentProvider = currentComponent.getComponentProvider();
if (componentProvider === currentComponent) {
break;
}
currentComponent = componentProvider;
}
return undefined;
}
findNavigator() {
return (this.constructor as typeof Routable).findNavigator();
}
// === Routes ===
/**
* Gets a route. If there is no route with the specified name, an error is thrown.
*
* @param name The name of the route to get.
*
* @returns A [Route](https://layrjs.com/docs/v2/reference/route) instance.
*
* @example
* ```
* Article.getRoute('View'); // A route instance
* ```
*
* @category Routes
*/
static get getRoute() {
return (this.prototype as Routable).getRoute;
}
/**
* Gets a route. If there is no route with the specified name, an error is thrown.
*
* @param name The name of the route to get.
*
* @returns A [Route](https://layrjs.com/docs/v2/reference/route) instance.
*
* @example
* ```
* Article.getRoute('View'); // A route instance
* ```
*
* @category Routes
*/
getRoute(name: string) {
const route = this.__getRoute(name);
if (route === undefined) {
throw new Error(`The route '${name}' is missing (${this.describeComponent()})`);
}
return route;
}
/**
* Returns whether the routable component has a route with the specified name.
*
* @param name The name of the route to check.
*
* @returns A boolean.
*
* @example
* ```
* Article.hasRoute('View'); // => true
* ```
*
* @category Routes
*/
static get hasRoute() {
return (this.prototype as Routable).hasRoute;
}
/**
* Returns whether the routable component has a route with the specified name.
*
* @param name The name of the route to check.
*
* @returns A boolean.
*
* @example
* ```
* Article.hasRoute('View'); // => true
* ```
*
* @category Routes
*/
hasRoute(name: string) {
return this.__getRoute(name) !== undefined;
}
static get __getRoute() {
return (this.prototype as Routable).__getRoute;
}
__getRoute(name: string) {
const routes = this.__getRoutes();
return routes.get(name);
}
/**
* Sets a route for a routable component class or instances.
*
* Typically, instead of using this method, you would rather use the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* @param name The name of the route.
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the route.
* @param [options] An optional object specifying the options to pass to the `Route`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the route is created.
*
* @returns The [Route](https://layrjs.com/docs/v2/reference/route) instance that was created.
*
* @example
* ```
* Article.setRoute('View', '/articles', {parameters: {page: 'number?'});
*
* Article.prototype.setRoute('View', '/articles/:id', {parameters: {showDetails: 'boolean?'}});
* ```
*
* @category Routes
*/
static get setRoute() {
return (this.prototype as Routable).setRoute;
}
/**
* Sets a route for a routable component class or instances.
*
* Typically, instead of using this method, you would rather use the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* @param name The name of the route.
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the route.
* @param [options] An optional object specifying the options to pass to the `Route`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the route is created.
*
* @returns The [Route](https://layrjs.com/docs/v2/reference/route) instance that was created.
*
* @example
* ```
* Article.setRoute('View', '/articles', {parameters: {page: 'number?'});
*
* Article.prototype.setRoute('View', '/articles/:id', {parameters: {showDetails: 'boolean?'}});
* ```
*
* @category Routes
*/
setRoute(name: string, pattern: Pattern, options: RouteOptions = {}) {
const route = new Route(name, pattern, options);
const routes = this.__getRoutes(true);
routes.set(name, route);
return route;
}
static get __callRoute() {
return (this.prototype as Routable).__callRoute;
}
__callRoute(
rootComponent: typeof Component,
route: Route,
identifiers: any,
params: any,
wrapperPath: string | undefined,
request: any
) {
const name = route.getName();
debug('Calling route %s(%o)', this.describeComponentProperty(name), params);
const component = instantiateComponent(this, identifiers);
const method = route.transformMethod((component as any)[name], request);
const navigator = this.findNavigator();
if (wrapperPath !== undefined) {
return callWrapperByPath(
rootComponent,
wrapperPath,
function () {
if (navigator !== undefined) {
return navigator.callAddressableMethodWrapper(component, method, params);
} else {
return method.call(component, params);
}
},
request
);
} else {
if (navigator !== undefined) {
return navigator.callAddressableMethodWrapper(component, method, params);
} else {
return method.call(component, params);
}
}
}
/**
* Finds the first route that matches the specified URL.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
*
* @returns When a route is found, returns an object of the shape `{route, identifiers, params}` where `route` is the [route](https://layrjs.com/docs/v2/reference/route) that was found, `identifiers` is a plain object containing the value of some [component identifier attributes](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type), and `params` is a plain object containing the value of some [URL parameters](https://layrjs.com/docs/v2/reference/addressable#url-parameters-type). If no routes were found, returns `undefined`.
*
* @example
* ```
* const result = Article.prototype.findRouteByURL('/articles/abc123?showDetails=1');
*
* result.route; // => A route instance
* result.identifiers; // => {id: 'abc123'}
* result.params; // => {showDetails: true}
* ```
*
* @category Routes
*/
static get findRouteByURL() {
return (this.prototype as Routable).findRouteByURL;
}
/**
* Finds the first route that matches the specified URL.
*
* @param url A string or a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object.
*
* @returns When a route is found, returns an object of the shape `{route, identifiers, params}` where `route` is the [route](https://layrjs.com/docs/v2/reference/route) that was found, `identifiers` is a plain object containing the value of some [component identifier attributes](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type), and `params` is a plain object containing the value of some [URL parameters](https://layrjs.com/docs/v2/reference/addressable#url-parameters-type). If no routes were found, returns `undefined`.
*
* @example
* ```
* const result = Article.prototype.findRouteByURL('/articles/abc123?showDetails=1');
*
* result.route; // => A route instance
* result.identifiers; // => {id: 'abc123'}
* result.params; // => {showDetails: true}
* ```
*
* @category Routes
*/
findRouteByURL(url: URL | string, request?: any) {
const normalizedURL = normalizeURL(url);
const routes = this.__getRoutes();
let result:
| ({
route: Route;
} & ReturnType)
| undefined;
for (const route of routes.values()) {
const routeResult = route.matchURL(normalizedURL, request);
if (routeResult !== undefined) {
result = {route, ...routeResult};
if (!result.route.isCatchAll()) {
break;
}
}
}
return result;
}
static __routes?: Map;
__routes?: Map;
static get __getRoutes() {
return (this.prototype as Routable).__getRoutes;
}
__getRoutes(autoFork = false) {
if (this.__routes === undefined) {
Object.defineProperty(this, '__routes', {value: new Map()});
} else if (autoFork && !hasOwnProperty(this, '__routes')) {
Object.defineProperty(this, '__routes', {value: new Map(this.__routes)});
}
return this.__routes!;
}
// === Wrappers ===
/**
* Gets a wrapper. If there is no wrapper with the specified name, an error is thrown.
*
* @param name The name of the wrapper to get.
*
* @returns A [Wrapper](https://layrjs.com/docs/v2/reference/wrapper) instance.
*
* @example
* ```
* Article.getWrapper('Layout'); => A wrapper instance
* ```
*
* @category Wrappers
*/
static get getWrapper() {
return (this.prototype as Routable).getWrapper;
}
/**
* Gets a wrapper. If there is no wrapper with the specified name, an error is thrown.
*
* @param name The name of the wrapper to get.
*
* @returns A [Wrapper](https://layrjs.com/docs/v2/reference/wrapper) instance.
*
* @example
* ```
* Article.getWrapper('Layout'); => A wrapper instance
* ```
*
* @category Wrappers
*/
getWrapper(name: string) {
const wrapper = this.__getWrapper(name);
if (wrapper === undefined) {
throw new Error(`The wrapper '${name}' is missing (${this.describeComponent()})`);
}
return wrapper;
}
/**
* Returns whether the routable component has a wrapper with the specified name.
*
* @param name The name of the wrapper to check.
*
* @returns A boolean.
*
* @example
* ```
* Article.hasWrapper('Layout'); // => true
* ```
*
* @category Wrappers
*/
static get hasWrapper() {
return (this.prototype as Routable).hasWrapper;
}
/**
* Returns whether the routable component has a wrapper with the specified name.
*
* @param name The name of the wrapper to check.
*
* @returns A boolean.
*
* @example
* ```
* Article.hasWrapper('Layout'); // => true
* ```
*
* @category Wrappers
*/
hasWrapper(name: string) {
return this.__getWrapper(name) !== undefined;
}
static get __getWrapper() {
return (this.prototype as Routable).__getWrapper;
}
__getWrapper(name: string) {
const wrappers = this.__getWrappers();
return wrappers.get(name);
}
/**
* Sets a wrapper for a routable component class or instances.
*
* Typically, instead of using this method, you would rather use the [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorator.
*
* @param name The name of the wrapper.
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the wrapper.
* @param [options] An optional object specifying the options to pass to the `Wrapper`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the wrapper is created.
*
* @returns The [Wrapper](https://layrjs.com/docs/v2/reference/wrapper) instance that was created.
*
* @example
* ```
* Article.setWrapper('Layout', '/articles');
*
* Article.prototype.setWrapper('View', '[/articles]/:id');
* ```
*
* @category Wrappers
*/
static get setWrapper() {
return (this.prototype as Routable).setWrapper;
}
/**
* Sets a wrapper for a routable component class or instances.
*
* Typically, instead of using this method, you would rather use the [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorator.
*
* @param name The name of the wrapper.
* @param pattern The canonical [URL pattern](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type) of the wrapper.
* @param [options] An optional object specifying the options to pass to the `Wrapper`'s [constructor](https://layrjs.com/docs/v2/reference/addressable#constructor) when the wrapper is created.
*
* @returns The [Wrapper](https://layrjs.com/docs/v2/reference/wrapper) instance that was created.
*
* @example
* ```
* Article.setWrapper('Layout', '/articles');
*
* Article.prototype.setWrapper('View', '[/articles]/:id');
* ```
*
* @category Wrappers
*/
setWrapper(name: string, pattern: Pattern, options: WrapperOptions = {}) {
const wrapper = new Wrapper(name, pattern, options);
const wrappers = this.__getWrappers(true);
wrappers.set(name, wrapper);
return wrapper;
}
static get __callWrapper() {
return (this.prototype as Routable).__callWrapper;
}
__callWrapper(
rootComponent: typeof Component,
wrapper: Wrapper,
identifiers: any,
wrapperPath: string | undefined,
children: () => any,
request: any
): any {
const name = wrapper.getName();
debug('Calling wrapper %s()', this.describeComponentProperty(name));
const component = instantiateComponent(this, identifiers);
const method = wrapper.transformMethod((component as any)[name], request);
const navigator = this.findNavigator();
if (wrapperPath !== undefined) {
return callWrapperByPath(
rootComponent,
wrapperPath,
function () {
if (navigator !== undefined) {
return navigator.callAddressableMethodWrapper(component, method, {children});
} else {
return method.call(component, {children});
}
},
request
);
} else {
if (navigator !== undefined) {
return navigator.callAddressableMethodWrapper(component, method, {children});
} else {
return method.call(component, {children});
}
}
}
/**
* Finds the first wrapper that matches the specified path.
*
* @param path A string representing a path.
*
* @returns When a wrapper is found, returns an object of the shape `{wrapper, identifiers}` where `wrapper` is the [wrapper](https://layrjs.com/docs/v2/reference/wrapper) that was found and `identifiers` is a plain object containing the value of some [component identifier attributes](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type). If no wrappers were found, returns `undefined`.
*
* @example
* ```
* const result = Article.prototype.findWrapperByPath('/articles/abc123');
*
* result.wrapper; // => A wrapper instance
* result.identifiers; // => {id: 'abc123'}
* ```
*
* @category Wrappers
*/
static get findWrapperByPath() {
return (this.prototype as Routable).findWrapperByPath;
}
/**
* Finds the first wrapper that matches the specified path.
*
* @param path A string representing a path.
*
* @returns When a wrapper is found, returns an object of the shape `{wrapper, identifiers}` where `wrapper` is the [wrapper](https://layrjs.com/docs/v2/reference/wrapper) that was found and `identifiers` is a plain object containing the value of some [component identifier attributes](https://layrjs.com/docs/v2/reference/addressable#url-pattern-type). If no wrappers were found, returns `undefined`.
*
* @example
* ```
* const result = Article.prototype.findWrapperByPath('/articles/abc123');
*
* result.wrapper; // => A wrapper instance
* result.identifiers; // => {id: 'abc123'}
* ```
*
* @category Wrappers
*/
findWrapperByPath(path: string, request?: any) {
const wrappers = this.__getWrappers();
for (const wrapper of wrappers.values()) {
const result = wrapper.matchPath(path, request);
if (result !== undefined) {
return {wrapper, ...result};
}
}
return undefined;
}
static __wrappers?: Map;
__wrappers?: Map;
static get __getWrappers() {
return (this.prototype as Routable).__getWrappers;
}
__getWrappers(autoFork = false) {
if (this.__wrappers === undefined) {
Object.defineProperty(this, '__wrappers', {value: new Map()});
} else if (autoFork && !hasOwnProperty(this, '__wrappers')) {
Object.defineProperty(this, '__wrappers', {value: new Map(this.__wrappers)});
}
return this.__wrappers!;
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Utilities ===
static isRoutable(value: any): value is RoutableComponent {
return isRoutableInstance(value);
}
}
return Routable;
}
export class RoutableComponent extends Routable(Component) {}
// === Multi-components functions ===
export function findRouteByURL(rootComponent: typeof Component, url: URL | string, request?: any) {
assertIsComponentClass(rootComponent);
const normalizedURL = normalizeURL(url);
let result:
| ({
routable: typeof RoutableComponent | RoutableComponent;
} & ReturnType)
| undefined;
for (const component of rootComponent.traverseComponents()) {
if (!isRoutableClass(component)) {
continue;
}
let routable: typeof RoutableComponent | RoutableComponent = component;
let routableResult = routable.findRouteByURL(normalizedURL, request);
if (routableResult !== undefined) {
result = {routable, ...routableResult};
if (!result!.route.isCatchAll()) {
break;
}
}
routable = component.prototype;
routableResult = routable.findRouteByURL(normalizedURL, request);
if (routableResult !== undefined) {
result = {routable, ...routableResult};
if (!result!.route.isCatchAll()) {
break;
}
}
}
return result;
}
export function callRouteByURL(rootComponent: typeof Component, url: URL | string, request?: any) {
const result = findRouteByURL(rootComponent, url, request);
if (result === undefined) {
throw new Error(`Couldn't find a route matching the specified URL (URL: '${url}')`);
}
const {routable, route, identifiers, params, wrapperPath} = result;
return routable.__callRoute(rootComponent, route, identifiers, params, wrapperPath, request);
}
export function findWrapperByPath(rootComponent: typeof Component, path: string, request?: any) {
assertIsComponentClass(rootComponent);
for (const component of rootComponent.traverseComponents()) {
if (!isRoutableClass(component)) {
continue;
}
let routable: typeof RoutableComponent | RoutableComponent = component;
let result = routable.findWrapperByPath(path, request);
if (result !== undefined) {
return {routable, ...result};
}
routable = component.prototype;
result = routable.findWrapperByPath(path, request);
if (result !== undefined) {
return {routable, ...result};
}
}
return undefined;
}
export function callWrapperByPath(
rootComponent: typeof Component,
path: string,
children: () => any,
request?: any
) {
const result = findWrapperByPath(rootComponent, path, request);
if (result === undefined) {
throw new Error(`Couldn't find a wrapper matching the specified path (path: '${path}')`);
}
const {routable, wrapper, identifiers, wrapperPath} = result;
return routable.__callWrapper(
rootComponent,
wrapper,
identifiers,
wrapperPath,
children,
request
);
}
function instantiateComponent(
componentClassOrPrototype: typeof Component | Component,
identifiers: any
) {
if (isComponentClass(componentClassOrPrototype)) {
return componentClassOrPrototype;
}
const componentClass = componentClassOrPrototype.constructor;
let componentIdentifier: any;
let referencedComponentIdentifiers: any = {};
for (const [name, value] of Object.entries(identifiers)) {
if (typeof value === 'string' || typeof value === 'number') {
if (componentIdentifier !== undefined) {
throw new Error(
`Cannot get or instantiate a component with more than one identifier (\`${JSON.stringify(
componentIdentifier
)}\` and \`${JSON.stringify({[name]: value})}\`)`
);
}
componentIdentifier = {[name]: value};
} else if (typeof value === 'object' && value !== null) {
referencedComponentIdentifiers[name] = value;
} else {
throw new Error(
`Unexpected identifier type encountered while getting or instantiating a component (type: '${getTypeOf(
value
)}')`
);
}
}
if (componentIdentifier === undefined) {
throw new Error(`Cannot get or instantiate a component with no specified identifier`);
}
const component = componentClass.instantiate(componentIdentifier, {source: 'server'});
for (const [name, identifiers] of Object.entries(referencedComponentIdentifiers)) {
const attribute = component.getAttribute(name);
const valueType = attribute.getValueType();
if (!isComponentValueTypeInstance(valueType)) {
throw new Error(
`Unexpected attribute type encountered while getting or instantiating a component (name: '${name}', type: '${valueType.toString()}')`
);
}
const referencedComponentClassOrPrototype = valueType.getComponent(attribute);
attribute.setValue(instantiateComponent(referencedComponentClassOrPrototype, identifiers), {
source: 'server'
});
}
return component;
}
================================================
FILE: packages/routable/src/route.test.ts
================================================
import {Route, isRouteInstance} from './route';
describe('Route', () => {
test('new Route()', async () => {
let route = new Route('Main', '/movies');
expect(isRouteInstance(route)).toBe(true);
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies');
expect(route.getParams()).toStrictEqual({});
expect(route.getAliases()).toStrictEqual([]);
expect(route.getFilter()).toBeUndefined();
expect(route.getTransformers()).toStrictEqual({});
// --- Using aliases ---
route = new Route('Main', '/movies', {aliases: ['/']});
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies');
expect(route.getParams()).toStrictEqual({});
expect(route.getAliases()).toStrictEqual(['/']);
expect(route.getFilter()).toBeUndefined();
expect(route.getTransformers()).toStrictEqual({});
// -- Using route identifiers ---
route = new Route('Main', '/movies/:id');
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies/:id');
expect(route.getParams()).toStrictEqual({});
expect(route.getAliases()).toStrictEqual([]);
expect(route.getFilter()).toBeUndefined();
expect(route.getTransformers()).toStrictEqual({});
// -- Using route parameters ---
route = new Route('Main', '/movies', {params: {showDetails: 'boolean?'}});
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies');
expect(route.getParams()).toStrictEqual({showDetails: 'boolean?'});
expect(route.getAliases()).toStrictEqual([]);
expect(route.getFilter()).toBeUndefined();
expect(route.getTransformers()).toStrictEqual({});
// @ts-expect-error
expect(() => new Route('Main', '/movies', {params: {showDetails: 'any'}})).toThrow(
"Couldn't parse a route (or wrapper) parameter type ('any' is not a supported type)"
);
// -- Using route filters ---
const filter = function (request: any) {
return request?.method === 'GET';
};
route = new Route('Main', '/movies', {filter});
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies');
expect(route.getParams()).toStrictEqual({});
expect(route.getAliases()).toStrictEqual([]);
expect(route.getFilter()).toBe(filter);
expect(route.getTransformers()).toStrictEqual({});
// -- Using route transformers ---
const input = function (params: any) {
return params;
};
const output = function (result: any) {
return result;
};
const error = function (error: any) {
throw error;
};
route = new Route('Main', '/movies', {transformers: {input, output, error}});
expect(route.getName()).toBe('Main');
expect(route.getPattern()).toBe('/movies');
expect(route.getParams()).toStrictEqual({});
expect(route.getAliases()).toStrictEqual([]);
expect(route.getFilter()).toBeUndefined();
expect(route.getTransformers()).toStrictEqual({input, output, error});
});
test('matchURL()', async () => {
let route = new Route('Main', '/movies');
expect(route.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/movies/abc123')).toBeUndefined();
expect(route.matchURL('/films')).toBeUndefined();
expect(route.matchURL('/')).toBeUndefined();
// --- Using aliases ---
route = new Route('Main', '/movies', {aliases: ['/', '/films']});
expect(route.matchURL('/movies')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/films')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/motion-pictures')).toBeUndefined();
// -- Using route identifiers ---
route = new Route('Main', '/movies/:id', {aliases: ['/films/:id']});
expect(route.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/movies/group%2F12345')).toStrictEqual({
identifiers: {id: 'group/12345'},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/films/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/movies')).toBeUndefined();
expect(route.matchURL('/movies/')).toBeUndefined();
expect(route.matchURL('/movies/abc123/about')).toBeUndefined();
// -- Using route nested identifiers ---
route = new Route('Main', '/projects/:project.slug/implementations/:id');
expect(route.matchURL('/projects/realworld/implementations/abc123')).toStrictEqual({
identifiers: {id: 'abc123', project: {slug: 'realworld'}},
params: {},
wrapperPath: undefined
});
// --- Using route identifier prefixes ---
route = new Route('Main', '/@:username');
expect(route.matchURL('/@john')).toStrictEqual({
identifiers: {username: 'john'},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/@')).toBeUndefined();
expect(route.matchURL('/john')).toBeUndefined();
// -- Using wrappers ---
route = new Route('Main', '[]/special');
expect(route.matchURL('/special')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: ''
});
route = new Route('Main', '[/]about');
expect(route.matchURL('/about')).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: '/'
});
route = new Route('Main', '[/projects/:project.slug]/implementations/:id');
expect(route.matchURL('/projects/realworld/implementations/abc123')).toStrictEqual({
identifiers: {id: 'abc123', project: {slug: 'realworld'}},
params: {},
wrapperPath: '/projects/realworld'
});
// --- Using optional route parameters ---
route = new Route('Main', '/movies/:id', {
params: {language: 'string?', showDetails: 'boolean?'}
});
expect(route.matchURL('/movies/abc123')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {language: undefined, showDetails: undefined},
wrapperPath: undefined
});
expect(route.matchURL('/movies/abc123?language=fr')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {language: 'fr', showDetails: undefined},
wrapperPath: undefined
});
expect(route.matchURL('/movies/abc123?language=fr&showDetails=1')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {language: 'fr', showDetails: true},
wrapperPath: undefined
});
expect(route.matchURL('/movies/abc123?unknownParam=abc')).toStrictEqual({
identifiers: {id: 'abc123'},
params: {language: undefined, showDetails: undefined},
wrapperPath: undefined
});
expect(() => route.matchURL('/movies/abc123?showDetails=true')).toThrow(
"Couldn't deserialize a route (or wrapper) parameter (name: 'showDetails', value: 'true', type: 'boolean?'"
);
// --- Using required route parameters ---
route = new Route('Main', '/', {params: {language: 'string'}});
expect(route.matchURL('/?language=fr')).toStrictEqual({
identifiers: {},
params: {language: 'fr'},
wrapperPath: undefined
});
expect(() => route.matchURL('/')).toThrow(
"A required route (or wrapper) parameter is missing (name: 'language', type: 'string')"
);
// -- Using route filters ---
route = new Route('Main', '/movies', {
filter(request: any) {
return request?.method === 'GET';
}
});
expect(route.matchURL('/movies', {method: 'GET'})).toStrictEqual({
identifiers: {},
params: {},
wrapperPath: undefined
});
expect(route.matchURL('/movies', {method: 'POST'})).toBeUndefined();
expect(route.matchURL('/movies')).toBeUndefined();
});
test('generateURL()', async () => {
let route = new Route('Main', '/movies');
expect(route.generateURL()).toBe('/movies');
route = new Route('Main', '/movies/:id');
expect(route.generateURL({id: 'abc123'})).toBe('/movies/abc123');
expect(route.generateURL({id: 'group/12345'})).toBe('/movies/group%2F12345');
// Make sure it works with non-enumerable properties
const movie = {};
Object.defineProperty(movie, 'id', {value: 'abc123'});
expect(route.generateURL(movie)).toBe('/movies/abc123');
expect(() => route.generateURL()).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/movies/:id' because the identifier 'id' is missing"
);
expect(() => route.generateURL({})).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/movies/:id' because the identifier 'id' is missing"
);
expect(() => route.generateURL({id: ''})).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/movies/:id' because the identifier 'id' is missing"
);
expect(() => route.generateURL({ref: 'abc123'})).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/movies/:id' because the identifier 'id' is missing"
);
// -- Using route nested identifiers ---
route = new Route('Main', '/projects/:project.slug/implementations/:id');
expect(route.generateURL({id: 'abc123', project: {slug: 'realworld'}})).toBe(
'/projects/realworld/implementations/abc123'
);
// --- Using route identifier prefixes ---
route = new Route('Main', '/@:username');
expect(route.generateURL({username: 'john'})).toBe('/@john');
expect(() => route.generateURL({})).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/@:username' because the identifier 'username' is missing"
);
expect(() => route.generateURL({username: ''})).toThrow(
"Couldn't build a route (or wrapper) path from the pattern '/@:username' because the identifier 'username' is missing"
);
// --- Using route parameters ---
route = new Route('Main', '/movies/:id', {
params: {language: 'string?', showDetails: 'boolean?'}
});
expect(route.generateURL({id: 'abc123'})).toBe('/movies/abc123');
expect(route.generateURL({id: 'abc123'}, {language: 'fr'})).toBe('/movies/abc123?language=fr');
expect(route.generateURL({id: 'abc123'}, {language: 'fr', showDetails: true})).toBe(
'/movies/abc123?language=fr&showDetails=1'
);
expect(route.generateURL({id: 'abc123'}, {unknownParam: 'abc'})).toBe('/movies/abc123');
expect(() => route.generateURL({id: 'abc123'}, {language: 123})).toThrow(
"Couldn't serialize a route (or wrapper) parameter (name: 'language', value: '123', expected type: 'string?', received type: 'number')"
);
// --- Using the 'hash' option ---
route = new Route('Main', '/movies/:id');
expect(route.generateURL({id: 'abc123'}, {}, {hash: 'main'})).toBe('/movies/abc123#main');
});
test('generatePath()', async () => {
const route = new Route('Main', '/movies/:id', {params: {language: 'string?'}});
expect(route.generatePath({id: 'abc123'})).toBe('/movies/abc123');
});
test('generateQueryString()', async () => {
const route = new Route('Main', '/movies/:id', {params: {language: 'string?'}});
expect(route.generateQueryString({})).toBe('');
expect(route.generateQueryString({language: 'fr'})).toBe('language=fr');
});
});
================================================
FILE: packages/routable/src/route.ts
================================================
import {Addressable, AddressableOptions} from './addressable';
export type RouteOptions = AddressableOptions;
/**
* *Inherits from [`Addressable`](https://layrjs.com/docs/v2/reference/addressable).*
*
* Represents a route in a [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
*
* #### Usage
*
* Typically, you create a `Route` and associate it to a routable component by using the [`@route()`](https://layrjs.com/docs/v2/reference/routable#route-decorator) decorator.
*
* See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*/
export class Route extends Addressable {
// === Creation ===
/**
* See the constructor that is inherited from the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#constructor) class.
*
* @category Creation
*/
// === Methods ===
/**
* See the methods that are inherited from the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#basic-methods) class.
*
* @category Methods
*/
// === Types ===
/**
* See the types that are related to the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#types) class.
*
* @category Types
*/
static isRoute(value: any): value is Route {
return isRouteInstance(value);
}
}
/**
* Returns whether the specified value is a [`Route`](https://layrjs.com/docs/v2/reference/route) class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isRouteClass(value: any): value is typeof Route {
return typeof value?.isRoute === 'function';
}
/**
* Returns whether the specified value is a [`Route`](https://layrjs.com/docs/v2/reference/route) instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isRouteInstance(value: any): value is Route {
return typeof value?.constructor?.isRoute === 'function';
}
================================================
FILE: packages/routable/src/utilities.ts
================================================
import {getTypeOf} from 'core-helpers';
import type {RoutableComponent} from './routable';
/**
* Returns whether the specified value is a routable component class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isRoutableClass(value: any): value is typeof RoutableComponent {
return typeof value?.isRoutable === 'function';
}
/**
* Returns whether the specified value is a routable component class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isRoutableInstance(value: any): value is RoutableComponent {
return typeof value?.constructor?.isRoutable === 'function';
}
/**
* Returns whether the specified value is a routable component class or instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isRoutableClassOrInstance(
value: any
): value is typeof RoutableComponent | RoutableComponent {
return (
typeof value?.isRoutable === 'function' || typeof value?.constructor?.isRoutable === 'function'
);
}
/**
* Throws an error if the specified value is not a routable component class.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsRoutableClass(value: any): asserts value is typeof RoutableComponent {
if (!isRoutableClass(value)) {
throw new Error(
`Expected a routable class, but received a value of type '${getTypeOf(value)}'`
);
}
}
/**
* Throws an error if the specified value is not a routable component instance.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsRoutableInstance(value: any): asserts value is RoutableComponent {
if (!isRoutableInstance(value)) {
throw new Error(
`Expected a routable component instance, but received a value of type '${getTypeOf(value)}'`
);
}
}
/**
* Throws an error if the specified value is not a routable component class or instance.
*
* @param value A value of any type.
*
* @category Utilities
*/
export function assertIsRoutableClassOrInstance(
value: any
): asserts value is typeof RoutableComponent | RoutableComponent {
if (!isRoutableClassOrInstance(value)) {
throw new Error(
`Expected a routable component class or instance, but received a value of type '${getTypeOf(
value
)}'`
);
}
}
================================================
FILE: packages/routable/src/wrapper.ts
================================================
import {Addressable, AddressableOptions} from './addressable';
import {Pattern} from './pattern';
export type WrapperOptions = AddressableOptions;
/**
* *Inherits from [`Addressable`](https://layrjs.com/docs/v2/reference/addressable).*
*
* Represents a wrapper in a [routable component](https://layrjs.com/docs/v2/reference/routable#routable-component-class).
*
* #### Usage
*
* Typically, you create a `Wrapper` and associate it to a routable component by using the [`@wrapper()`](https://layrjs.com/docs/v2/reference/routable#wrapper-decorator) decorator.
*
* See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*/
export class Wrapper extends Addressable {
// === Creation ===
/**
* See the constructor that is inherited from the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#constructor) class.
*
* @category Creation
*/
constructor(name: string, pattern: Pattern, options: WrapperOptions = {}) {
super(name, pattern, options);
if (this.isCatchAll()) {
throw new Error(`Couldn't create the wrapper '${name}' (catch-all wrappers are not allowed)`);
}
}
// === Methods ===
/**
* See the methods that are inherited from the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#basic-methods) class.
*
* @category Methods
*/
// === Types ===
/**
* See the types that are related to the [`Addressable`](https://layrjs.com/docs/v2/reference/addressable#types) class.
*
* @category Types
*/
static isWrapper(value: any): value is Wrapper {
return isWrapperInstance(value);
}
}
/**
* Returns whether the specified value is a [`Wrapper`](https://layrjs.com/docs/v2/reference/wrapper) class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isWrapperClass(value: any): value is typeof Wrapper {
return typeof value?.isWrapper === 'function';
}
/**
* Returns whether the specified value is a [`Wrapper`](https://layrjs.com/docs/v2/reference/wrapper) instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isWrapperInstance(value: any): value is Wrapper {
return typeof value?.constructor?.isWrapper === 'function';
}
================================================
FILE: packages/routable/tsconfig.json
================================================
{
"extends": "@mvila/tsconfig",
"include": ["src/**/*"]
}
================================================
FILE: packages/storable/README.md
================================================
# @layr/storable
A mixin providing persistence capabilities to Layr components.
## Installation
```
npm install @layr/storable
```
## License
MIT
================================================
FILE: packages/storable/package.json
================================================
{
"name": "@layr/storable",
"version": "2.0.76",
"description": "A mixin providing persistence capabilities to Layr components",
"keywords": [
"layr",
"component",
"mixin",
"storage",
"persistence",
"database",
"orm",
"odm"
],
"author": "Manuel Vila ",
"license": "MIT",
"repository": "https://github.com/layrjs/layr/tree/master/packages/storable",
"files": [
"dist"
],
"main": "dist/node-cjs/index.js",
"module": "dist/node-esm/index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "dev-tools build:ts-library",
"prepare": "npm run build",
"test": "dev-tools test:ts-library",
"publish:package": "dev-tools publish:package",
"update": "dev-tools update:dependencies"
},
"dependencies": {
"@layr/component": "^2.0.51",
"core-helpers": "^1.0.8",
"lodash": "^4.17.21",
"tslib": "^2.4.1"
},
"devDependencies": {
"@mvila/dev-tools": "^1.3.1",
"@mvila/tsconfig": "^1.0.6",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191"
}
}
================================================
FILE: packages/storable/src/decorators.test.ts
================================================
import {Component} from '@layr/component';
import {Storable} from './storable';
import {attribute, method, loader, finder, index} from './decorators';
import {isStorableAttributeInstance, isStorableMethodInstance} from './properties';
import {isIndexInstance} from './index-class';
describe('Decorators', () => {
test('@attribute()', async () => {
const beforeLoadHook = function () {};
const beforeSaveHook = function () {};
class Movie extends Storable(Component) {
@attribute('number', {beforeLoad: beforeLoadHook}) static limit = 100;
@attribute('string', {beforeSave: beforeSaveHook}) title = '';
}
const limitAttribute = Movie.getStorableAttribute('limit');
expect(isStorableAttributeInstance(limitAttribute)).toBe(true);
expect(limitAttribute.getName()).toBe('limit');
expect(limitAttribute.getParent()).toBe(Movie);
expect(limitAttribute.getHook('beforeLoad')).toBe(beforeLoadHook);
expect(limitAttribute.hasHook('beforeLoad')).toBe(true);
expect(limitAttribute.hasHook('beforeSave')).toBe(false);
const titleAttribute = Movie.prototype.getStorableAttribute('title');
expect(isStorableAttributeInstance(titleAttribute)).toBe(true);
expect(titleAttribute.getName()).toBe('title');
expect(titleAttribute.getParent()).toBe(Movie.prototype);
expect(titleAttribute.getHook('beforeSave')).toBe(beforeSaveHook);
expect(titleAttribute.hasHook('beforeSave')).toBe(true);
expect(titleAttribute.hasHook('beforeLoad')).toBe(false);
});
test('@loader()', async () => {
const limitLoader = function () {};
const titleLoader = function () {};
class Movie extends Storable(Component) {
@loader(limitLoader) @attribute('number?') static limit: number;
@loader(titleLoader) @attribute('string') title = '';
}
const limitAttribute = Movie.getStorableAttribute('limit');
expect(isStorableAttributeInstance(limitAttribute)).toBe(true);
expect(limitAttribute.getName()).toBe('limit');
expect(limitAttribute.getParent()).toBe(Movie);
expect(limitAttribute.getLoader()).toBe(limitLoader);
expect(limitAttribute.hasLoader()).toBe(true);
const titleAttribute = Movie.prototype.getStorableAttribute('title');
expect(isStorableAttributeInstance(titleAttribute)).toBe(true);
expect(titleAttribute.getName()).toBe('title');
expect(titleAttribute.getParent()).toBe(Movie.prototype);
expect(titleAttribute.getLoader()).toBe(titleLoader);
expect(titleAttribute.hasLoader()).toBe(true);
});
test('@finder()', async () => {
const hasNoAccessFinder = function () {
return {};
};
const hasAccessLevelFinder = function () {
return {};
};
class Movie extends Storable(Component) {
@finder(hasNoAccessFinder) @attribute('boolean?') hasNoAccess?: boolean;
@finder(hasAccessLevelFinder) @method() hasAccessLevel() {}
}
const hasNoAccessAttribute = Movie.prototype.getStorableAttribute('hasNoAccess');
expect(isStorableAttributeInstance(hasNoAccessAttribute)).toBe(true);
expect(hasNoAccessAttribute.getName()).toBe('hasNoAccess');
expect(hasNoAccessAttribute.getParent()).toBe(Movie.prototype);
expect(hasNoAccessAttribute.getFinder()).toBe(hasNoAccessFinder);
expect(hasNoAccessAttribute.hasFinder()).toBe(true);
const hasAccessLevelMethod = Movie.prototype.getStorableMethod('hasAccessLevel');
expect(isStorableMethodInstance(hasAccessLevelMethod)).toBe(true);
expect(hasAccessLevelMethod.getName()).toBe('hasAccessLevel');
expect(hasAccessLevelMethod.getParent()).toBe(Movie.prototype);
expect(hasAccessLevelMethod.getFinder()).toBe(hasAccessLevelFinder);
expect(hasAccessLevelMethod.hasFinder()).toBe(true);
});
test('@index()', async () => {
@index({year: 'desc', title: 'asc'}, {isUnique: true})
class Movie extends Storable(Component) {
@index({isUnique: true}) @attribute('string') title!: string;
@index({direction: 'desc'}) @attribute('number') year!: number;
}
const titleIndex = Movie.prototype.getIndex({title: 'asc'});
expect(isIndexInstance(titleIndex)).toBe(true);
expect(titleIndex.getAttributes()).toStrictEqual({title: 'asc'});
expect(titleIndex.getParent()).toBe(Movie.prototype);
expect(titleIndex.getOptions().isUnique).toBe(true);
const yearIndex = Movie.prototype.getIndex({year: 'desc'});
expect(isIndexInstance(yearIndex)).toBe(true);
expect(yearIndex.getAttributes()).toStrictEqual({year: 'desc'});
expect(yearIndex.getParent()).toBe(Movie.prototype);
expect(yearIndex.getOptions().isUnique).not.toBe(true);
const compoundIndex = Movie.prototype.getIndex({year: 'desc', title: 'asc'});
expect(isIndexInstance(compoundIndex)).toBe(true);
expect(compoundIndex.getAttributes()).toStrictEqual({year: 'desc', title: 'asc'});
expect(compoundIndex.getParent()).toBe(Movie.prototype);
expect(compoundIndex.getOptions().isUnique).toBe(true);
});
});
================================================
FILE: packages/storable/src/decorators.ts
================================================
import {
Attribute,
PrimaryIdentifierAttribute,
SecondaryIdentifierAttribute,
Method,
createAttributeDecorator,
createMethodDecorator,
isComponentClassOrInstance,
isComponentInstance
} from '@layr/component';
import {StorableComponent, SortDirection} from './storable';
import {
StorablePropertyFinder,
StorableAttribute,
StorableAttributeOptions,
StorablePrimaryIdentifierAttribute,
StorableSecondaryIdentifierAttribute,
StorableAttributeLoader,
StorableMethod,
StorableMethodOptions
} from './properties';
import type {IndexAttributes} from './index-class';
import {isStorableClass, isStorableInstance, isStorableClassOrInstance} from './utilities';
type StorableAttributeDecoratorOptions = Omit;
/**
* Decorates an attribute of a storable component so it can be combined with a [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type), a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type), or any kind of [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type).
*
* @param [valueType] A string specifying the [type of values](https://layrjs.com/docs/v2/reference/value-type#supported-types) that can be stored in the attribute (default: `'any'`).
* @param [options] The options to create the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute#constructor).
*
* @examplelink See an example of use in the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) class.
*
* @category Decorators
* @decorator
*/
export function attribute(
valueType?: string,
options?: StorableAttributeDecoratorOptions
): PropertyDecorator;
export function attribute(options?: StorableAttributeDecoratorOptions): PropertyDecorator;
export function attribute(
valueType?: string | StorableAttributeDecoratorOptions,
options?: StorableAttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([
[isStorableClassOrInstance, StorableAttribute],
[isComponentClassOrInstance, Attribute]
]),
'attribute',
valueType,
options
);
}
/**
* Decorates an attribute of a component as a [storable primary identifier attribute](https://layrjs.com/docs/v2/reference/storable-primary-identifier-attribute).
*
* @param [valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options] The options to create the [`StorablePrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/storable-primary-identifier-attribute#constructor).
*
* @category Decorators
* @decorator
*/
export function primaryIdentifier(
valueType?: string,
options?: StorableAttributeDecoratorOptions
): PropertyDecorator;
export function primaryIdentifier(options?: StorableAttributeDecoratorOptions): PropertyDecorator;
export function primaryIdentifier(
valueType?: string | StorableAttributeDecoratorOptions,
options?: StorableAttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([
[isStorableInstance, StorablePrimaryIdentifierAttribute],
[isComponentInstance, PrimaryIdentifierAttribute]
]),
'primaryIdentifier',
valueType,
options
);
}
/**
* Decorates an attribute of a component as a [storable secondary identifier attribute](https://layrjs.com/docs/v2/reference/storable-secondary-identifier-attribute).
*
* @param [valueType] A string specifying the type of values the attribute can store. It can be either `'string'` or `'number'` (default: `'string'`).
* @param [options] The options to create the [`StorableSecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/storable-secondary-identifier-attribute#constructor).
*
* @category Decorators
* @decorator
*/
export function secondaryIdentifier(
valueType?: string,
options?: StorableAttributeDecoratorOptions
): PropertyDecorator;
export function secondaryIdentifier(options?: StorableAttributeDecoratorOptions): PropertyDecorator;
export function secondaryIdentifier(
valueType?: string | StorableAttributeDecoratorOptions,
options?: StorableAttributeDecoratorOptions
) {
return createAttributeDecorator(
new Map([
[isStorableInstance, StorableSecondaryIdentifierAttribute],
[isComponentInstance, SecondaryIdentifierAttribute]
]),
'secondaryIdentifier',
valueType,
options
);
}
/**
* Decorates a method of a storable component so it can be combined with a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type).
*
* @param [options] The options to create the [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method#constructor).
*
* @examplelink See an example of use in the [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method) class.
*
* @category Decorators
* @decorator
*/
export function method(options: StorableMethodOptions = {}) {
return createMethodDecorator(
new Map([
[isStorableClassOrInstance, StorableMethod],
[isComponentClassOrInstance, Method]
]),
'method',
options
);
}
/**
* Decorates a storable attribute with a [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type).
*
* @param loader A function representing the [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) of the storable attribute.
*
* @examplelink See an example of use in the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) class.
*
* @category Decorators
* @decorator
*/
export function loader(loader: StorableAttributeLoader) {
return function (target: typeof StorableComponent | StorableComponent, name: string) {
if (!isStorableClassOrInstance(target)) {
throw new Error(
`@loader() must be used as a storable component attribute decorator (property: '${name}')`
);
}
if (
!target.hasStorableAttribute(name) ||
target.getStorableAttribute(name, {autoFork: false}).getParent() !== target
) {
throw new Error(
`@loader() must be used in combination with @attribute() (property: '${name}')`
);
}
target.getStorableAttribute(name).setLoader(loader);
};
}
/**
* Decorates a storable attribute or method with a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type).
*
* @param finder A function representing the [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) of the storable attribute or method.
*
* @examplelink See an example of use in the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) and [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method) classes.
*
* @category Decorators
* @decorator
*/
export function finder(finder: StorablePropertyFinder) {
return function (target: StorableComponent, name: string) {
if (!isStorableInstance(target)) {
throw new Error(
`@finder() must be used as a storable component property decorator (property: '${name}')`
);
}
if (
!target.hasStorableProperty(name) ||
target.getStorableProperty(name, {autoFork: false}).getParent() !== target
) {
throw new Error(
`@finder() must be used in combination with @attribute() or @method() (property: '${name}')`
);
}
target.getStorableProperty(name).setFinder(finder);
};
}
type ClassIndexParam = IndexAttributes;
type ClassIndexOptions = {isUnique?: boolean};
type AttributeIndexParam = {direction?: SortDirection; isUnique?: boolean};
/**
* Defines an [index](https://layrjs.com/docs/v2/reference/index) for an attribute or a set of attributes.
*
* This decorator is commonly placed before a storable component attribute to define a [single attribute index](https://layrjs.com/docs/v2/reference/index#single-attribute-indexes), but it can also be placed before a storable component class to define a [compound attribute index](https://layrjs.com/docs/v2/reference/index#compound-attribute-indexes).
*
* @param [optionsOrAttributes] Depends on the type of index you want to define (see below).
* @param [options] An object specifying some options in the case of compound attribute index (see below).
*
* @details
* ###### Single Attribute Indexes
*
* You can define an index for a single attribute by placing the `@index()` decorator before an attribute definition. In this case, you can specify the following parameters:
*
* - `options`:
* - `direction`: A string representing the sort direction of the index. The possible values are `'asc'` (ascending) or `'desc'` (descending) and the default value is `'asc'`.
* - `isUnique`: A boolean specifying whether the index should hold unique values or not (default: `false`). When set to `true`, the underlying database will prevent you to store an attribute with the same value in multiple storable components.
*
* ###### Compound Attribute Indexes
*
* You can define an index that combines multiple attributes by placing the `@index()` decorator before a storable component class definition. In this case, you can specify the following parameters:
*
* - `attributes`: An object specifying the attributes to be indexed. The shape of the object should be `{attributeName: direction, ...}` where `attributeName` is a string representing the name of an attribute and `direction` is a string representing the sort direction (possible values: `'asc'` or `'desc'`).
* - `options`:
* - `isUnique`: A boolean specifying whether the index should hold unique values or not (default: `false`). When set to `true`, the underlying database will prevent you to store an attribute with the same value in multiple storable components.
*
* @example
* ```
* // JS
*
* import {Component} from '@layr/component';
* import {Storable, attribute, index} from '@layr/storable';
*
* // An index that combines the `year` and `title` attributes:
* ﹫index({year: 'desc', title: 'asc'})
* export class Movie extends Storable(Component) {
* // An index for the `title` attribute with the `isUnique` option:
* @index({isUnique: true}) @attribute('string') title;
*
* // An index for the `year` attribute with the `'desc'` sort direction:
* @index({direction: 'desc'}) @attribute('number') year;
* }
* ```
*
* @example
* ```
* // TS
*
* import {Component} from '@layr/component';
* import {Storable, attribute, index} from '@layr/storable';
*
* // An index that combines the `year` and `title` attributes:
* ﹫index({year: 'desc', title: 'asc'})
* export class Movie extends Storable(Component) {
* // An index for the `title` attribute with the `isUnique` option:
* @index({isUnique: true}) @attribute('string') title!: string;
*
* // An index for the `year` attribute with the `'desc'` sort direction:
* @index({direction: 'desc'}) @attribute('number') year!: string;
* }
* ```
*
* @category Decorators
* @decorator
*/
export function index(
param?: AttributeIndexParam
): (target: StorableComponent, name: string) => void;
export function index(
param: ClassIndexParam,
options?: ClassIndexOptions
): (target: typeof StorableComponent) => void;
export function index(
param: ClassIndexParam | AttributeIndexParam = {},
options: ClassIndexOptions = {}
) {
return function (target: typeof StorableComponent | StorableComponent, name?: string) {
if (name === undefined) {
// Class decorator
if (!isStorableClass(target)) {
throw new Error(
`@index() must be used as a storable component class decorator or a storable component attribute decorator`
);
}
target.prototype.setIndex(param as ClassIndexParam, options);
return;
}
// Attribute decorator
if (!isStorableInstance(target)) {
throw new Error(
`@index() must be used as a storable component class decorator or a storable component attribute decorator (property: '${name}')`
);
}
if (
!target.hasProperty(name) ||
target.getProperty(name, {autoFork: false}).getParent() !== target
) {
throw new Error(
`@index() must be used in combination with @attribute() (property: '${name}')`
);
}
const {direction = 'asc', isUnique} = param as AttributeIndexParam;
target.setIndex({[name]: direction}, {isUnique});
};
}
================================================
FILE: packages/storable/src/index-class.test.ts
================================================
import {Component, EmbeddedComponent, provide} from '@layr/component';
import {Storable} from './storable';
import {primaryIdentifier, secondaryIdentifier, attribute, loader, method} from './decorators';
import {Index} from './index-class';
describe('Index', () => {
test('Creation', async () => {
class Person extends Storable(Component) {
@primaryIdentifier() id!: string;
@attribute('string') fullName!: string;
}
class MovieDetails extends Storable(EmbeddedComponent) {
@attribute('number') duration!: number;
@attribute('string') aspectRatio!: string;
}
class Movie extends Storable(Component) {
@provide() static Person = Person;
@provide() static MovieDetails = MovieDetails;
@primaryIdentifier() id!: string;
@secondaryIdentifier() slug!: string;
@attribute('string') title!: string;
@attribute('number') year!: number;
@loader(async function (this: Movie) {
await this.load({year: true});
return this.year <= new Date().getFullYear();
})
@attribute('boolean')
isReleased!: boolean;
@attribute('object') infos!: any;
@attribute('object[]') history!: any[];
@attribute('Person') director!: Person;
@attribute('Person[]') actors!: Person[];
@attribute('MovieDetails') details!: MovieDetails;
@method() play() {}
}
let index = new Index({title: 'asc'}, Movie.prototype);
expect(Index.isIndex(index)).toBe(true);
expect(index.getAttributes()).toStrictEqual({title: 'asc'});
expect(index.getParent()).toBe(Movie.prototype);
expect(index.getOptions().isUnique).not.toBe(true);
index = new Index({title: 'desc'}, Movie.prototype, {isUnique: true});
expect(Index.isIndex(index)).toBe(true);
expect(index.getAttributes()).toStrictEqual({title: 'desc'});
expect(index.getOptions().isUnique).toBe(true);
index = new Index({year: 'desc', id: 'asc'}, Movie.prototype);
expect(Index.isIndex(index)).toBe(true);
expect(index.getAttributes()).toStrictEqual({year: 'desc', id: 'asc'});
expect(() => new Index({}, Movie.prototype)).toThrow(
"Cannot create an index for an empty 'attributes' parameter (component: 'Movie')"
);
index = new Index({duration: 'asc'}, MovieDetails.prototype);
expect(Index.isIndex(index)).toBe(true);
expect(index.getAttributes()).toStrictEqual({duration: 'asc'});
expect(index.getParent()).toBe(MovieDetails.prototype);
expect(index.getOptions().isUnique).not.toBe(true);
// @ts-expect-error
expect(() => new Index('title', Movie.prototype)).toThrow(
"Expected a plain object, but received a value of type 'string'"
);
// @ts-expect-error
expect(() => new Index({title: 'asc'}, Movie)).toThrow(
"Expected a storable component instance, but received a value of type 'typeof Movie'"
);
expect(() => new Index({country: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute that doesn't exist (component: 'Movie', attribute: 'country')"
);
expect(() => new Index({play: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for a property that is not an attribute (component: 'Movie', property: 'play')"
);
expect(() => new Index({id: 'asc'}, Movie.prototype)).toThrow(
"Cannot explicitly create an index for an identifier attribute (component: 'Movie', attribute: 'id'). Note that this type of attribute is automatically indexed."
);
expect(() => new Index({slug: 'asc'}, Movie.prototype)).toThrow(
"Cannot explicitly create an index for an identifier attribute (component: 'Movie', attribute: 'slug'). Note that this type of attribute is automatically indexed."
);
expect(() => new Index({infos: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute of type 'object' (component: 'Movie', attribute: 'infos')"
);
expect(() => new Index({history: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute of type 'object' (component: 'Movie', attribute: 'history')"
);
expect(() => new Index({director: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute of type 'Component' (component: 'Movie', attribute: 'director'). Note that primary identifier attributes of referenced components are automatically indexed."
);
expect(() => new Index({actors: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute of type 'Component' (component: 'Movie', attribute: 'actors'). Note that primary identifier attributes of referenced components are automatically indexed."
);
expect(() => new Index({details: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for an attribute of type 'Component' (component: 'Movie', attribute: 'details'). Note that primary identifier attributes of referenced components are automatically indexed."
);
expect(() => new Index({isReleased: 'asc'}, Movie.prototype)).toThrow(
"Cannot create an index for a computed attribute (component: 'Movie', attribute: 'isReleased')"
);
// @ts-expect-error
expect(() => new Index({title: 'ASC'}, Movie.prototype)).toThrow(
"Cannot create an index with an invalid sort direction (component: 'Movie', attribute: 'title', sort direction: 'ASC')"
);
// @ts-expect-error
expect(() => new Index({title: 'asc'}, Movie.prototype, {unknownOption: 123})).toThrow(
"Did not expect the option 'unknownOption' to exist"
);
});
});
================================================
FILE: packages/storable/src/index-class.ts
================================================
import {
isAttributeInstance,
isIdentifierAttributeInstance,
isComponentValueTypeInstance,
isObjectValueTypeInstance
} from '@layr/component';
import {assertIsPlainObject, assertNoUnknownOptions} from 'core-helpers';
import type {StorableComponent, SortDirection} from './storable';
import {isStorableAttributeInstance} from './properties/storable-attribute';
import {assertIsStorableInstance} from './utilities';
export type IndexAttributes = {[name: string]: SortDirection};
export type IndexOptions = {isUnique?: boolean};
/**
* Represents an index for one or several [attributes](https://layrjs.com/docs/v2/reference/attribute) of a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class).
*
* Once an index is defined for an attribute, all queries involving this attribute (through the [`find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method) or the [`count()`](https://layrjs.com/docs/v2/reference/storable#count-class-method) methods) can be greatly optimized by the storable component's [store](https://layrjs.com/docs/v2/reference/store) and its underlying database.
*
* #### Usage
*
* ##### Single Attribute Indexes
*
* Typically, you create an `Index` for a storable component's attribute by using the [`@index()`](https://layrjs.com/docs/v2/reference/storable#index-decorator) decorator. Then, you call the [`migrateStorables()`](https://layrjs.com/docs/v2/reference/store#migrate-storables-instance-method) method on the storable component's store to effectively create the index in the underlying database.
*
* For example, here is how you would define a `Movie` class with some indexes:
*
* ```js
* // JS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute, index} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* export class Movie extends Storable(Component) {
* // Primary and secondary identifier attributes are automatically indexed,
* // so there is no need to define an index for these types of attributes
* @primaryIdentifier() id;
*
* // Let's define an index for the `title` attribute
* @index() @attribute('string') title;
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* ```ts
* // TS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute, index} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* export class Movie extends Storable(Component) {
* // Primary and secondary identifier attributes are automatically indexed,
* // so there is no need to define an index for these types of attributes
* @primaryIdentifier() id!: string;
*
* // Let's define an index for the `title` attribute
* @index() @attribute('string') title!: string;
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* Then you can call the [`migrateStorables()`](https://layrjs.com/docs/v2/reference/store#migrate-storables-instance-method) method on the store to create the indexes in the MongoDB database:
*
* ```
* await store.migrateStorables();
* ```
*
* And now that the `title` attribute is indexed, you can make any query on this attribute in a very performant way:
*
* ```
* const movies = await Movie.find({title: 'Inception'});
* ```
*
* ##### Compound Attribute Indexes
*
* You can create a compound attribute index to optimize some queries that involve a combination of attributes. To do so, you use the [`@index()`](https://layrjs.com/docs/v2/reference/storable#index-decorator) decorator on the storable component itself:
*
* ```js
* // JS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute, index} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* // Let's define a compound attribute index for the combination of the `year`
* // attribute (descending order) and the `title` attribute (ascending order)
* ﹫index({year: 'desc', title: 'asc'})
* export class Movie extends Storable(Component) {
* @primaryIdentifier() id;
*
* @attribute('string') title;
*
* @attribute('number') year;
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* ```ts
* // TS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute, index} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* // Let's define a compound attribute index for the combination of the `year`
* // attribute (descending order) and the `title` attribute (ascending order)
* ﹫index({year: 'desc', title: 'asc'})
* export class Movie extends Storable(Component) {
* @primaryIdentifier() id!: string;
*
* @attribute('string') title!: string;
*
* @attribute('number') year!: number;
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* Then you can call the [`migrateStorables()`](https://layrjs.com/docs/v2/reference/store#migrate-storables-instance-method) method on the store to create the compound attribute index in the MongoDB database:
*
* ```
* await store.migrateStorables();
* ```
*
* And now you can make any query involving a combination of `year` and `title` in a very performant way:
*
* ```
* const movies = await Movie.find(
* {year: {$greaterThan: 2010}},
* true,
* {sort: {year: 'desc', title: 'asc'}}
* );
* ```
*/
export class Index {
_attributes: IndexAttributes;
_parent: StorableComponent;
_options!: IndexOptions;
/**
* Creates an instance of [`Index`](https://layrjs.com/docs/v2/reference/index).
*
* @param attributes An object specifying the attributes to be indexed. The shape of the object should be `{attributeName: direction, ...}` where `attributeName` is a string representing the name of an attribute and `direction` is a string representing the sort direction (possible values: `'asc'` or `'desc'`).
* @param parent The storable component prototype that owns the index.
* @param [options.isUnique] A boolean specifying whether the index should hold unique values or not (default: `false`). When set to `true`, the underlying database will prevent you to store an attribute with the same value in multiple storable components.
*
* @returns The [`Index`](https://layrjs.com/docs/v2/reference/index) instance that was created.
*
* @category Creation
*/
constructor(attributes: IndexAttributes, parent: StorableComponent, options: IndexOptions = {}) {
assertIsPlainObject(attributes);
assertIsStorableInstance(parent);
for (const [name, direction] of Object.entries(attributes)) {
if (!parent.hasProperty(name)) {
throw new Error(
`Cannot create an index for an attribute that doesn't exist (${parent.describeComponent()}, attribute: '${name}')`
);
}
const property = parent.getProperty(name, {autoFork: false});
if (!isAttributeInstance(property)) {
throw new Error(
`Cannot create an index for a property that is not an attribute (${parent.describeComponent()}, property: '${name}')`
);
}
if (isStorableAttributeInstance(property) && property.isComputed()) {
throw new Error(
`Cannot create an index for a computed attribute (${parent.describeComponent()}, attribute: '${name}')`
);
}
const scalarType = property.getValueType().getScalarType();
if (isObjectValueTypeInstance(scalarType)) {
throw new Error(
`Cannot create an index for an attribute of type 'object' (${parent.describeComponent()}, attribute: '${name}')`
);
}
if (!(direction === 'asc' || direction === 'desc')) {
throw new Error(
`Cannot create an index with an invalid sort direction (${parent.describeComponent()}, attribute: '${name}', sort direction: '${direction}')`
);
}
}
if (Object.keys(attributes).length === 0) {
throw new Error(
`Cannot create an index for an empty 'attributes' parameter (${parent.describeComponent()})`
);
}
if (Object.keys(attributes).length === 1) {
const name = Object.keys(attributes)[0];
const attribute = parent.getAttribute(name, {autoFork: false});
if (isIdentifierAttributeInstance(attribute)) {
throw new Error(
`Cannot explicitly create an index for an identifier attribute (${parent.describeComponent()}, attribute: '${name}'). Note that this type of attribute is automatically indexed.`
);
}
const scalarType = attribute.getValueType().getScalarType();
if (isComponentValueTypeInstance(scalarType)) {
throw new Error(
`Cannot create an index for an attribute of type 'Component' (${parent.describeComponent()}, attribute: '${name}'). Note that primary identifier attributes of referenced components are automatically indexed.`
);
}
}
this._attributes = attributes;
this._parent = parent;
this.setOptions(options);
}
/**
* Returns the indexed attributes.
*
* @returns An object of the shape `{attributeName: direction, ...}`.
*
* @category Basic Methods
*/
getAttributes() {
return this._attributes;
}
/**
* Returns the parent of the index.
*
* @returns A storable component prototype.
*
* @category Basic Methods
*/
getParent() {
return this._parent;
}
// === Options ===
getOptions() {
return this._options;
}
setOptions(options: IndexOptions = {}) {
const {isUnique, ...unknownOptions} = options;
assertNoUnknownOptions(unknownOptions);
this._options = {isUnique};
}
// === Forking ===
fork(parent: StorableComponent) {
const indexFork = Object.create(this) as Index;
indexFork._parent = parent;
return indexFork;
}
// === Utilities ===
static isIndex(value: any): value is Index {
return isIndexInstance(value);
}
static _buildIndexKey(attributes: IndexAttributes) {
return JSON.stringify(attributes);
}
}
/**
* Returns whether the specified value is an `Index` class.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isIndexClass(value: any): value is typeof Index {
return typeof value?.isIndex === 'function';
}
/**
* Returns whether the specified value is an `Index` instance.
*
* @param value A value of any type.
*
* @returns A boolean.
*
* @category Utilities
*/
export function isIndexInstance(value: any): value is Index {
return isIndexClass(value?.constructor) === true;
}
================================================
FILE: packages/storable/src/index.ts
================================================
export * from './decorators';
export * from './index-class';
export * from './properties';
export * from './operator';
export * from './query';
export * from './storable';
export * from './store-like';
export * from './utilities';
================================================
FILE: packages/storable/src/js-tests/decorators.test.js
================================================
import {Component} from '@layr/component';
import {Storable} from '../storable';
import {attribute, method, loader, finder, index} from '../decorators';
import {isStorableAttributeInstance, isStorableMethodInstance} from '../properties';
import {isIndexInstance} from '../index-class';
describe('Decorators', () => {
test('@attribute()', async () => {
const beforeLoadHook = function () {};
const beforeSaveHook = function () {};
class Movie extends Storable(Component) {
@attribute('number', {beforeLoad: beforeLoadHook}) static limit = 100;
@attribute('string', {beforeSave: beforeSaveHook}) title = '';
}
const limitAttribute = Movie.getStorableAttribute('limit');
expect(isStorableAttributeInstance(limitAttribute)).toBe(true);
expect(limitAttribute.getName()).toBe('limit');
expect(limitAttribute.getParent()).toBe(Movie);
expect(limitAttribute.getHook('beforeLoad')).toBe(beforeLoadHook);
expect(limitAttribute.hasHook('beforeLoad')).toBe(true);
expect(limitAttribute.hasHook('beforeSave')).toBe(false);
const titleAttribute = Movie.prototype.getStorableAttribute('title');
expect(isStorableAttributeInstance(titleAttribute)).toBe(true);
expect(titleAttribute.getName()).toBe('title');
expect(titleAttribute.getParent()).toBe(Movie.prototype);
expect(titleAttribute.getHook('beforeSave')).toBe(beforeSaveHook);
expect(titleAttribute.hasHook('beforeSave')).toBe(true);
expect(titleAttribute.hasHook('beforeLoad')).toBe(false);
});
test('@loader()', async () => {
const limitLoader = function () {};
const titleLoader = function () {};
class Movie extends Storable(Component) {
@loader(limitLoader) @attribute('number?') static limit;
@loader(titleLoader) @attribute('string') title = '';
}
const limitAttribute = Movie.getStorableAttribute('limit');
expect(isStorableAttributeInstance(limitAttribute)).toBe(true);
expect(limitAttribute.getName()).toBe('limit');
expect(limitAttribute.getParent()).toBe(Movie);
expect(limitAttribute.getLoader()).toBe(limitLoader);
expect(limitAttribute.hasLoader()).toBe(true);
const titleAttribute = Movie.prototype.getStorableAttribute('title');
expect(isStorableAttributeInstance(titleAttribute)).toBe(true);
expect(titleAttribute.getName()).toBe('title');
expect(titleAttribute.getParent()).toBe(Movie.prototype);
expect(titleAttribute.getLoader()).toBe(titleLoader);
expect(titleAttribute.hasLoader()).toBe(true);
});
test('@finder()', async () => {
const hasNoAccessFinder = function () {
return {};
};
const hasAccessLevelFinder = function () {
return {};
};
class Movie extends Storable(Component) {
@finder(hasNoAccessFinder) @attribute('boolean?') hasNoAccess;
@finder(hasAccessLevelFinder) @method() hasAccessLevel() {}
}
const hasNoAccessAttribute = Movie.prototype.getStorableAttribute('hasNoAccess');
expect(isStorableAttributeInstance(hasNoAccessAttribute)).toBe(true);
expect(hasNoAccessAttribute.getName()).toBe('hasNoAccess');
expect(hasNoAccessAttribute.getParent()).toBe(Movie.prototype);
expect(hasNoAccessAttribute.getFinder()).toBe(hasNoAccessFinder);
expect(hasNoAccessAttribute.hasFinder()).toBe(true);
const hasAccessLevelMethod = Movie.prototype.getStorableMethod('hasAccessLevel');
expect(isStorableMethodInstance(hasAccessLevelMethod)).toBe(true);
expect(hasAccessLevelMethod.getName()).toBe('hasAccessLevel');
expect(hasAccessLevelMethod.getParent()).toBe(Movie.prototype);
expect(hasAccessLevelMethod.getFinder()).toBe(hasAccessLevelFinder);
expect(hasAccessLevelMethod.hasFinder()).toBe(true);
});
test('@index()', async () => {
@index({year: 'desc', title: 'asc'}, {isUnique: true})
class Movie extends Storable(Component) {
@index({isUnique: true}) @attribute('string') title;
@index({direction: 'desc'}) @attribute('number') year;
}
const titleIndex = Movie.prototype.getIndex({title: 'asc'});
expect(isIndexInstance(titleIndex)).toBe(true);
expect(titleIndex.getAttributes()).toStrictEqual({title: 'asc'});
expect(titleIndex.getParent()).toBe(Movie.prototype);
expect(titleIndex.getOptions().isUnique).toBe(true);
const yearIndex = Movie.prototype.getIndex({year: 'desc'});
expect(isIndexInstance(yearIndex)).toBe(true);
expect(yearIndex.getAttributes()).toStrictEqual({year: 'desc'});
expect(yearIndex.getParent()).toBe(Movie.prototype);
expect(yearIndex.getOptions().isUnique).not.toBe(true);
const compoundIndex = Movie.prototype.getIndex({year: 'desc', title: 'asc'});
expect(isIndexInstance(compoundIndex)).toBe(true);
expect(compoundIndex.getAttributes()).toStrictEqual({year: 'desc', title: 'asc'});
expect(compoundIndex.getParent()).toBe(Movie.prototype);
expect(compoundIndex.getOptions().isUnique).toBe(true);
});
});
================================================
FILE: packages/storable/src/js-tests/jsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2017",
"module": "CommonJS",
"checkJs": true,
"experimentalDecorators": true
}
}
================================================
FILE: packages/storable/src/operator.ts
================================================
import {getTypeOf} from 'core-helpers';
import type {Query} from './query';
export type Operator = string;
const basicOperators = new Set([
'$equal',
'$notEqual',
'$greaterThan',
'$greaterThanOrEqual',
'$lessThan',
'$lessThanOrEqual',
'$in'
]);
const stringOperators = new Set(['$includes', '$startsWith', '$endsWith', '$matches']);
const arrayOperators = new Set(['$some', '$every', '$length']);
const logicalOperators = new Set(['$not', '$and', '$or', '$nor']);
const aliases = new Map(Object.entries({}));
export function looksLikeOperator(string: string): string is Operator {
return string.startsWith('$');
}
export function normalizeOperatorForValue(
operator: Operator,
value: unknown,
{query}: {query: Query}
): Operator {
const alias = aliases.get(operator);
if (alias !== undefined) {
operator = alias;
}
if (basicOperators.has(operator)) {
return normalizeBasicOperatorForValue(operator, value, {query});
}
if (stringOperators.has(operator)) {
return normalizeStringOperatorForValue(operator, value, {query});
}
if (arrayOperators.has(operator)) {
return normalizeArrayOperatorForValue(operator, value, {query});
}
if (logicalOperators.has(operator)) {
return normalizeLogicalOperatorForValue(operator, value, {query});
}
throw new Error(
`A query contains an operator that is not supported (operator: '${operator}', query: '${JSON.stringify(
query
)}')`
);
}
function normalizeBasicOperatorForValue(
operator: Operator,
value: unknown,
{query}: {query: Query}
): Operator {
if (operator === '$in') {
if (!Array.isArray(value)) {
throw new Error(
`Expected an array as value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
if (typeof value === 'object' && !(value === null || value instanceof Date)) {
throw new Error(
`Expected a scalar value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
function normalizeStringOperatorForValue(
operator: Operator,
value: unknown,
{query}: {query: Query}
): Operator {
if (operator === '$matches') {
if (!(value instanceof RegExp)) {
throw new Error(
`Expected a regular expression as value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
if (typeof value !== 'string') {
throw new Error(
`Expected a string as value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
function normalizeArrayOperatorForValue(
operator: Operator,
value: unknown,
{query}: {query: Query}
): Operator {
if (operator === '$length') {
if (typeof value !== 'number') {
throw new Error(
`Expected a number as value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
return operator;
}
function normalizeLogicalOperatorForValue(
operator: Operator,
value: unknown,
{query}: {query: Query}
): Operator {
if (operator === '$and' || operator === '$or' || operator === '$nor') {
if (!Array.isArray(value)) {
throw new Error(
`Expected an array as value of the operator '${operator}', but received a value of type '${getTypeOf(
value
)}' (query: '${JSON.stringify(query)}')`
);
}
return operator;
}
return operator;
}
================================================
FILE: packages/storable/src/properties/index.ts
================================================
export * from './storable-attribute';
export * from './storable-method';
export * from './storable-primary-identifier-attribute';
export * from './storable-property';
export * from './storable-secondary-identifier-attribute';
================================================
FILE: packages/storable/src/properties/storable-attribute.test.ts
================================================
import {Component} from '@layr/component';
import {Storable} from '../storable';
import {StorableAttribute, isStorableAttributeInstance} from './storable-attribute';
describe('StorableAttribute', () => {
test('new StorableAttribute()', async () => {
class Movie extends Storable(Component) {}
let loaderHasBeenCalled = false;
let finderHasBeenCalled = false;
let beforeLoadHasBeenCalled = false;
const storableAttribute = new StorableAttribute('title', Movie.prototype, {
valueType: 'string',
async loader() {
expect(this).toBe(Movie.prototype);
loaderHasBeenCalled = true;
},
async finder() {
expect(this).toBe(Movie.prototype);
finderHasBeenCalled = true;
},
async beforeLoad() {
expect(this).toBe(Movie.prototype);
beforeLoadHasBeenCalled = true;
}
});
expect(isStorableAttributeInstance(storableAttribute)).toBe(true);
expect(storableAttribute.getName()).toBe('title');
expect(storableAttribute.getParent()).toBe(Movie.prototype);
expect(storableAttribute.hasLoader()).toBe(true);
expect(storableAttribute.hasFinder()).toBe(true);
expect(storableAttribute.isComputed()).toBe(true);
expect(storableAttribute.hasHook('beforeLoad')).toBe(true);
expect(loaderHasBeenCalled).toBe(false);
await storableAttribute.callLoader();
expect(loaderHasBeenCalled).toBe(true);
expect(finderHasBeenCalled).toBe(false);
await storableAttribute.callFinder(1);
expect(finderHasBeenCalled).toBe(true);
expect(beforeLoadHasBeenCalled).toBe(false);
await storableAttribute.callHook('beforeLoad');
expect(beforeLoadHasBeenCalled).toBe(true);
const otherStorableAttribute = new StorableAttribute('country', Movie.prototype, {
valueType: 'string'
});
expect(otherStorableAttribute.hasLoader()).toBe(false);
expect(otherStorableAttribute.hasFinder()).toBe(false);
expect(otherStorableAttribute.isComputed()).toBe(false);
await expect(otherStorableAttribute.callLoader()).rejects.toThrow(
"Cannot call a loader that is missing (attribute: 'Movie.prototype.country')"
);
await expect(otherStorableAttribute.callFinder(1)).rejects.toThrow(
"Cannot call a finder that is missing (attribute: 'Movie.prototype.country')"
);
expect(otherStorableAttribute.hasHook('beforeLoad')).toBe(false);
await expect(otherStorableAttribute.callHook('beforeLoad')).rejects.toThrow(
"Cannot call a hook that is missing (attribute: 'Movie.prototype.country', hook: 'beforeLoad')"
);
});
test('Introspection', async () => {
class Movie extends Storable(Component) {}
expect(
new StorableAttribute('limit', Movie, {
valueType: 'number',
value: 100,
exposure: {get: true}
}).introspect()
).toStrictEqual({
name: 'limit',
type: 'StorableAttribute',
valueType: 'number',
value: 100,
exposure: {get: true}
});
});
test('Unintrospection', async () => {
expect(
StorableAttribute.unintrospect({
name: 'limit',
type: 'StorableAttribute',
valueType: 'number',
value: 100,
exposure: {get: true}
})
).toEqual({
name: 'limit',
options: {valueType: 'number', value: 100, exposure: {get: true}}
});
});
});
================================================
FILE: packages/storable/src/properties/storable-attribute.ts
================================================
import {Attribute} from '@layr/component';
import type {Component, AttributeOptions} from '@layr/component';
import {PromiseLikeable, hasOwnProperty, Constructor} from 'core-helpers';
// TODO: Find a way to remove this useless import
// I did that to remove a TypeScript error in the generated declaration file
// @ts-ignore
import type {Property} from '@layr/component';
import {StorablePropertyMixin, StorablePropertyOptions} from './storable-property';
import {assertIsStorableClassOrInstance} from '../utilities';
export type StorableAttributeOptions = StorablePropertyOptions &
AttributeOptions & {
loader?: StorableAttributeLoader;
beforeLoad?: StorableAttributeHook;
afterLoad?: StorableAttributeHook;
beforeSave?: StorableAttributeHook;
afterSave?: StorableAttributeHook;
beforeDelete?: StorableAttributeHook;
afterDelete?: StorableAttributeHook;
};
export type StorableAttributeLoader = () => PromiseLikeable;
export type StorableAttributeHook = (attribute: StorableAttribute) => PromiseLikeable;
export type StorableAttributeHookName =
| 'beforeLoad'
| 'afterLoad'
| 'beforeSave'
| 'afterSave'
| 'beforeDelete'
| 'afterDelete';
export const StorableAttributeMixin = >(Base: T) =>
/**
* @name StorableAttribute
*
* *Inherits from [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) and [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property).*
*
* The `StorableAttribute` class extends the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute) class with some capabilities such as [computed attributes](https://layrjs.com/docs/v2/reference/storable-attribute#computed-attributes) or [hooks](https://layrjs.com/docs/v2/reference/storable-attribute#hooks).
*
* #### Usage
*
* Typically, you create a `StorableAttribute` and associate it to a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) using the [`@attribute()`](https://layrjs.com/docs/v2/reference/storable#attribute-decorator) decorator.
*
* For example, here is how you would define a `Movie` component with some attributes:
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id;
*
* ﹫attribute('string') title = '';
*
* ﹫attribute('number') rating;
*
* ﹫attribute('Date') releaseDate;
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id!: string;
*
* ﹫attribute('string') title = '';
*
* ﹫attribute('number') rating!: number;
*
* ﹫attribute('Date') releaseDate!: Date;
* }
* ```
*
* So far we've defined some storable attributes in the same way we would do with [regular attributes](https://layrjs.com/docs/v2/reference/attribute). The only difference is that we imported the [`@attribute()`](https://layrjs.com/docs/v2/reference/storable#attribute-decorator) decorator from `﹫layr/storable` instead of `﹫layr/component`.
*
* Let's now see how to take advantage of some capabilities that are unique to storable attributes.
*
* ##### Computed Attributes
*
* A computed attribute is a special kind of component attribute that computes its value when the component is loaded with a storable component method such as [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method), [`get()`](https://layrjs.com/docs/v2/reference/storable#get-class-method), or [`find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method).
*
* The value of a computed attribute shouldn't be set manually, and is not persisted when you [save](https://layrjs.com/docs/v2/reference/storable#save-instance-method) a component to a store.
*
* ###### Loaders
*
* Use the [`@loader()`](https://layrjs.com/docs/v2/reference/storable#loader-decorator) decorator to specify the function that computes the value of a computed attribute.
*
* For example, let's define a `isTrending` computed attribute that determines its value according to the movie's `rating` and `releaseDate`:
*
* ```
* // JS
*
* import {loader} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* @loader(async function() {
* await this.load({rating: true, releaseDate: true});
*
* const ratingLimit = 7;
* const releaseDateLimit = new Date(Date.now() - 864000000); // 10 days before
*
* return this.rating >= ratingLimit && this.releaseDate >= releaseDateLimit;
* })
* @attribute('boolean')
* isTrending;
* }
* ```
*
* ```
* // TS
*
* import {loader} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* @loader(async function(this: Movie) {
* await this.load({rating: true, releaseDate: true});
*
* const ratingLimit = 7;
* const releaseDateLimit = new Date(Date.now() - 864000000); // 10 days before
*
* return this.rating >= ratingLimit && this.releaseDate >= releaseDateLimit;
* })
* @attribute('boolean')
* isTrending!: boolean;
* }
* ```
*
* Then, when we get a movie, we can get the `isTrending` computed attribute like any attribute:
*
* ```
* const movie = await Movie.get('abc123', {title: true, isTrending: true});
*
* movie.title; // => 'Inception'
* movie.isTrending; // => true (on July 16th, 2010)
* ```
*
* ###### Finders
*
* The best thing about computed attributes is that they can be used in a [`Query`](https://layrjs.com/docs/v2/reference/query) when you are [finding](https://layrjs.com/docs/v2/reference/storable#find-class-method) or [counting](https://layrjs.com/docs/v2/reference/storable#count-class-method) some storable components.
*
* To enable that, use the [`@finder()`](https://layrjs.com/docs/v2/reference/storable#finder-decorator) decorator, and specify a function that returns a [`Query`](https://layrjs.com/docs/v2/reference/query).
*
* For example, let's make our `isTrending` attribute searchable by adding a finder:
*
* ```
* // JS
*
* import {loader} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* @loader(
* // ...
* )
* @finder(function (isTrending) {
* const ratingLimit = 7;
* const releaseDateLimit = new Date(Date.now() - 864000000); // 10 days before
*
* if (isTrending) {
* // Return a query for `{isTrending: true}`
* return {
* rating: {$greaterThanOrEqual: ratingLimit}
* releaseDate: {$greaterThanOrEqual: releaseDateLimit}
* };
* }
*
* // Return a query for `{isTrending: false}`
* return {
* $or: [
* {rating: {$lessThan: ratingLimit}},
* {releaseDate: {$lessThan: releaseDateLimit}}
* ]
* };
* })
* @attribute('boolean')
* isTrending;
* }
* ```
*
* ```
* // TS
*
* import {loader} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* @loader(
* // ...
* )
* @finder(function (isTrending: boolean) {
* const ratingLimit = 7;
* const releaseDateLimit = new Date(Date.now() - 864000000); // 10 days before
*
* if (isTrending) {
* // Return a query for `{isTrending: true}`
* return {
* rating: {$greaterThanOrEqual: ratingLimit}
* releaseDate: {$greaterThanOrEqual: releaseDateLimit}
* };
* }
*
* // Return a query for `{isTrending: false}`
* return {
* $or: [
* {rating: {$lessThan: ratingLimit}},
* {releaseDate: {$lessThan: releaseDateLimit}}
* ]
* };
* })
* @attribute('boolean')
* isTrending!: boolean;
* }
* ```
*
* And now, we can query our `isTrending` computed attribute like we would do with any attribute:
*
* ```
* await Movie.find({isTrending: true}); // => All trending movies
* await Movie.find({isTrending: false}); // => All non-trending movies
*
* await Movie.count({isTrending: true}); // => Number of trending movies
* await Movie.count({isTrending: false}); // => Number of non-trending movies
*
* // Combine computed attributes with regular attributes to find
* // all Japanese trending movies
* await Movie.find({country: 'Japan', isTrending: true});
* ```
*
* ##### Hooks
*
* Storable attributes offer a number of hooks that you can use to execute some custom logic when an attribute is loaded, saved or deleted.
*
* To define a hook for a storable attribute, use one of the following [`@attribute()`](https://layrjs.com/docs/v2/reference/storable#attribute-decorator) options:
*
* - `beforeLoad`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *before* an attribute is *loaded*.
* - `afterLoad`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *after* an attribute is *loaded*.
* - `beforeSave`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *before* an attribute is *saved*.
* - `afterSave`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *after* an attribute is *saved*.
* - `beforeDelete`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *before* an attribute is *deleted*.
* - `afterDelete`: Specifies a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) to be executed *after* an attribute is *deleted*.
*
* For example, we could use a `beforeSave` hook to make sure a user's password is hashed before it is saved to a store:
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
* import bcrypt from 'bcryptjs';
*
* class User extends Storable(Component) {
* ﹫primaryIdentifier() id;
*
* ﹫attribute('string') username;
*
* ﹫attribute('string', {
* async beforeSave() {
* this.password = await bcrypt.hash(this.password);
* }
* })
* password;
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
* import bcrypt from 'bcryptjs';
*
* class User extends Storable(Component) {
* ﹫primaryIdentifier() id!: string;
*
* ﹫attribute('string') username!: string;
*
* ﹫attribute('string', {
* async beforeSave(this: User) {
* this.password = await bcrypt.hash(this.password);
* }
* })
* password!: string;
* }
* ```
*
* Then, when we save a user, its password gets automatically hashed:
* ```
* const user = new User({username: 'steve', password: 'zyx98765'});
*
* user.password; // => 'zyx98765'
*
* await user.save(); // The password will be hashed before saved to the store
*
* user.password; // => '$2y$12$AGJ91pnqlM7TcqnLg0iIFuiN80z9k.wFnGVl1a4lrANUepBKmvNVO'
*
* // Note that if we save the user again, as long as its password hasn't changed,
* // it will not be saved, and therefore not be hashed again
*
* user.username = 'steve2';
* await user.save(); // Only the username will be saved
*
* user.password; // => '$2y$12$AGJ91pnqlM7TcqnLg0iIFuiN80z9k.wFnGVl1a4lrANUepBKmvNVO'
* ```
*/
class extends StorablePropertyMixin(Base) {
/**
* @constructor
*
* Creates a storable attribute. Typically, instead of using this constructor, you would rather use the [`@attribute()`](https://layrjs.com/docs/v2/reference/storable#attribute-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) class, prototype, or instance that owns the attribute.
* @param [options.valueType] A string specifying the [type of values](https://layrjs.com/docs/v2/reference/value-type#supported-types) the attribute can store (default: `'any'`).
* @param [options.default] The default value (or a function returning the default value) of the attribute.
* @param [options.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the value of the attribute.
* @param [options.items.validators] An array of [validators](https://layrjs.com/docs/v2/reference/validator) for the items of an array attribute.
* @param [options.loader] A function specifying a [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) for the attribute.
* @param [options.finder] A function specifying a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) for the attribute.
* @param [options.beforeLoad] A function specifying a "beforeLoad" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.afterLoad] A function specifying an "afterLoad" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.beforeSave] A function specifying a "beforeSave" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.afterSave] A function specifying an "afterSave" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.beforeDelete] A function specifying a "beforeDelete" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.afterDelete] A function specifying an "afterDelete" [`hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the attribute.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the attribute should be exposed to remote access.
*
* @returns The [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) instance that was created.
*
* @category Creation
*/
// === Options ===
setOptions(options: StorableAttributeOptions = {}) {
const {
loader,
beforeLoad,
afterLoad,
beforeSave,
afterSave,
beforeDelete,
afterDelete,
...otherOptions
} = options;
if (loader !== undefined) {
this.setLoader(loader);
}
if (beforeLoad !== undefined) {
this.setHook('beforeLoad', beforeLoad);
}
if (afterLoad !== undefined) {
this.setHook('afterLoad', afterLoad);
}
if (beforeSave !== undefined) {
this.setHook('beforeSave', beforeSave);
}
if (afterSave !== undefined) {
this.setHook('afterSave', afterSave);
}
if (beforeDelete !== undefined) {
this.setHook('beforeDelete', beforeDelete);
}
if (afterDelete !== undefined) {
this.setHook('afterDelete', afterDelete);
}
super.setOptions(otherOptions);
}
// === 'isControlled' mark
isControlled() {
return super.isControlled() || this.isComputed();
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Attribute Methods ===
/**
* See the methods that are inherited from the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#value-type) class.
*
* @category Attribute Methods
*/
// === Loader ===
_loader: StorableAttributeLoader | undefined;
/**
* Returns the [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) of the attribute.
*
* @returns A [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) function (or `undefined` if the attribute has no associated loader).
*
* @category Loader
*/
getLoader() {
return this._loader;
}
/**
* Returns whether the attribute has a [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type).
*
* @returns A boolean.
*
* @category Loader
*/
hasLoader() {
return this.getLoader() !== undefined;
}
/**
* Sets a [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) for the attribute.
*
* @param loader The [`Loader`](https://layrjs.com/docs/v2/reference/storable-attribute#loader-type) function to set.
*
* @category Loader
*/
setLoader(loader: StorableAttributeLoader) {
this._loader = loader;
}
async callLoader() {
const loader = this.getLoader();
if (loader === undefined) {
throw new Error(`Cannot call a loader that is missing (${this.describe()})`);
}
return await loader.call(this.getParent());
}
/**
* @typedef Loader
*
* A function representing the "loader" of an attribute.
*
* The function should return a value for the attribute that is being loaded. Typically, you would return a value according to the value of some other attributes.
*
* The function can be `async` and is executed with the parent of the attribute as `this` context.
*
* See an example of use in the ["Computed Attributes"](https://layrjs.com/docs/v2/reference/storable-attribute#computed-attributes) section above.
*
* @category Loader
*/
// === Finder ===
/**
* See the methods that are inherited from the [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property#finder) class.
*
* @category Finder
*/
isComputed() {
return this.hasLoader() || this.hasFinder();
}
// === Hooks ===
/**
* Returns a specific [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the current attribute.
*
* @param name A string representing the name of the hook you want to get. The possible values are `'beforeLoad'`, `'afterLoad'`, `'beforeSave'`, `'afterSave'`, `'beforeDelete'`, and `'afterDelete'`.
*
* @returns A [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) function (or `undefined` if the attribute doesn't have a hook with the specified `name`).
*
* @category Hooks
*/
getHook(name: StorableAttributeHookName) {
return this._getHooks()[name];
}
/**
* Returns whether the current attribute has a specific [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type).
*
* @param name A string representing the name of the hook you want to check. The possible values are `'beforeLoad'`, `'afterLoad'`, `'beforeSave'`, `'afterSave'`, `'beforeDelete'`, and `'afterDelete'`.
*
* @returns A boolean.
*
* @category Hooks
*/
hasHook(name: StorableAttributeHookName) {
return name in this._getHooks();
}
/**
* Sets a [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) for the current attribute.
*
* @param name A string representing the name of the hook you want to set. The possible values are `'beforeLoad'`, `'afterLoad'`, `'beforeSave'`, `'afterSave'`, `'beforeDelete'`, and `'afterDelete'`.
* @param hook The [`Hook`](https://layrjs.com/docs/v2/reference/storable-attribute#hook-type) function to set.
*
* @category Hooks
*/
setHook(name: StorableAttributeHookName, hook: StorableAttributeHook) {
this._getHooks(true)[name] = hook;
}
async callHook(name: StorableAttributeHookName) {
const hook = this.getHook(name);
if (hook === undefined) {
throw new Error(`Cannot call a hook that is missing (${this.describe()}, hook: '${name}')`);
}
await hook.call(this.getParent(), this);
}
_hooks!: Partial>;
_getHooks(autoFork = false) {
if (this._hooks === undefined) {
Object.defineProperty(this, '_hooks', {
value: Object.create(null)
});
} else if (autoFork && !hasOwnProperty(this, '_hooks')) {
Object.defineProperty(this, '_hooks', {
value: Object.create(this._hooks)
});
}
return this._hooks;
}
/**
* @typedef Hook
*
* A function representing a "hook" of an attribute.
*
* According to the type of the hook, the function is automatically called when an attribute is loaded, saved or deleted.
*
* The function can be `async` and is invoked with the attribute as first parameter and the parent of the attribute (i.e., the storable component) as `this` context.
*
* See an example of use in the ["Hooks"](https://layrjs.com/docs/v2/reference/storable-attribute#hooks) section above.
*
* @category Hooks
*/
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Utilities ===
static isStorableAttribute(value: any): value is StorableAttribute {
return isStorableAttributeInstance(value);
}
};
export function isStorableAttributeClass(value: any): value is typeof StorableAttribute {
return typeof value?.isStorableAttribute === 'function';
}
export function isStorableAttributeInstance(value: any): value is StorableAttribute {
return isStorableAttributeClass(value?.constructor) === true;
}
export class StorableAttribute extends StorableAttributeMixin(Attribute) {
constructor(
name: string,
parent: typeof Component | Component,
options: StorableAttributeOptions = {}
) {
assertIsStorableClassOrInstance(parent);
super(name, parent, options);
}
}
================================================
FILE: packages/storable/src/properties/storable-method.ts
================================================
import {Method} from '@layr/component';
import type {Component, MethodOptions} from '@layr/component';
import {Constructor} from 'core-helpers';
// TODO: Find a way to remove this useless import
// I did that to remove a TypeScript error in the generated declaration file
// @ts-ignore
import type {Property} from '@layr/component';
import {StorablePropertyMixin, StorablePropertyOptions} from './storable-property';
import {assertIsStorableClassOrInstance} from '../utilities';
export type StorableMethodOptions = StorablePropertyOptions & MethodOptions;
export const StorableMethodMixin = >(Base: T) =>
/**
* @name StorableMethod
*
* *Inherits from [`Method`](https://layrjs.com/docs/v2/reference/method) and [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property).*
*
* The `StorableMethod` class extends the [`Method`](https://layrjs.com/docs/v2/reference/method) class with the capabilities of the [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property) class.
*
* In a nutshell, using the `StorableMethod` class allows you to associate a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) to a method so this method can be used in a [`Query`](https://layrjs.com/docs/v2/reference/query).
*
*
* #### Usage
*
* Typically, you create a `StorableMethod` and associate it to a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) using the [`@method()`](https://layrjs.com/docs/v2/reference/storable#method-decorator) decorator.
*
* For example, here is how you would define a `Movie` component with some storable attributes and methods:
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute, method} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id;
*
* ﹫attribute('string') title = '';
*
* ﹫attribute('string') country = '';
*
* ﹫attribute('Date') releaseDate;
*
* ﹫method() async wasReleasedIn(year) {
* await this.load({releaseDate});
*
* return this.releaseDate().getFullYear() === year;
* }
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute, method} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id!: string;
*
* ﹫attribute('string') title = '';
*
* ﹫attribute('string') country = '';
*
* ﹫attribute('Date') releaseDate!: Date;
*
* ﹫method() async wasReleasedIn(year: number) {
* await this.load({releaseDate});
*
* return this.releaseDate().getUTCFullYear() === year;
* }
* }
* ```
*
* Notice the `wasReleasedIn()` method that allows us to determine if a movie was released in a specific year. We could use this method as follows:
*
* ```
* const movie = new Movie({
* title: 'Inception',
* country: 'USA',
* releaseDate: new Date('2010-07-16')
* });
*
* await movie.wasReleasedIn(2010); // => true
* await movie.wasReleasedIn(2011); // => false
* ```
*
* So far, there is nothing special about the `wasReleasedIn()` method. We could have achieved the same result without the [`@method()`](https://layrjs.com/docs/v2/reference/storable#method-decorator) decorator.
*
* Now, let's imagine that we want to find all the movies that was released in 2010. We could do so as follows:
*
* ```
* await Movie.find({
* releaseDate: {
* $greaterThanOrEqual: new Date('2010-01-01'),
* $lessThan: new Date('2011-01-01')
* }
* });
* ```
*
* That would certainly work, but wouldn't it be great if we could do the following instead:
*
* ```
* await Movie.find({wasReleasedIn: 2010});
* ```
*
* Unfortunately, the above [`Query`](https://layrjs.com/docs/v2/reference/query) wouldn't work. To make such a query possible, we must somehow transform the logic of the `wasReleasedIn()` method into a regular query, and this is exactly where a `StorableMethod` can be useful.
*
* Because the `wasReleasedIn()` method is a `StorableMethod` (thanks to the [`@method()`](https://layrjs.com/docs/v2/reference/storable#method-decorator) decorator), we can can associate a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) to it by adding the [`@finder()`](https://layrjs.com/docs/v2/reference/storable#finder-decorator) decorator:
*
* ```
* // JS
*
* // ...
*
* import {finder} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* ﹫finder(function (year) {
* return {
* releaseDate: {
* $greaterThanOrEqual: new Date(`${year}-01-01`),
* $lessThan: new Date(`${year + 1}-01-01`)
* }
* };
* })
* ﹫method()
* async wasReleasedIn(year) {
* // ...
* }
* }
* ```
*
* ```
* // TS
*
* // ...
*
* import {finder} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* // ...
*
* ﹫finder(function (year: number) {
* return {
* releaseDate: {
* $greaterThanOrEqual: new Date(`${year}-01-01`),
* $lessThan: new Date(`${year + 1}-01-01`)
* }
* };
* })
* ﹫method()
* async wasReleasedIn(year: number) {
* // ...
* }
* }
* ```
*
* And now, it is possible to use the `wasReleasedIn()` method in any query:
*
* ```
* // Find all the movies released in 2010
* await Movie.find({wasReleasedIn: 2010});
*
* // Find all the American movies released in 2010
* await Movie.find({country: 'USA', wasReleasedIn: 2010});
* ```
*/
class extends StorablePropertyMixin(Base) {
_storableMethodBrand!: void;
/**
* @constructor
*
* Creates a storable method. Typically, instead of using this constructor, you would rather use the [`@method()`](https://layrjs.com/docs/v2/reference/storable#method-decorator) decorator.
*
* @param name The name of the method.
* @param parent The [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) class, prototype, or instance that owns the method.
* @param [options] An object specifying any option supported by the constructor of [`Method`](https://layrjs.com/docs/v2/reference/method#constructor) and [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property#constructor).
*
* @returns The [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method) instance that was created.
*
* @category Creation
*/
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Finder ===
/**
* See the methods that are inherited from the [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property#finder) class.
*
* @category Finder
*/
// === Utilities ===
static isStorableMethod(value: any): value is StorableMethod {
return isStorableMethodInstance(value);
}
};
export function isStorableMethodClass(value: any): value is typeof StorableMethod {
return typeof value?.isStorableMethod === 'function';
}
export function isStorableMethodInstance(value: any): value is StorableMethod {
return isStorableMethodClass(value?.constructor) === true;
}
export class StorableMethod extends StorableMethodMixin(Method) {
constructor(
name: string,
parent: typeof Component | Component,
options: StorableMethodOptions = {}
) {
assertIsStorableClassOrInstance(parent);
super(name, parent, options);
}
}
================================================
FILE: packages/storable/src/properties/storable-primary-identifier-attribute.ts
================================================
import {PrimaryIdentifierAttribute} from '@layr/component';
// TODO: Find a way to remove this useless import
// I did that to remove a TypeScript error in the generated declaration file
// @ts-ignore
import type {Property, Attribute} from '@layr/component';
import {StorableAttributeMixin} from './storable-attribute';
/**
* *Inherits from [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) and [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute).*
*
* The `StorablePrimaryIdentifierAttribute` class is like the [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) class but extended with the capabilities of the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) class.
*
* #### Usage
*
* Typically, you create a `StorablePrimaryIdentifierAttribute` and associate it to a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) using the [`@primaryIdentifier()`](https://layrjs.com/docs/v2/reference/storable#primary-identifier-decorator) decorator.
*
* **Example:**
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id;
*
* ﹫attribute('string') title = '';
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id!: string;
*
* ﹫attribute('string') title = '';
* }
* ```
*/
export class StorablePrimaryIdentifierAttribute extends StorableAttributeMixin(
PrimaryIdentifierAttribute
) {
_storablePrimaryIdentifierAttributeBrand!: void;
/**
* @constructor
*
* Creates a storable primary identifier attribute. Typically, instead of using this constructor, you would rather use the [`@primaryIdentifier()`](https://layrjs.com/docs/v2/reference/storable#primary-identifier-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) prototype that owns the attribute.
* @param [options] An object specifying any option supported by the constructor of [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute#constructor) and [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute#constructor).
*
* @returns The [`StorablePrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/storable-primary-identifier-attribute) instance that was created.
*
* @category Creation
*/
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Attribute Methods ===
/**
* See the methods that are inherited from the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#value-type) class.
*
* @category Attribute Methods
*/
}
================================================
FILE: packages/storable/src/properties/storable-property.ts
================================================
import {Property} from '@layr/component';
import type {Component, PropertyOptions} from '@layr/component';
import {PromiseLikeable, Constructor} from 'core-helpers';
import type {Query} from '../query';
import {assertIsStorableClassOrInstance} from '../utilities';
export type StorablePropertyOptions = PropertyOptions & {
finder?: StorablePropertyFinder;
};
export type StorablePropertyFinder = (value: unknown) => PromiseLikeable;
export const StorablePropertyMixin = >(Base: T) =>
/**
* @name StorableProperty
*
* *Inherits from [`Property`](https://layrjs.com/docs/v2/reference/property).*
*
* A base class from which classes such as [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) or [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method) are constructed. Unless you build a custom property class, you probably won't have to use this class directly.
*/
class extends Base {
/**
* @constructor
*
* Creates a storable property.
*
* @param name The name of the property.
* @param parent The [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) class, prototype, or instance that owns the property.
* @param [options.finder] A function specifying a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) for the property.
* @param [options.exposure] A [`PropertyExposure`](https://layrjs.com/docs/v2/reference/property#property-exposure-type) object specifying how the property should be exposed to remote access.
*
* @returns The [`StorableProperty`](https://layrjs.com/docs/v2/reference/storable-property) instance that was created.
*
* @category Creation
*/
// === Options ===
setOptions(options: StorablePropertyOptions = {}) {
const {finder, ...otherOptions} = options;
this._finder = finder;
super.setOptions(otherOptions);
}
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Finder ===
_finder: StorablePropertyFinder | undefined;
/**
* Returns the [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type)of the property.
*
* @returns A [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) function (or `undefined` if the property has no associated finder).
*
* @category Finder
*/
getFinder() {
return this._finder;
}
/**
* Returns whether the property has a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type).
*
* @returns A boolean.
*
* @category Finder
*/
hasFinder() {
return this.getFinder() !== undefined;
}
/**
* Sets a [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) for the property.
*
* @param finder The [`Finder`](https://layrjs.com/docs/v2/reference/storable-property#finder-type) function to set.
*
* @category Finder
*/
setFinder(finder: StorablePropertyFinder) {
this._finder = finder;
}
async callFinder(value: unknown) {
const finder = this.getFinder();
if (finder === undefined) {
throw new Error(`Cannot call a finder that is missing (${this.describe()})`);
}
return await finder.call(this.getParent(), value);
}
/**
* @typedef Finder
*
* A function representing the "finder" of a property.
*
* The function should return a [`Query`](https://layrjs.com/docs/v2/reference/query) for the property that is queried for.
*
* The function has the following characteristics:
*
* - It can be `async`.
* - As first parameter, it receives the value that was specified in the user's query.
* - It is executed with the parent of the property as `this` context.
*
* See an example of use in the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) and [`StorableMethod`](https://layrjs.com/docs/v2/reference/storable-method) classes.
*
* @category Finder
*/
// === Utilities ===
static isStorableProperty(value: any): value is StorableProperty {
return isStorablePropertyInstance(value);
}
};
export function isStorablePropertyClass(value: any): value is typeof StorableProperty {
return typeof value?.isStorableProperty === 'function';
}
export function isStorablePropertyInstance(value: any): value is StorableProperty {
return isStorablePropertyClass(value?.constructor) === true;
}
export class StorableProperty extends StorablePropertyMixin(Property) {
constructor(
name: string,
parent: typeof Component | Component,
options: StorablePropertyOptions = {}
) {
assertIsStorableClassOrInstance(parent);
super(name, parent, options);
}
}
================================================
FILE: packages/storable/src/properties/storable-secondary-identifier-attribute.ts
================================================
import {SecondaryIdentifierAttribute} from '@layr/component';
// TODO: Find a way to remove this useless import
// I did that to remove a TypeScript error in the generated declaration file
// @ts-ignore
import type {Property, Attribute} from '@layr/component';
import {StorableAttributeMixin} from './storable-attribute';
/**
* *Inherits from [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) and [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute).*
*
* The `StorableSecondaryIdentifierAttribute` class is like the [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute) class but extended with the capabilities of the [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute) class.
*
* #### Usage
*
* Typically, you create a `StorableSecondaryIdentifierAttribute` and associate it to a [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) using the [`@secondaryIdentifier()`](https://layrjs.com/docs/v2/reference/storable#secondary-identifier-decorator) decorator.
*
* **Example:**
*
* ```
* // JS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, secondaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id;
*
* ﹫secondaryIdentifier() slug;
*
* ﹫attribute('string') title = '';
* }
* ```
*
* ```
* // TS
*
* import {Component} from '﹫layr/component';
* import {Storable, primaryIdentifier, secondaryIdentifier, attribute} from '﹫layr/storable';
*
* class Movie extends Storable(Component) {
* ﹫primaryIdentifier() id!: string;
*
* ﹫secondaryIdentifier() slug!: string;
*
* ﹫attribute('string') title = '';
* }
* ```
*/
export class StorableSecondaryIdentifierAttribute extends StorableAttributeMixin(
SecondaryIdentifierAttribute
) {
_storableSecondaryIdentifierAttributeBrand!: void;
/**
* @constructor
*
* Creates a storable secondary identifier attribute. Typically, instead of using this constructor, you would rather use the [`@secondaryIdentifier()`](https://layrjs.com/docs/v2/reference/storable#secondary-identifier-decorator) decorator.
*
* @param name The name of the attribute.
* @param parent The [storable component](https://layrjs.com/docs/v2/reference/storable#storable-component-class) prototype that owns the attribute.
* @param [options] An object specifying any option supported by the constructor of [`SecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/secondary-identifier-attribute#constructor) and [`StorableAttribute`](https://layrjs.com/docs/v2/reference/storable-attribute#constructor).
*
* @returns The [`StorableSecondaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/storable-secondary-identifier-attribute) instance that was created.
*
* @category Creation
*/
// === Property Methods ===
/**
* See the methods that are inherited from the [`Property`](https://layrjs.com/docs/v2/reference/property#basic-methods) class.
*
* @category Property Methods
*/
// === Attribute Methods ===
/**
* See the methods that are inherited from the [`Attribute`](https://layrjs.com/docs/v2/reference/attribute#value-type) class.
*
* @category Attribute Methods
*/
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
}
================================================
FILE: packages/storable/src/query.ts
================================================
import type {PlainObject} from 'core-helpers';
/**
* @typedef Query
*
* A plain object specifying the criteria to be used when selecting some components from a store with the methods [`StorableComponent.find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method) or [`StorableComponent.count()`](https://layrjs.com/docs/v2/reference/storable#count-class-method).
*
* #### Basic Queries
*
* ##### Empty Query
*
* Specify an empty object (`{}`) to select all the components:
*
* ```
* // Find all the movies
* await Movie.find({});
* ```
*
* ##### Single Attribute Query
*
* Specify an object composed of an attribute's name and value to select the components that have an attribute's value equals to a specific value:
*
* ```
* // Find the Japanese movies
* await Movie.find({country: 'Japan'});
*
* // Find the unreleased movies
* await Movie.find({year: undefined});
* ```
*
* ##### Multiple Attributes Query
*
* You can combine several attributes' name and value to select the components by multiple attributes:
*
* ```
* // Find the Japanese drama movies
* await Movie.find({country: 'Japan', genre: 'drama'});
* ```
*
* #### Basic Operators
*
* Instead of a specific value, you can specify an object containing one or more operators to check whether the value of an attribute matches certain criteria.
*
* ##### `$equal`
*
* Use the `$equal` operator to check whether the value of an attribute is equal to a specific value. This is the default operator, so when you specify a value without any operator, the `$equal` operator is used under the hood:
*
* ```
* // Find the Japanese movies
* await Movie.find({country: {$equal: 'Japan'}});
*
* // Same as above, but in a short manner
* await Movie.find({country: 'Japan'});
* ```
*
* ##### `$notEqual`
*
* Use the `$notEqual` operator to check whether the value of an attribute is different than a specific value:
*
* ```
* // Find the non-Japanese movies
* await Movie.find({country: {$notEqual: 'Japan'}});
* // Find the released movies
* await Movie.find({year: {$notEqual: undefined}});
* ```
*
* ##### `$greaterThan`
*
* Use the `$greaterThan` operator to check whether the value of an attribute is greater than a specific value:
*
* ```
* // Find the movies released after 2010
* await Movie.find({year: {$greaterThan: 2010}});
* ```
*
* ##### `$greaterThanOrEqual`
*
* Use the `$greaterThanOrEqual` operator to check whether the value of an attribute is greater than or equal to a specific value:
*
* ```
* // Find the movies released in or after 2010
* await Movie.find({year: {$greaterThanOrEqual: 2010}});
* ```
*
* ##### `$lessThan`
*
* Use the `$lessThan` operator to check whether the value of an attribute is less than a specific value:
*
* ```
* // Find the movies released before 2010
* await Movie.find({year: {$lessThan: 2010}});
* ```
*
* ##### `$lessThanOrEqual`
*
* Use the `$lessThanOrEqual` operator to check whether the value of an attribute is less than or equal to a specific value:
*
* ```
* // Find the movies released in or before 2010
* await Movie.find({year: {$lessThanOrEqual: 2010}});
* ```
*
* ##### `$in`
*
* Use the `$in` operator to check whether the value of an attribute is equal to any value in the specified array:
*
* ```
* // Find the movies that are Japanese or French
* await Movie.find({country: {$in: ['Japan', 'France']}});
*
* // Find the movies that have any of the specified identifiers
* await Movie.find({id: {$in: ['abc123', 'abc456', 'abc789']}});
* ```
*
* ##### Combining several operators
*
* You can combine several operators to check whether the value of an attribute matches several criteria:
*
* ```
* // Find the movies released after 2010 and before 2015
* await Movie.find({year: {$greaterThan: 2010, $lessThan: 2015}});
* ```
*
* #### String Operators
*
* A number of operators are dedicated to string attributes.
*
* ##### `$includes`
*
* Use the `$includes` operator to check whether the value of a string attribute includes a specific string:
*
* ```
* // Find the movies that have the string 'awesome' in their title
* await Movie.find({title: {$includes: 'awesome'}});
* ```
*
* ##### `$startsWith`
*
* Use the `$startsWith` operator to check whether the value of a string attribute starts with a specific string:
*
* ```
* // Find the movies that have their title starting with the string 'awesome'
* await Movie.find({title: {$startsWith: 'awesome'}});
* ```
*
* ##### `$endsWith`
*
* Use the `$endsWith` operator to check whether the value of a string attribute ends with a specific string:
*
* ```
* // Find the movies that have their title ending with the string 'awesome'
* await Movie.find({title: {$endsWith: 'awesome'}});
* ```
*
* ##### `$matches`
*
* Use the `$matches` operator to check whether the value of a string attribute matches the specified [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions):
*
* ```
* // Find the movies that have a number in their title
* await Movie.find({title: {$matches: /\d/}});
* ```
*
* #### Array Operators
*
* A number of operators are dedicated to array attributes.
*
* ##### `$some`
*
* Use the `$some` operator to check whether an array attribute has an item equals to a specific value. This is the default operator for array attributes, so when you specify a value without any array operator, the `$some` operator is used under the hood:
*
* ```
* // Find the movies that have the 'awesome' tag
* await Movie.find({tags: {$some: 'awesome'}});
*
* // Same as above, but in a short manner
* await Movie.find({tags: 'awesome'});
* ```
*
* ##### `$every`
*
* Use the `$every` operator to check whether an array attribute has all its items equals to a specific value:
*
* ```
* // Find the movies that have all their tags equal to 'awesome'
* await Movie.find({tags: {$every: 'awesome'}});
* ```
*
* ##### `$length`
*
* Use the `$length` operator to check whether an array attribute has a specific number of items:
*
* ```
* // Find the movies that have three tags:
* await Movie.find({tags: {$length: 3}});
*
* // Find the movies that don't have any tag:
* await Movie.find({tags: {$length: 0}});
* ```
*
* #### Logical Operators
*
* The logical operators allows you to combine several subqueries.
*
* ##### `$and`
*
* Use the `$and` operator to perform a logical **AND** operation on an array of subqueries and select the components that satisfy *all* the subqueries. Note that since **AND** is the implicit logical operation when you combine multiple attributes or operators, you will typically use the `$and` operator in combination with some other logical operators such as [`$or`](https://layrjs.com/docs/v2/reference/query#or) to avoid repetition.
*
* ```
* // Find the Japanese drama movies
* await Movie.find({$and: [{country: 'Japan'}, {genre: 'drama'}]});
*
* // Same as above, but in a short manner
* await Movie.find({country: 'Japan', genre: 'drama'});
*
* // Find the movies released after 2010 and before 2015
* await Movie.find({$and: [{year: {$greaterThan: 2010}}, {year: {$lessThan: 2015}}]});
*
* // Same as above, but in a short manner
* await Movie.find({year: {$greaterThan: 2010, $lessThan: 2015}});
*
* // Find the Japanese movies released before 2010 or after 2015
* await Movie.find({
* $and: [
* {country: 'Japan'},
* {$or: [{year: {$lessThan: 2010}}, {year: {$greaterThan: 2015}}]}
* ]
* });
*
* // Same as above, but we have to repeat the country to remove the $and operator
* await Movie.find({
* $or: [
* {country: 'Japan', year: {$lessThan: 2010}},
* {country: 'Japan', year: {$greaterThan: 2015}}
* ]
* });
* ```
*
* ##### `$or`
*
* Use the `$or` operator to perform a logical **OR** operation on an array of subqueries and select the components that satisfy *at least* one of the subqueries.
*
* ```
* // Find the movies that are either Japanese or a drama
* await Movie.find({$or: [{country: 'Japan', {genre: 'drama'}]});
*
* // Find the movies released before 2010 or after 2015
* await Movie.find({$or: [{year: {$lessThan: 2010}}, {year: {$greaterThan: 2015}}]});
* ```
*
* ##### `$nor`
*
* Use the `$nor` operator to perform a logical **NOR** operation on an array of subqueries and select the components that *fail all* the subqueries.
*
* ```
* // Find the movies that are not Japanese and not a drama
* await Movie.find({$nor: [{country: 'Japan', {genre: 'drama'}]});
* ```
*
* ##### `$not`
*
* Use the `$not` operator to invert the effect of an operator.
*
* ```
* // Find the non-Japanese movies
* await Movie.find({country: {$not: {$equal: 'Japan'}}});
*
* // Same as above, but in a short manner
* await Movie.find({country: {$notEqual: 'Japan'}});
*
* // Find the movies that was not released in or after 2010
* await Movie.find({year: {$not: {$greaterThanOrEqual: 2010}}});
*
* // Same as above, but in a short manner
* await Movie.find({year: {$lessThan: 2010}});
* ```
*
* #### Embedded Components
*
* When a query involves an [embedded component](https://layrjs.com/docs/v2/reference/embedded-component), wrap the attributes of the embedded component in an object:
*
* ```
* // Find the movies that have a '16:9' aspect ratio
* await Movie.find({details: {aspectRatio: '16:9'}});
*
* // Find the movies that have a '16:9' aspect ratio and are longer than 2 hours
* await Movie.find({details: {aspectRatio: '16:9', duration: {$greaterThan: 120}}});
* ```
*
* #### Referenced Components
*
* To check whether a component holds a [reference to another component](https://layrjs.com/docs/v2/reference/component#referencing-components), you can specify an object representing the [primary identifier](https://layrjs.com/docs/v2/reference/primary-identifier-attribute) of the referenced component:
*
* ```
* // Find the Tarantino's movies
* const tarantino = await Director.get({slug: 'quentin-tarantino'});
* await Movie.find({director: {id: tarantino.id}});
* ```
*
* Wherever you can specify a primary identifier, you can specify a component instead. So, the example above can be shortened as follows:
*
* ```
* // Find the Tarantino's movies in a short manner
* const tarantino = await Director.get({slug: 'quentin-tarantino'});
* await Movie.find({director: tarantino});
* ```
*/
export type Query = PlainObject;
================================================
FILE: packages/storable/src/storable.ts
================================================
import {
Component,
isComponentClass,
isComponentInstance,
isComponentClassOrInstance,
isComponentValueTypeInstance,
Attribute,
ValueType,
isArrayValueTypeInstance,
AttributeSelector,
createAttributeSelectorFromAttributes,
attributeSelectorsAreEqual,
mergeAttributeSelectors,
removeFromAttributeSelector,
traverseAttributeSelector,
trimAttributeSelector,
normalizeAttributeSelector,
IdentifierDescriptor,
IdentifierValue,
method
} from '@layr/component';
import {hasOwnProperty, isPrototypeOf, isPlainObject, getTypeOf, Constructor} from 'core-helpers';
import mapKeys from 'lodash/mapKeys';
import {
StorableProperty,
StorablePropertyOptions,
isStorablePropertyInstance,
StorableAttribute,
StorableAttributeOptions,
isStorableAttributeInstance,
StorablePrimaryIdentifierAttribute,
StorableSecondaryIdentifierAttribute,
StorableAttributeHookName,
StorableMethod,
StorableMethodOptions,
isStorableMethodInstance
} from './properties';
import {Index, IndexAttributes, IndexOptions} from './index-class';
import type {Query} from './query';
import type {StoreLike} from './store-like';
import {
isStorableInstance,
isStorableClassOrInstance,
ensureStorableClass,
isStorable
} from './utilities';
export type SortDescriptor = {[name: string]: SortDirection};
export type SortDirection = 'asc' | 'desc';
/**
* Extends a [`Component`](https://layrjs.com/docs/v2/reference/component) class with some storage capabilities.
*
* #### Usage
*
* The `Storable()` mixin can be used both in the backend and the frontend.
*
* ##### Backend Usage
*
* Call `Storable()` with a [`Component`](https://layrjs.com/docs/v2/reference/component) class to construct a [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) class that you can extend with your data model and business logic. Then, register this class into a store such as [`MongoDBStore`](https://layrjs.com/docs/v2/reference/mongodb-store) by using the [`registerStorable()`](https://layrjs.com/docs/v2/reference/store#register-storable-instance-method) method (or [`registerRootComponent()`](https://layrjs.com/docs/v2/reference/store#register-root-component-instance-method) to register several components at once).
*
* **Example:**
*
* ```js
* // JS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* export class Movie extends Storable(Component) {
* @primaryIdentifier() id;
*
* @attribute() title = '';
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* ```ts
* // TS
*
* import {Component} from '@layr/component';
* import {Storable, primaryIdentifier, attribute} from '@layr/storable';
* import {MongoDBStore} from '@layr/mongodb-store';
*
* export class Movie extends Storable(Component) {
* @primaryIdentifier() id!: string;
*
* @attribute() title = '';
* }
*
* const store = new MongoDBStore('mongodb://user:pass@host:port/db');
*
* store.registerStorable(Movie);
* ```
*
* Once you have a storable component registered into a store, you can use any method provided by the `Storable()` mixin to interact with the database:
*
* ```
* const movie = new Movie({id: 'abc123', title: 'Inception'});
*
* // Save the movie to the database
* await movie.save();
*
* // Retrieve the movie from the database
* await Movie.get('abc123'); // => movie
* ```
*
* ##### Frontend Usage
*
* Typically, you construct a storable component in the frontend by "inheriting" a storable component exposed by the backend. To accomplish that, you create a [`ComponentHTTPClient`](https://layrjs.com/docs/v2/reference/component-http-client), and then call the [`getComponent()`](https://layrjs.com/docs/v2/reference/component-http-client#get-component-instance-method) method to construct your frontend component.
*
* **Example:**
*
* ```
* import {ComponentHTTPClient} from '@layr/component-http-client';
* import {Storable} from '@layr/storable';
*
* (async () => {
* const client = new ComponentHTTPClient('https://...', {
* mixins: [Storable]
* });
*
* const Movie = await client.getComponent();
* })();
* ```
*
* > Note that you have to pass the `Storable` mixin when you create a `ComponentHTTPClient` that is consuming a storable component.
*
* Once you have a storable component in the frontend, you can use any method that is exposed by the backend. For example, if the `Movie`'s [`save()`](https://layrjs.com/docs/v2/reference/storable#save-instance-method) method is exposed by the backend, you can call it from the frontend to add a new movie into the database:
*
* ```
* const movie = new Movie({title: 'Inception 2'});
*
* await movie.save();
* ```
*
* See the ["Storing Data"](https://layrjs.com/docs/v2/introduction/storing-data) tutorial for a comprehensive example using the `Storable()` mixin.
*
* ### StorableComponent class {#storable-component-class}
*
* *Inherits from [`Component`](https://layrjs.com/docs/v2/reference/component).*
*
* A `StorableComponent` class is constructed by calling the `Storable()` mixin ([see above](https://layrjs.com/docs/v2/reference/storable#storable-mixin)).
*
* @mixin
*/
export function Storable>(Base: T) {
if (!isComponentClass(Base)) {
throw new Error(
`The Storable mixin should be applied on a component class (received type: '${getTypeOf(
Base
)}')`
);
}
if (typeof (Base as any).isStorable === 'function') {
return Base as T & typeof Storable;
}
class Storable extends Base {
declare ['constructor']: typeof StorableComponent;
// === Component Methods ===
/**
* See the methods that are inherited from the [`Component`](https://layrjs.com/docs/v2/reference/component#creation) class.
*
* @category Component Methods
*/
// === Store registration ===
static __store: StoreLike | undefined;
/**
* Returns the store in which the storable component is registered. If the storable component is not registered in a store, an error is thrown.
*
* @returns A [`Store`](https://layrjs.com/docs/v2/reference/store) instance.
*
* @example
* ```
* Movie.getStore(); // => store
* ```
*
* @category Store Registration
*/
static getStore() {
const store = this.__store;
if (store === undefined) {
throw new Error(
`Cannot get the store of a storable component that is not registered (${this.describeComponent()})`
);
}
return store;
}
/**
* Returns whether the storable component is registered in a store.
*
* @returns A boolean.
*
* @example
* ```
* Movie.hasStore(); // => true
* ```
*
* @category Store Registration
*/
static hasStore() {
return this.__store !== undefined;
}
static __setStore(store: StoreLike) {
Object.defineProperty(this, '__store', {value: store});
}
// === Storable properties ===
static getPropertyClass(type: string) {
if (type === 'StorableAttribute') {
return StorableAttribute;
}
if (type === 'StorablePrimaryIdentifierAttribute') {
return StorablePrimaryIdentifierAttribute;
}
if (type === 'StorableSecondaryIdentifierAttribute') {
return StorableSecondaryIdentifierAttribute;
}
if (type === 'StorableMethod') {
return StorableMethod;
}
return super.getPropertyClass(type);
}
static get getStorableProperty() {
return this.prototype.getStorableProperty;
}
getStorableProperty(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const property = this.__getStorableProperty(name, {autoFork});
if (property === undefined) {
throw new Error(`The storable property '${name}' is missing (${this.describeComponent()})`);
}
return property;
}
static get hasStorableProperty() {
return this.prototype.hasStorableProperty;
}
hasStorableProperty(name: string) {
return this.__getStorableProperty(name, {autoFork: false}) !== undefined;
}
static get __getStorableProperty() {
return this.prototype.__getStorableProperty;
}
__getStorableProperty(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isStorablePropertyInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not a storable property (${property.describe()})`
);
}
return property;
}
static get setStorableProperty() {
return this.prototype.setStorableProperty;
}
setStorableProperty(name: string, propertyOptions: StorablePropertyOptions = {}) {
return this.setProperty(name, StorableProperty, propertyOptions);
}
getStorablePropertiesWithFinder() {
return this.getProperties({
filter: (property) => isStorablePropertyInstance(property) && property.hasFinder()
});
}
// === Storable attributes ===
static get getStorableAttribute() {
return this.prototype.getStorableAttribute;
}
getStorableAttribute(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const attribute = this.__getStorableAttribute(name, {autoFork});
if (attribute === undefined) {
throw new Error(
`The storable attribute '${name}' is missing (${this.describeComponent()})`
);
}
return attribute;
}
static get hasStorableAttribute() {
return this.prototype.hasStorableAttribute;
}
hasStorableAttribute(name: string) {
return this.__getStorableAttribute(name, {autoFork: false}) !== undefined;
}
static get __getStorableAttribute() {
return this.prototype.__getStorableAttribute;
}
__getStorableAttribute(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isStorableAttributeInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not a storable attribute (${property.describe()})`
);
}
return property;
}
static get setStorableAttribute() {
return this.prototype.setStorableAttribute;
}
setStorableAttribute(name: string, attributeOptions: StorableAttributeOptions = {}) {
return this.setProperty(name, StorableAttribute, attributeOptions);
}
getStorableAttributesWithLoader(
options: {attributeSelector?: AttributeSelector; setAttributesOnly?: boolean} = {}
) {
const {attributeSelector = true, setAttributesOnly = false} = options;
return this.getAttributes({
filter: (attribute) => isStorableAttributeInstance(attribute) && attribute.hasLoader(),
attributeSelector,
setAttributesOnly
});
}
getStorableComputedAttributes(
options: {attributeSelector?: AttributeSelector; setAttributesOnly?: boolean} = {}
) {
const {attributeSelector = true, setAttributesOnly = false} = options;
return this.getAttributes({
filter: (attribute) => isStorableAttributeInstance(attribute) && attribute.isComputed(),
attributeSelector,
setAttributesOnly
});
}
getStorableAttributesWithHook(
name: StorableAttributeHookName,
options: {attributeSelector?: AttributeSelector; setAttributesOnly?: boolean} = {}
) {
const {attributeSelector = true, setAttributesOnly = false} = options;
return this.getAttributes({
filter: (attribute) => isStorableAttributeInstance(attribute) && attribute.hasHook(name),
attributeSelector,
setAttributesOnly
});
}
async __callStorableAttributeHooks(
name: StorableAttributeHookName,
{
attributeSelector,
setAttributesOnly
}: {attributeSelector: AttributeSelector; setAttributesOnly?: boolean}
) {
for (const attribute of this.getStorableAttributesWithHook(name, {
attributeSelector,
setAttributesOnly
})) {
await attribute.callHook(name);
}
}
// === Indexes ===
getIndex(attributes: IndexAttributes, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const index = this.__getIndex(attributes, {autoFork});
if (index === undefined) {
throw new Error(
`The index \`${JSON.stringify(attributes)}\` is missing (${this.describeComponent()})`
);
}
return index;
}
hasIndex(attributes: IndexAttributes) {
return this.__getIndex(attributes, {autoFork: false}) !== undefined;
}
__getIndex(attributes: IndexAttributes, options: {autoFork: boolean}) {
const {autoFork} = options;
const indexes = this.__getIndexes();
const key = Index._buildIndexKey(attributes);
let index = indexes[key];
if (index === undefined) {
return undefined;
}
if (autoFork && index.getParent() !== this) {
index = index.fork(this);
indexes[key] = index;
}
return index;
}
setIndex(attributes: IndexAttributes, options: IndexOptions = {}): Index {
let index = this.hasIndex(attributes) ? this.getIndex(attributes) : undefined;
if (index === undefined) {
index = new Index(attributes, this, options);
const indexes = this.__getIndexes();
const key = Index._buildIndexKey(attributes);
indexes[key] = index;
} else {
index.setOptions(options);
}
return index;
}
deleteIndex(attributes: IndexAttributes) {
const indexes = this.__getIndexes();
const key = Index._buildIndexKey(attributes);
if (!hasOwnProperty(indexes, key)) {
return false;
}
delete indexes[key];
return true;
}
getIndexes(
options: {
autoFork?: boolean;
} = {}
) {
const {autoFork = true} = options;
const storable = this;
return {
*[Symbol.iterator]() {
const indexes = storable.__getIndexes({autoCreateOrFork: false});
if (indexes !== undefined) {
for (const key in indexes) {
const attributes = indexes[key].getAttributes();
const index = storable.getIndex(attributes, {autoFork});
yield index;
}
}
}
};
}
__indexes?: {[name: string]: Index};
__getIndexes({autoCreateOrFork = true} = {}) {
if (autoCreateOrFork) {
if (!('__indexes' in this)) {
Object.defineProperty(this, '__indexes', {value: Object.create(null)});
} else if (!hasOwnProperty(this, '__indexes')) {
Object.defineProperty(this, '__indexes', {value: Object.create(this.__indexes!)});
}
}
return this.__indexes!;
}
// === Storable methods ===
static get getStorableMethod() {
return this.prototype.getStorableMethod;
}
getStorableMethod(name: string, options: {autoFork?: boolean} = {}) {
const {autoFork = true} = options;
const method = this.__getStorableMethod(name, {autoFork});
if (method === undefined) {
throw new Error(`The storable method '${name}' is missing (${this.describeComponent()})`);
}
return method;
}
static get hasStorableMethod() {
return this.prototype.hasStorableMethod;
}
hasStorableMethod(name: string) {
return this.__getStorableMethod(name, {autoFork: false}) !== undefined;
}
static get __getStorableMethod() {
return this.prototype.__getStorableMethod;
}
__getStorableMethod(name: string, options: {autoFork: boolean}) {
const {autoFork} = options;
const property = this.__getProperty(name, {autoFork});
if (property === undefined) {
return undefined;
}
if (!isStorableMethodInstance(property)) {
throw new Error(
`A property with the specified name was found, but it is not a storable method (${property.describe()})`
);
}
return property;
}
static get setStorableMethod() {
return this.prototype.setStorableMethod;
}
setStorableMethod(name: string, methodOptions: StorableMethodOptions = {}) {
return this.setProperty(name, StorableMethod, methodOptions);
}
// === Operations ===
/**
* Retrieves a storable component instance (and possibly, some of its referenced components) from the store.
*
* > This method uses the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method) method under the hood to load the component's attributes. So if you want to expose the [`get()`](https://layrjs.com/docs/v2/reference/storable#get-class-method) method to the frontend, you will typically have to expose the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method) method as well.
*
* @param identifier A plain object specifying the identifier of the component you want to retrieve. The shape of the object should be `{[identifierName]: identifierValue}`. Alternatively, you can specify a string or a number representing the value of a [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be loaded (default: `true`, which means that all the attributes will be loaded).
* @param [options.reload] A boolean specifying whether a component that has already been loaded should be loaded again from the store (default: `false`). Most of the time you will leave this option off to take advantage of the cache.
* @param [options.throwIfMissing] A boolean specifying whether an error should be thrown if there is no component matching the specified `identifier` in the store (default: `true`).
*
* @returns A [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) instance.
*
* @example
* ```
* // Fully retrieve a movie by its primary identifier
* await Movie.get({id: 'abc123'});
* // Same as above, but in a short manner
* await Movie.get('abc123');
*
* // Fully retrieve a movie by its secondary identifier
* await Movie.get({slug: 'inception'});
*
* // Partially retrieve a movie by its primary identifier
* await Movie.get({id: 'abc123'}, {title: true, rating: true});
*
* // Partially retrieve a movie, and fully retrieve its referenced director component
* await Movie.get({id: 'abc123'}, {title: true, director: true});
*
* // Partially retrieve a movie, and partially retrieve its referenced director component
* await Movie.get({id: 'abc123'}, {title: true, director: {fullName: true}});
* ```
*
* @category Storage Operations
*/
static async get(
this: T,
identifierDescriptor: IdentifierDescriptor,
attributeSelector: AttributeSelector | undefined,
options: {reload?: boolean; throwIfMissing: false; _callerMethodName?: string}
): Promise | undefined>;
static async get(
this: T,
identifierDescriptor: IdentifierDescriptor,
attributeSelector?: AttributeSelector,
options?: {reload?: boolean; throwIfMissing?: boolean; _callerMethodName?: string}
): Promise>;
@method() static async get(
this: T,
identifierDescriptor: IdentifierDescriptor,
attributeSelector: AttributeSelector = true,
options: {reload?: boolean; throwIfMissing?: boolean; _callerMethodName?: string} = {}
) {
identifierDescriptor = this.normalizeIdentifierDescriptor(identifierDescriptor);
attributeSelector = normalizeAttributeSelector(attributeSelector);
const {reload = false, throwIfMissing = true, _callerMethodName} = options;
let storable = this.getIdentityMap().getComponent(identifierDescriptor) as
| InstanceType
| undefined;
const hasPrimaryIdentifier =
storable?.getPrimaryIdentifierAttribute().isSet() ||
this.prototype.getPrimaryIdentifierAttribute().getName() in identifierDescriptor;
if (!hasPrimaryIdentifier) {
if (this.hasStore()) {
// Nothing to do, the storable will be loaded by load()
} else if (this.hasRemoteMethod('get')) {
// Let's fetch the primary identifier
storable = await this.callRemoteMethod(
'get',
identifierDescriptor,
{},
{
reload,
throwIfMissing
}
);
if (storable === undefined) {
return;
}
} else {
throw new Error(
`To be able to execute the get() method${describeCaller(
_callerMethodName
)} with a secondary identifier, a storable component should be registered in a store or have an exposed get() remote method (${this.describeComponent()})`
);
}
}
let storableHasBeenCreated = false;
if (storable === undefined) {
storable = this.instantiate(identifierDescriptor);
storableHasBeenCreated = true;
}
const loadedStorable = await storable.load(attributeSelector, {
reload,
throwIfMissing,
_callerMethodName: _callerMethodName ?? 'get'
});
if (loadedStorable === undefined && storableHasBeenCreated && storable.isAttached()) {
storable.detach();
}
return loadedStorable;
}
/**
* Returns whether a storable component instance exists in the store.
*
* @param identifier A plain object specifying the identifier of the component you want to search. The shape of the object should be `{[identifierName]: identifierValue}`. Alternatively, you can specify a string or a number representing the value of a [`PrimaryIdentifierAttribute`](https://layrjs.com/docs/v2/reference/primary-identifier-attribute).
* @param [options.reload] A boolean specifying whether a component that has already been loaded should be searched again from the store (default: `false`). Most of the time you will leave this option off to take advantage of the cache.
*
* @returns A boolean.
*
* @example
* ```
* // Check if there is a movie with a certain primary identifier
* await Movie.has({id: 'abc123'}); // => true
*
* // Same as above, but in a short manner
* await Movie.has('abc123'); // => true
*
* // Check if there is a movie with a certain secondary identifier
* await Movie.has({slug: 'inception'}); // => true
* ```
*
* @category Storage Operations
*/
static async has(identifierDescriptor: IdentifierDescriptor, options: {reload?: boolean} = {}) {
const {reload = false} = options;
const storable: StorableComponent | undefined = await this.get(
identifierDescriptor,
{},
{reload, throwIfMissing: false, _callerMethodName: 'has'}
);
return storable !== undefined;
}
/**
* Loads some attributes of the current storable component instance (and possibly, some of its referenced components) from the store.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be loaded (default: `true`, which means that all the attributes will be loaded).
* @param [options.reload] A boolean specifying whether a component that has already been loaded should be loaded again from the store (default: `false`). Most of the time you will leave this option off to take advantage of the cache.
* @param [options.throwIfMissing] A boolean specifying whether an error should be thrown if there is no matching component in the store (default: `true`).
*
* @returns The current [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) instance.
*
* @example
* ```
* // Retrieve a movie with the 'title' attribute only
* const movie = await Movie.get('abc123', {title: true});
*
* // Load a few more movie's attributes
* await movie.load({tags: true, rating: true});
*
* // Load some attributes of the movie's director
* await movie.load({director: {fullName: true}});
*
* // Since the movie's rating has already been loaded,
* // it will not be loaded again from the store
* await movie.load({rating: true});
*
* // Change the movie's rating
* movie.rating = 8.5;
*
* // Since the movie's rating has been modified,
* // it will be loaded again from the store
* await movie.load({rating: true});
*
* // Force reloading the movie's rating
* await movie.load({rating: true}, {reload: true});
* ```
*
* @category Storage Operations
*/
async load(
this: T,
attributeSelector: AttributeSelector | undefined,
options: {reload?: boolean; throwIfMissing: false; _callerMethodName?: string}
): Promise;
async load(
this: T,
attributeSelector?: AttributeSelector,
options?: {reload?: boolean; throwIfMissing?: boolean; _callerMethodName?: string}
): Promise;
@method() async load(
this: T,
attributeSelector: AttributeSelector = true,
options: {reload?: boolean; throwIfMissing?: boolean; _callerMethodName?: string} = {}
) {
const {reload = false, throwIfMissing = true, _callerMethodName} = options;
if (this.isNew()) {
throw new Error(
`Cannot load a storable component that is marked as new (${this.describeComponent()})`
);
}
let resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector);
if (!reload) {
const alreadyLoadedAttributeSelector = this.resolveAttributeSelector(
resolvedAttributeSelector,
{
filter: (attribute: Attribute) =>
attribute.getValueSource() === 'server' || attribute.getValueSource() === 'store',
setAttributesOnly: true,
aggregationMode: 'intersection'
}
);
resolvedAttributeSelector = removeFromAttributeSelector(
resolvedAttributeSelector,
alreadyLoadedAttributeSelector
);
}
const computedAttributes = this.getStorableComputedAttributes({
attributeSelector: resolvedAttributeSelector
});
let nonComputedAttributeSelector = removeFromAttributeSelector(
resolvedAttributeSelector,
createAttributeSelectorFromAttributes(computedAttributes)
);
nonComputedAttributeSelector = trimAttributeSelector(nonComputedAttributeSelector);
let loadedStorable: T | undefined;
if (nonComputedAttributeSelector !== false) {
await this.beforeLoad(nonComputedAttributeSelector);
const constructor = this.constructor as typeof StorableComponent;
if (constructor.hasStore()) {
loadedStorable = (await constructor.getStore().load(this, {
attributeSelector: nonComputedAttributeSelector,
throwIfMissing
})) as T;
} else if (this.hasRemoteMethod('load')) {
if (this.getPrimaryIdentifierAttribute().isSet()) {
loadedStorable = await this.callRemoteMethod('load', nonComputedAttributeSelector, {
reload,
throwIfMissing
});
} else if (this.constructor.hasRemoteMethod('get')) {
loadedStorable = await this.constructor.callRemoteMethod(
'get',
this.getIdentifierDescriptor(),
nonComputedAttributeSelector,
{
reload,
throwIfMissing
}
);
} else {
throw new Error(
`To be able to execute the load() method${describeCaller(
_callerMethodName
)} when no primary identifier is set, a storable component should be registered in a store or have an exposed get() remote method (${this.constructor.describeComponent()})`
);
}
} else {
throw new Error(
`To be able to execute the load() method${describeCaller(
_callerMethodName
)}, a storable component should be registered in a store or have an exposed load() remote method (${this.describeComponent()})`
);
}
if (loadedStorable === undefined) {
return undefined;
}
await loadedStorable.afterLoad(nonComputedAttributeSelector);
} else {
loadedStorable = this; // OPTIMIZATION: There was nothing to load
}
for (const attribute of loadedStorable.getStorableAttributesWithLoader({
attributeSelector: resolvedAttributeSelector
})) {
const value = await attribute.callLoader();
attribute.setValue(value, {source: 'store'});
}
await loadedStorable.__populate(attributeSelector, {
reload,
throwIfMissing,
_callerMethodName
});
return loadedStorable;
}
async __populate(
attributeSelector: AttributeSelector,
{
reload,
throwIfMissing,
_callerMethodName
}: {reload: boolean; throwIfMissing: boolean; _callerMethodName: string | undefined}
) {
const resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
includeReferencedComponents: true
});
const storablesWithAttributeSelectors = new Map<
typeof StorableComponent | StorableComponent,
AttributeSelector
>();
traverseAttributeSelector(
this,
resolvedAttributeSelector,
(componentOrObject, subattributeSelector) => {
if (
isStorableClassOrInstance(componentOrObject) &&
!ensureStorableClass(componentOrObject).isEmbedded()
) {
const storable = componentOrObject;
if (!storablesWithAttributeSelectors.has(storable)) {
storablesWithAttributeSelectors.set(storable, subattributeSelector);
} else {
const mergedAttributeSelector = mergeAttributeSelectors(
storablesWithAttributeSelectors.get(storable)!,
subattributeSelector
);
storablesWithAttributeSelectors.set(storable, mergedAttributeSelector);
}
}
},
{includeSubtrees: true, includeLeafs: false}
);
if (storablesWithAttributeSelectors.size > 0) {
await Promise.all(
Array.from(storablesWithAttributeSelectors).map(
([storable, attributeSelector]) =>
isStorableInstance(storable)
? storable.load(attributeSelector, {reload, throwIfMissing, _callerMethodName})
: undefined // TODO: Implement class loading
)
);
}
}
/**
* Saves the current storable component instance to the store. If the component is new, it will be added to the store with all its attributes. Otherwise, only the attributes that have been modified will be saved to the store.
*
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be saved (default: `true`, which means that all the modified attributes will be saved).
* @param [options.throwIfMissing] A boolean specifying whether an error should be thrown if the current component is not new and there is no existing component with the same identifier in the store (default: `true` if the component is not new).
* @param [options.throwIfExists] A boolean specifying whether an error should be thrown if the current component is new and there is an existing component with the same identifier in the store (default: `true` if the component is new).
*
* @returns The current [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) instance.
*
* @example
* ```
* // Retrieve a movie with a few attributes
* const movie = await Movie.get('abc123', {title: true, rating: true});
*
* // Change the movie's rating
* movie.rating = 8;
*
* // Save the new movie's rating to the store
* await movie.save();
*
* // Since the movie's rating has not been changed since the previous save(),
* // it will not be saved again
* await movie.save();
* ```
*
* @category Storage Operations
*/
async save(
this: T,
attributeSelector: AttributeSelector | undefined,
options: {throwIfMissing: false; throwIfExists?: boolean}
): Promise;
async save(
this: T,
attributeSelector: AttributeSelector | undefined,
options: {throwIfMissing?: boolean; throwIfExists: false}
): Promise;
async save(
this: T,
attributeSelector?: AttributeSelector,
options?: {throwIfMissing?: boolean; throwIfExists?: boolean}
): Promise;
@method() async save(
this: T,
attributeSelector: AttributeSelector = true,
options: {throwIfMissing?: boolean; throwIfExists?: boolean} = {}
) {
const isNew = this.isNew();
const {throwIfMissing = !isNew, throwIfExists = isNew} = options;
if (throwIfMissing === true && throwIfExists === true) {
throw new Error(
"The 'throwIfMissing' and 'throwIfExists' options cannot be both set to true"
);
}
const computedAttributes = this.getStorableComputedAttributes();
const computedAttributeSelector = createAttributeSelectorFromAttributes(computedAttributes);
let resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
setAttributesOnly: true,
target: 'store',
aggregationMode: 'intersection'
});
resolvedAttributeSelector = removeFromAttributeSelector(
resolvedAttributeSelector,
computedAttributeSelector
);
if (!isNew && Object.keys(resolvedAttributeSelector).length < 2) {
return this; // OPTIMIZATION: There is nothing to save
}
await this.beforeSave(resolvedAttributeSelector);
resolvedAttributeSelector = this.resolveAttributeSelector(attributeSelector, {
setAttributesOnly: true,
target: 'store',
aggregationMode: 'intersection'
});
resolvedAttributeSelector = removeFromAttributeSelector(
resolvedAttributeSelector,
computedAttributeSelector
);
if (!isNew && Object.keys(resolvedAttributeSelector).length < 2) {
return this; // OPTIMIZATION: There is nothing to save
}
let savedStorable: T | undefined;
const constructor = this.constructor as typeof StorableComponent;
if (constructor.hasStore()) {
savedStorable = (await constructor.getStore().save(this, {
attributeSelector: resolvedAttributeSelector,
throwIfMissing,
throwIfExists
})) as T;
} else if (this.hasRemoteMethod('save')) {
savedStorable = await this.callRemoteMethod('save', attributeSelector, {
throwIfMissing,
throwIfExists
});
} else {
throw new Error(
`To be able to execute the save() method, a storable component should be registered in a store or have an exposed save() remote method (${this.describeComponent()})`
);
}
if (savedStorable === undefined) {
return undefined;
}
await savedStorable.afterSave(resolvedAttributeSelector);
return savedStorable;
}
_assertArrayItemsAreFullyLoaded(attributeSelector: AttributeSelector) {
traverseAttributeSelector(
this,
attributeSelector,
(value, attributeSelector, {isArray}) => {
if (isArray && isComponentInstance(value)) {
const component = value;
if (component.constructor.isEmbedded()) {
if (
!attributeSelectorsAreEqual(
component.resolveAttributeSelector(true),
attributeSelector
)
) {
throw new Error(
`Cannot save an array item that has some unset attributes (${component.describeComponent()})`
);
}
}
}
},
{includeSubtrees: true, includeLeafs: false}
);
}
/**
* Deletes the current storable component instance from the store.
*
* @param [options.throwIfMissing] A boolean specifying whether an error should be thrown if there is no matching component in the store (default: `true`).
*
* @returns The current [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) instance.
*
* @example
* ```
* // Retrieve a movie
* const movie = await Movie.get('abc123');
*
* // Delete the movie
* await movie.delete();
* ```
*
* @category Storage Operations
*/
async delete(
this: T,
options: {throwIfMissing: false}
): Promise;
async delete(
this: T,
options?: {throwIfMissing?: boolean}
): Promise;
@method() async delete(
this: T,
options: {throwIfMissing?: boolean} = {}
) {
if (this.isNew()) {
throw new Error(
`Cannot delete a storable component that is new (${this.describeComponent()})`
);
}
const {throwIfMissing = true} = options;
const attributeSelector = this.resolveAttributeSelector(true);
const computedAttributes = this.getStorableComputedAttributes({attributeSelector});
const nonComputedAttributeSelector = removeFromAttributeSelector(
attributeSelector,
createAttributeSelectorFromAttributes(computedAttributes)
);
await this.beforeDelete(nonComputedAttributeSelector);
let deletedStorable: T | undefined;
const constructor = this.constructor as typeof StorableComponent;
if (constructor.hasStore()) {
deletedStorable = (await constructor.getStore().delete(this, {throwIfMissing})) as T;
} else if (this.hasRemoteMethod('delete')) {
deletedStorable = await this.callRemoteMethod('delete', {throwIfMissing});
} else {
throw new Error(
`To be able to execute the delete() method, a storable component should be registered in a store or have an exposed delete() remote method (${this.describeComponent()})`
);
}
if (deletedStorable === undefined) {
return undefined;
}
await deletedStorable.afterDelete(nonComputedAttributeSelector);
deletedStorable.setIsDeletedMark(true);
// TODO: deletedStorable.detach();
return deletedStorable;
}
/**
* Finds some storable component instances matching the specified query in the store, and load all or some of their attributes (and possibly, load some of their referenced components as well).
*
* > This method uses the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method) method under the hood to load the components' attributes. So if you want to expose the [`find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method) method to the frontend, you will typically have to expose the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method) method as well.
*
* @param [query] A [`Query`](https://layrjs.com/docs/v2/reference/query) object specifying the criteria to be used when selecting the components from the store (default: `{}`, which means that any component can be selected).
* @param [attributeSelector] An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) specifying the attributes to be loaded (default: `true`, which means that all the attributes will be loaded).
* @param [options.sort] A plain object specifying how the found components should be sorted (default: `undefined`). The shape of the object should be `{[name]: direction}` where `name` is the name of an attribute, and `direction` is the string `'asc'` or `'desc'` representing the sort direction (ascending or descending).
* @param [options.skip] A number specifying how many components should be skipped from the found components (default: `0`).
* @param [options.limit] A number specifying the maximum number of components that should be returned (default: `undefined`).
* @param [options.reload] A boolean specifying whether a component that has already been loaded should be loaded again from the store (default: `false`). Most of the time you will leave this option off to take advantage of the cache.
*
* @returns An array of [`StorableComponent`](https://layrjs.com/docs/v2/reference/storable#storable-component-class) instances.
*
* @example
* ```
* // Find all the movies
* await Movie.find();
*
* // Find the Japanese movies
* await Movie.find({country: 'Japan'});
*
* // Find the Japanese drama movies
* await Movie.find({country: 'Japan', genre: 'drama'});
*
* // Find the Tarantino's movies
* const tarantino = await Director.get({slug: 'quentin-tarantino'});
* await Movie.find({director: tarantino});
*
* // Find the movies released after 2010
* await Movie.find({year: {$greaterThan: 2010}});
*
* // Find the top 30 movies
* await Movie.find({}, true, {sort: {rating: 'desc'}, limit: 30});
*
* // Find the next top 30 movies
* await Movie.find({}, true, {sort: {rating: 'desc'}, skip: 30, limit: 30});
* ```
*
* @category Storage Operations
*/
@method() static async find(
this: T,
query: Query = {},
attributeSelector: AttributeSelector = true,
options: {sort?: SortDescriptor; skip?: number; limit?: number; reload?: boolean} = {}
) {
const {sort, skip, limit, reload = false} = options;
query = await this.__callStorablePropertyFindersForQuery(query);
query = this.__normalizeQuery(query, {loose: !this.hasStore()});
let foundStorables: InstanceType[];
if (this.hasStore()) {
foundStorables = (await this.getStore().find(this, query, {
sort,
skip,
limit
})) as InstanceType[];
} else if (this.hasRemoteMethod('find')) {
foundStorables = await this.callRemoteMethod('find', query, {}, {sort, skip, limit});
} else {
throw new Error(
`To be able to execute the find() method, a storable component should be registered in a store or have an exposed find() remote method (${this.describeComponent()})`
);
}
const loadedStorables = await Promise.all(
foundStorables.map((foundStorable) =>
foundStorable.load(attributeSelector, {reload, _callerMethodName: 'find'})
)
);
return loadedStorables;
}
/**
* Counts the number of storable component instances matching the specified query in the store.
*
* @param [query] A [`Query`](https://layrjs.com/docs/v2/reference/query) object specifying the criteria to be used when selecting the components from the store (default: `{}`, which means that any component can be selected, and therefore the total number of components available in the store will be returned).
*
* @returns A number.
*
* @example
* ```
* // Count the total number of movies
* await Movie.count();
*
* // Count the number of Japanese movies
* await Movie.count({country: 'Japan'});
*
* // Count the number of Japanese drama movies
* await Movie.count({country: 'Japan', genre: 'drama'});
*
* // Count the number of Tarantino's movies
* const tarantino = await Director.get({slug: 'quentin-tarantino'})
* await Movie.count({director: tarantino});
*
* // Count the number of movies released after 2010
* await Movie.count({year: {$greaterThan: 2010}});
* ```
*
* @category Storage Operations
*/
@method() static async count(query: Query = {}) {
query = await this.__callStorablePropertyFindersForQuery(query);
query = this.__normalizeQuery(query, {loose: !this.hasStore()});
let storablesCount: number;
if (this.hasStore()) {
storablesCount = await this.getStore().count(this, query);
} else if (this.hasRemoteMethod('count')) {
storablesCount = await this.callRemoteMethod('count', query);
} else {
throw new Error(
`To be able to execute the count() method, a storable component should be registered in a store or have an exposed count() remote method (${this.describeComponent()})`
);
}
return storablesCount;
}
static async __callStorablePropertyFindersForQuery(query: Query) {
for (const property of this.prototype.getStorablePropertiesWithFinder()) {
const name = property.getName();
if (!hasOwnProperty(query, name)) {
continue; // The property finder is not used in the query
}
const {[name]: value, ...remainingQuery} = query;
const finderQuery = await property.callFinder(value);
query = {...remainingQuery, ...finderQuery};
}
return query;
}
static __normalizeQuery(query: Query, {loose = false}: {loose?: boolean} = {}) {
const normalizeQueryForComponent = function (
query: Query | typeof Component | Component,
component: typeof Component | Component
) {
if (isComponentClassOrInstance(query)) {
if (component === query || isPrototypeOf(component, query)) {
return query.toObject({minimize: true});
}
throw new Error(
`An unexpected component was specified in a query (${component.describeComponent({
componentPrefix: 'expected'
})}, ${query.describeComponent({componentPrefix: 'specified'})})`
);
}
if (!isPlainObject(query)) {
throw new Error(
`Expected a plain object in a query, but received a value of type '${getTypeOf(query)}'`
);
}
const normalizedQuery: Query = {};
for (const [name, subquery] of Object.entries(query)) {
if (name === '$some' || name === '$every') {
normalizedQuery[name] = normalizeQueryForComponent(subquery, component);
continue;
}
if (name === '$length') {
normalizedQuery[name] = subquery;
continue;
}
if (name === '$not') {
normalizedQuery[name] = normalizeQueryForComponent(subquery, component);
continue;
}
if (name === '$and' || name === '$or' || name === '$nor') {
if (!Array.isArray(subquery)) {
throw new Error(
`Expected an array as value of the operator '${name}', but received a value of type '${getTypeOf(
subquery
)}'`
);
}
const subqueries: Query[] = subquery;
normalizedQuery[name] = subqueries.map((subquery) =>
normalizeQueryForComponent(subquery, component)
);
continue;
}
if (name === '$in') {
if (!Array.isArray(subquery)) {
throw new Error(
`Expected an array as value of the operator '${name}', but received a value of type '${getTypeOf(
subquery
)}'`
);
}
if (!isComponentInstance(component)) {
throw new Error(
`The operator '${name}' cannot be used in the context of a component class`
);
}
const nestedComponents: Component[] = subquery;
const primaryIdentifiers: IdentifierValue[] = nestedComponents.map(
(nestedComponent) => {
if (!isComponentInstance(nestedComponent)) {
throw new Error(
`Expected an array of component instances as value of the operator '${name}', but received a value of type '${getTypeOf(
nestedComponent
)}'`
);
}
if (!isPrototypeOf(component, nestedComponent)) {
throw new Error(
`An unexpected item was specified for the operator '${name}' (${component.describeComponent(
{
componentPrefix: 'expected'
}
)}, ${nestedComponent.describeComponent({componentPrefix: 'specified'})})`
);
}
return nestedComponent.getPrimaryIdentifierAttribute().getValue()!;
}
);
const primaryIdentifierAttributeName = component
.getPrimaryIdentifierAttribute()
.getName();
normalizedQuery[primaryIdentifierAttributeName] = {[name]: primaryIdentifiers};
continue;
}
if (component.hasAttribute(name)) {
const attribute = component.getAttribute(name);
normalizedQuery[name] = normalizeQueryForAttribute(subquery, attribute);
} else {
if (!loose) {
throw new Error(
`An unknown attribute was specified in a query (${component.describeComponent()}, attribute: '${name}')`
);
}
normalizedQuery[name] = subquery;
}
}
return normalizedQuery;
};
const normalizeQueryForAttribute = function (query: Query, attribute: Attribute) {
const type = attribute.getValueType();
return normalizeQueryForAttributeAndType(query, attribute, type);
};
const normalizeQueryForAttributeAndType = function (
query: Query,
attribute: Attribute,
type: ValueType
): Query {
if (isComponentValueTypeInstance(type)) {
const component = type.getComponent(attribute);
const normalizedQuery = normalizeQueryForComponent(query, component);
return normalizedQuery;
}
if (isArrayValueTypeInstance(type)) {
const itemType = type.getItemType();
let normalizedQuery = normalizeQueryForAttributeAndType(query, attribute, itemType);
if (isPlainObject(normalizedQuery) && '$includes' in normalizedQuery) {
// Make '$includes' an alias of '$some'
normalizedQuery = mapKeys(normalizedQuery, (_value, key) =>
key === '$includes' ? '$some' : key
);
}
if (
!(
isPlainObject(normalizedQuery) &&
('$some' in normalizedQuery ||
'$every' in normalizedQuery ||
'$length' in normalizedQuery ||
'$not' in normalizedQuery)
)
) {
// Make '$some' implicit
normalizedQuery = {$some: normalizedQuery};
}
return normalizedQuery;
}
return query;
};
return normalizeQueryForComponent(query, this.prototype);
}
// === isDeleted Mark ===
__isDeleted: boolean | undefined;
/**
* Returns whether the component instance is marked as deleted or not.
*
* @returns A boolean.
*
* @example
* ```
* movie.getIsDeletedMark(); // => false
* await movie.delete();
* movie.getIsDeletedMark(); // => true
* ```
*
* @category isDeleted Mark
*/
getIsDeletedMark() {
return this.__isDeleted === true;
}
/**
* Sets whether the component instance is marked as deleted or not.
*
* @param isDeleted A boolean specifying if the component instance should be marked as deleted or not.
*
* @example
* ```
* movie.getIsDeletedMark(); // => false
* movie.setIsDeletedMark(true);
* movie.getIsDeletedMark(); // => true
* ```
*
* @category isDeleted Mark
*/
setIsDeletedMark(isDeleted: boolean) {
Object.defineProperty(this, '__isDeleted', {value: isDeleted, configurable: true});
}
// === Hooks ===
/**
* A method that you can override to execute some custom logic just before the current storable component instance is loaded from the store.
*
* This method is automatically called when the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method), [`get()`](https://layrjs.com/docs/v2/reference/storable#get-class-method), or [`find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method) method is called, and there are some attributes to load. If all the attributes have already been loaded by a previous operation, unless the `reload` option is used, this method is not called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that will be loaded.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeLoad(attributeSelector) {
* // Don't forget to call the parent method
* await super.beforeLoad(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeLoad(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.beforeLoad(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async beforeLoad(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('beforeLoad', {attributeSelector});
}
/**
* A method that you can override to execute some custom logic just after the current storable component instance has been loaded from the store.
*
* This method is automatically called when the [`load()`](https://layrjs.com/docs/v2/reference/storable#load-instance-method), [`get()`](https://layrjs.com/docs/v2/reference/storable#get-class-method), or [`find()`](https://layrjs.com/docs/v2/reference/storable#find-class-method) method is called, and there were some attributes to load. If all the attributes have already been loaded by a previous operation, unless the `reload` option is used, this method is not called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that were loaded.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterLoad(attributeSelector) {
* // Don't forget to call the parent method
* await super.afterLoad(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterLoad(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.afterLoad(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async afterLoad(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('afterLoad', {
attributeSelector,
setAttributesOnly: true
});
}
/**
* A method that you can override to execute some custom logic just before the current storable component instance is saved to the store.
*
* This method is automatically called when the [`save()`](https://layrjs.com/docs/v2/reference/storable#save-instance-method) method is called, and there are some modified attributes to save. If no attributes were modified, this method is not called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that will be saved.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeSave(attributeSelector) {
* // Don't forget to call the parent method
* await super.beforeSave(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeSave(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.beforeSave(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async beforeSave(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('beforeSave', {
attributeSelector,
setAttributesOnly: true
});
}
/**
* A method that you can override to execute some custom logic just after the current storable component instance has been saved to the store.
*
* This method is automatically called when the [`save()`](https://layrjs.com/docs/v2/reference/storable#save-instance-method) method is called, and there were some modified attributes to save. If no attributes were modified, this method is not called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that were saved.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterSave(attributeSelector) {
* // Don't forget to call the parent method
* await super.afterSave(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterSave(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.afterSave(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async afterSave(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('afterSave', {
attributeSelector,
setAttributesOnly: true
});
}
/**
* A method that you can override to execute some custom logic just before the current storable component instance is deleted from the store.
*
* This method is automatically called when the [`delete()`](https://layrjs.com/docs/v2/reference/storable#delete-instance-method) method is called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that will be deleted.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeDelete(attributeSelector) {
* // Don't forget to call the parent method
* await super.beforeDelete(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async beforeDelete(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.beforeDelete(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async beforeDelete(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('beforeDelete', {
attributeSelector,
setAttributesOnly: true
});
}
/**
* A method that you can override to execute some custom logic just after the current storable component instance has been deleted from the store.
*
* This method is automatically called when the [`delete()`](https://layrjs.com/docs/v2/reference/storable#delete-instance-method) method is called.
*
* @param attributeSelector An [`AttributeSelector`](https://layrjs.com/docs/v2/reference/attribute-selector) indicating the attributes that were deleted.
*
* @example
* ```
* // JS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterDelete(attributeSelector) {
* // Don't forget to call the parent method
* await super.afterDelete(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @example
* ```
* // TS
*
* class Movie extends Storable(Component) {
* // ...
*
* async afterDelete(attributeSelector: AttributeSelector) {
* // Don't forget to call the parent method
* await super.afterDelete(attributeSelector);
*
* // Implement your custom logic here
* }
* }
* ```
*
* @category Hooks
*/
async afterDelete(attributeSelector: AttributeSelector) {
await this.__callStorableAttributeHooks('afterDelete', {
attributeSelector,
setAttributesOnly: true
});
}
// === Observability ===
/**
* See the methods that are inherited from the [`Observable`](https://layrjs.com/docs/v2/reference/observable#observable-class) class.
*
* @category Observability
*/
// === Utilities ===
static get isStorable() {
return this.prototype.isStorable;
}
isStorable(value: any): value is typeof StorableComponent | StorableComponent {
return isStorable(value);
}
}
Object.defineProperty(Storable, '__mixin', {value: 'Storable'});
return Storable;
}
// Make sure the name of the Storable mixin persists over minification
Object.defineProperty(Storable, 'displayName', {value: 'Storable'});
export class StorableComponent extends Storable(Component) {}
function describeCaller(callerMethodName: string | undefined) {
return callerMethodName !== undefined ? ` (called from ${callerMethodName}())` : '';
}
================================================
FILE: packages/storable/src/store-like.ts
================================================
import {AttributeSelector} from '@layr/component';
import type {StorableComponent} from './storable';
import type {Query} from './query';
import type {SortDescriptor} from './storable';
export declare class StoreLike {
getURL: () => string | undefined;
load: (
storable: StorableComponent,
options?: {attributeSelector?: AttributeSelector; throwIfMissing?: boolean}
) => Promise;
save: (
storable: StorableComponent,
options?: {
attributeSelector?: AttributeSelector;
throwIfMissing?: boolean;
throwIfExists?: boolean;
}
) => Promise