UNPKG

14.4 kBPlain TextView Raw
1// Copyright IBM Corp. and LoopBack contributors 2017,2020. All Rights Reserved.
2// Node module: @loopback/repository
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6import {AnyObject, DataObject, Options, PrototypeOf} from './common-types';
7import {
8 BelongsToDefinition,
9 HasManyDefinition,
10 HasOneDefinition,
11 JsonSchema,
12 ReferencesManyDefinition,
13 RelationMetadata,
14 RelationType,
15} from './index';
16import {TypeResolver} from './type-resolver';
17import {Type} from './types';
18
19/**
20 * This module defines the key classes representing building blocks for Domain
21 * Driven Design.
22 * See https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks
23 */
24
25/* eslint-disable @typescript-eslint/no-explicit-any */
26
27export interface JsonSchemaWithExtensions extends JsonSchema {
28 [attributes: string]: any;
29}
30
31export type PropertyType =
32 | string
33 | Function
34 | object
35 | Type<any>
36 | TypeResolver<Model>;
37
38/**
39 * Property definition for a model
40 */
41export interface PropertyDefinition {
42 type: PropertyType; // For example, 'string', String, or {}
43 id?: boolean | number;
44 /**
45 * Used to hide this property from the response body,
46 * adding this property to the hiddenProperties array
47 */
48 hidden?: boolean;
49 json?: PropertyForm;
50 jsonSchema?: JsonSchemaWithExtensions;
51 store?: PropertyForm;
52 itemType?: PropertyType; // type of array
53 [attribute: string]: any; // Other attributes
54}
55
56/**
57 * Defining the settings for a model
58 * See https://loopback.io/doc/en/lb4/Model.html#supported-entries-of-model-definition
59 */
60export interface ModelSettings {
61 /**
62 * Description of the model
63 */
64 description?: string;
65 /**
66 * Prevent clients from setting the auto-generated ID value manually
67 */
68 forceId?: boolean;
69 /**
70 * Hides properties from response bodies
71 */
72 hiddenProperties?: string[];
73 /**
74 * Scope enables you to set a scope that will apply to every query made by the model's repository
75 */
76 scope?: object;
77 /**
78 * Specifies whether the model accepts only predefined properties or not
79 */
80 strict?: boolean | 'filter';
81
82 // Other variable settings
83 [name: string]: any;
84}
85
86/**
87 * See https://github.com/loopbackio/loopback-datasource-juggler/issues/432
88 */
89export interface PropertyForm {
90 in?: boolean; // Can the property be used for input
91 out?: boolean; // Can the property be used for output
92 name?: string; // Custom name for this form
93}
94
95/**
96 * A key-value map describing model relations.
97 * A relation name is used as the key, a relation definition is the value.
98 */
99export type RelationDefinitionMap = {
100 [relationName: string]: RelationMetadata;
101};
102
103/**
104 * DSL for building a model definition.
105 */
106export interface ModelDefinitionSyntax {
107 name: string;
108 properties?: {[name: string]: PropertyDefinition | PropertyType};
109 settings?: ModelSettings;
110 relations?: RelationDefinitionMap;
111 jsonSchema?: JsonSchemaWithExtensions;
112 [attribute: string]: any;
113}
114
115/**
116 * Definition for a model
117 */
118export class ModelDefinition {
119 readonly name: string;
120 properties: {[name: string]: PropertyDefinition};
121 settings: ModelSettings;
122 relations: RelationDefinitionMap;
123 // indexes: Map<string, any>;
124 [attribute: string]: any; // Other attributes
125
126 constructor(nameOrDef: string | ModelDefinitionSyntax) {
127 if (typeof nameOrDef === 'string') {
128 nameOrDef = {name: nameOrDef};
129 }
130 const {name, properties, settings, relations} = nameOrDef;
131
132 this.name = name;
133
134 this.properties = {};
135 if (properties) {
136 for (const p in properties) {
137 this.addProperty(p, properties[p]);
138 }
139 }
140
141 this.settings = settings ?? new Map();
142 this.relations = relations ?? {};
143 }
144
145 /**
146 * Add a property
147 * @param name - Property definition or name (string)
148 * @param definitionOrType - Definition or property type
149 */
150 addProperty(
151 name: string,
152 definitionOrType: PropertyDefinition | PropertyType,
153 ): this {
154 const definition = (definitionOrType as PropertyDefinition).type
155 ? (definitionOrType as PropertyDefinition)
156 : {type: definitionOrType};
157
158 if (
159 definition.id === true &&
160 definition.generated === true &&
161 definition.type !== undefined &&
162 definition.useDefaultIdType === undefined
163 ) {
164 definition.useDefaultIdType = false;
165 }
166
167 this.properties[name] = definition;
168 return this;
169 }
170
171 /**
172 * Add a setting
173 * @param name - Setting name
174 * @param value - Setting value
175 */
176 addSetting(name: string, value: any): this {
177 this.settings[name] = value;
178 return this;
179 }
180
181 /**
182 * Define a new relation.
183 * @param definition - The definition of the new relation.
184 */
185 addRelation(definition: RelationMetadata): this {
186 this.relations[definition.name] = definition;
187 return this;
188 }
189
190 /**
191 * Define a new belongsTo relation.
192 * @param name - The name of the belongsTo relation.
193 * @param definition - The definition of the belongsTo relation.
194 */
195 belongsTo(
196 name: string,
197 definition: Omit<BelongsToDefinition, 'name' | 'type' | 'targetsMany'>,
198 ): this {
199 const meta: BelongsToDefinition = {
200 ...definition,
201 name,
202 type: RelationType.belongsTo,
203 targetsMany: false,
204 };
205 return this.addRelation(meta);
206 }
207
208 /**
209 * Define a new hasOne relation.
210 * @param name - The name of the hasOne relation.
211 * @param definition - The definition of the hasOne relation.
212 */
213 hasOne(
214 name: string,
215 definition: Omit<HasOneDefinition, 'name' | 'type' | 'targetsMany'>,
216 ): this {
217 const meta: HasOneDefinition = {
218 ...definition,
219 name,
220 type: RelationType.hasOne,
221 targetsMany: false,
222 };
223 return this.addRelation(meta);
224 }
225
226 /**
227 * Define a new hasMany relation.
228 * @param name - The name of the hasMany relation.
229 * @param definition - The definition of the hasMany relation.
230 */
231 hasMany(
232 name: string,
233 definition: Omit<HasManyDefinition, 'name' | 'type' | 'targetsMany'>,
234 ): this {
235 const meta: HasManyDefinition = {
236 ...definition,
237 name,
238 type: RelationType.hasMany,
239 targetsMany: true,
240 };
241 return this.addRelation(meta);
242 }
243
244 /**
245 * Define a new referencesMany relation.
246 * @param name - The name of the referencesMany relation.
247 * @param definition - The definition of the referencesMany relation.
248 */
249 referencesMany(
250 name: string,
251 definition: Omit<ReferencesManyDefinition, 'name' | 'type' | 'targetsMany'>,
252 ): this {
253 const meta: ReferencesManyDefinition = {
254 ...definition,
255 name,
256 type: RelationType.referencesMany,
257 targetsMany: true,
258 };
259 return this.addRelation(meta);
260 }
261
262 /**
263 * Get an array of names of ID properties, which are specified in
264 * the model settings or properties with `id` attribute.
265 *
266 * @example
267 * ```ts
268 * {
269 * settings: {
270 * id: ['id']
271 * }
272 * properties: {
273 * id: {
274 * type: 'string',
275 * id: true
276 * }
277 * }
278 * }
279 * ```
280 */
281 idProperties(): string[] {
282 if (typeof this.settings.id === 'string') {
283 return [this.settings.id];
284 } else if (Array.isArray(this.settings.id)) {
285 return this.settings.id;
286 }
287 const idProps = Object.keys(this.properties).filter(
288 prop => this.properties[prop].id,
289 );
290 return idProps;
291 }
292}
293
294function asJSON(value: any): any {
295 if (value == null) return value;
296 if (typeof value.toJSON === 'function') {
297 return value.toJSON();
298 }
299 // Handle arrays
300 if (Array.isArray(value)) {
301 return value.map(item => asJSON(item));
302 }
303 return value;
304}
305
306/**
307 * Convert a value to a plain object as DTO.
308 *
309 * - The prototype of the value in primitive types are preserved,
310 * like `Date`, `ObjectId`.
311 * - If the value is an instance of custom model, call `toObject` to convert.
312 * - If the value is an array, convert each element recursively.
313 *
314 * @param value the value to convert
315 * @param options the options
316 */
317function asObject(value: any, options?: Options): any {
318 if (value == null) return value;
319 if (typeof value.toObject === 'function') {
320 return value.toObject(options);
321 }
322 if (Array.isArray(value)) {
323 return value.map(item => asObject(item, options));
324 }
325 return value;
326}
327
328/**
329 * Base class for models
330 */
331export class Model {
332 static get modelName(): string {
333 return this.definition?.name || this.name;
334 }
335
336 static definition: ModelDefinition;
337
338 /**
339 * Serialize into a plain JSON object
340 */
341 toJSON(): Object {
342 const def = (this.constructor as typeof Model).definition;
343 if (def == null || def.settings.strict === false) {
344 return this.toObject({ignoreUnknownProperties: false});
345 }
346
347 const copyPropertyAsJson = (key: string) => {
348 const val = asJSON((this as AnyObject)[key]);
349 if (val !== undefined) {
350 json[key] = val;
351 }
352 };
353
354 const json: AnyObject = {};
355 const hiddenProperties: string[] = def.settings.hiddenProperties || [];
356 for (const p in def.properties) {
357 if (p in this && !hiddenProperties.includes(p)) {
358 copyPropertyAsJson(p);
359 }
360 }
361
362 for (const r in def.relations) {
363 const relName = def.relations[r].name;
364 if (relName in this) {
365 copyPropertyAsJson(relName);
366 }
367 }
368
369 return json;
370 }
371
372 /**
373 * Convert to a plain object as DTO
374 *
375 * If `ignoreUnknownProperty` is set to false, convert all properties in the
376 * model instance, otherwise only convert the ones defined in the model
377 * definitions.
378 *
379 * See function `asObject` for each property's conversion rules.
380 */
381 toObject(options?: Options): Object {
382 const def = (this.constructor as typeof Model).definition;
383 const obj: AnyObject = {};
384
385 if (options?.ignoreUnknownProperties === false) {
386 const hiddenProperties: string[] = def?.settings.hiddenProperties || [];
387 for (const p in this) {
388 if (!hiddenProperties.includes(p)) {
389 const val = (this as AnyObject)[p];
390 obj[p] = asObject(val, options);
391 }
392 }
393 return obj;
394 }
395
396 if (def?.relations) {
397 for (const r in def.relations) {
398 const relName = def.relations[r].name;
399 if (relName in this) {
400 obj[relName] = asObject((this as AnyObject)[relName], {
401 ...options,
402 ignoreUnknownProperties: false,
403 });
404 }
405 }
406 }
407
408 const props = def.properties;
409 const keys = Object.keys(props);
410
411 for (const i in keys) {
412 const propertyName = keys[i];
413 const val = (this as AnyObject)[propertyName];
414
415 if (val === undefined) continue;
416 obj[propertyName] = asObject(val, options);
417 }
418
419 return obj;
420 }
421
422 constructor(data?: DataObject<Model>) {
423 Object.assign(this, data);
424 }
425}
426
427export interface Persistable {
428 // isNew: boolean;
429}
430
431/**
432 * Base class for value objects - An object that contains attributes but has no
433 * conceptual identity. They should be treated as immutable.
434 */
435export abstract class ValueObject extends Model implements Persistable {}
436
437/**
438 * Base class for entities which have unique ids
439 */
440export class Entity extends Model implements Persistable {
441 /**
442 * Get the names of identity properties (primary keys).
443 */
444 static getIdProperties(): string[] {
445 return this.definition.idProperties();
446 }
447
448 /**
449 * Get the identity value for a given entity instance or entity data object.
450 *
451 * @param entityOrData - The data object for which to determine the identity
452 * value.
453 */
454 static getIdOf(entityOrData: AnyObject): any {
455 if (typeof entityOrData.getId === 'function') {
456 return entityOrData.getId();
457 }
458
459 const idName = this.getIdProperties()[0];
460 return entityOrData[idName];
461 }
462
463 /**
464 * Get the identity value. If the identity is a composite key, returns
465 * an object.
466 */
467 getId(): any {
468 const definition = (this.constructor as typeof Entity).definition;
469 const idProps = definition.idProperties();
470 if (idProps.length === 1) {
471 return (this as AnyObject)[idProps[0]];
472 }
473 if (!idProps.length) {
474 throw new Error(
475 `Invalid Entity ${this.constructor.name}:` +
476 'missing primary key (id) property',
477 );
478 }
479 return this.getIdObject();
480 }
481
482 /**
483 * Get the identity as an object, such as `{id: 1}` or
484 * `{schoolId: 1, studentId: 2}`
485 */
486 getIdObject(): Object {
487 const definition = (this.constructor as typeof Entity).definition;
488 const idProps = definition.idProperties();
489 const idObj = {} as any;
490 for (const idProp of idProps) {
491 idObj[idProp] = (this as AnyObject)[idProp];
492 }
493 return idObj;
494 }
495
496 /**
497 * Build the where object for the given id
498 * @param id - The id value
499 */
500 static buildWhereForId(id: any) {
501 const where = {} as any;
502 const idProps = this.definition.idProperties();
503 if (idProps.length === 1) {
504 where[idProps[0]] = id;
505 } else {
506 for (const idProp of idProps) {
507 where[idProp] = id[idProp];
508 }
509 }
510 return where;
511 }
512}
513
514/**
515 * Domain events
516 */
517export class Event {
518 source: any;
519 type: string;
520}
521
522export type EntityData = DataObject<Entity>;
523
524export type EntityResolver<T extends Entity> = TypeResolver<T, typeof Entity>;
525
526/**
527 * Check model data for navigational properties linking to related models.
528 * Throw a descriptive error if any such property is found.
529 *
530 * @param modelClass Model constructor, e.g. `Product`.
531 * @param entityData Model instance or a plain-data object,
532 * e.g. `{name: 'pen'}`.
533 */
534export function rejectNavigationalPropertiesInData<M extends typeof Entity>(
535 modelClass: M,
536 data: DataObject<PrototypeOf<M>>,
537) {
538 const def = modelClass.definition;
539 const props = def.properties;
540
541 for (const r in def.relations) {
542 const relName = def.relations[r].name;
543 if (!(relName in data)) continue;
544
545 let msg =
546 'Navigational properties are not allowed in model data ' +
547 `(model "${modelClass.modelName}" property "${relName}"), ` +
548 'please remove it.';
549
550 if (relName in props) {
551 msg +=
552 ' The error might be invoked by belongsTo relations, please make' +
553 ' sure the relation name is not the same as the property name.';
554 }
555
556 throw new Error(msg);
557 }
558}