diff --git a/.eslintrc.js b/.eslintrc.js index fd5169e..aefad1c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,6 +28,7 @@ module.exports = { { types: { Object: false, + Function: false, }, }, ], diff --git a/package.json b/package.json index 1decaf9..7e13dea 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@types/revalidator": "^0.3.8", "clone": "^2.1.2", + "reflect-metadata": "^0.1.13", "dotenv": "^16.3.1", "neo4j-driver": "^5.11.0", "revalidator": "^0.3.1", diff --git a/src/Decorators/Decorators.spec.ts b/src/Decorators/Decorators.spec.ts new file mode 100644 index 0000000..6b1ec2f --- /dev/null +++ b/src/Decorators/Decorators.spec.ts @@ -0,0 +1,344 @@ +import { Neogma } from '../Neogma'; +import * as dotenv from 'dotenv'; +import { Node, Props } from './node'; +import { Property } from './property'; +import { QueryBuilder } from '../Queries'; +import { + NodePropertyDecoratorOptions, + NodeRelationshipDecoratorOptions, + getNodeMetadata, + parseNodeMetadata, +} from './shared'; +import { Relationship } from './relationship'; + +let neogma: Neogma; + +beforeAll(async () => { + dotenv.config(); + neogma = new Neogma({ + url: process.env.NEO4J_URL ?? '', + username: process.env.NEO4J_USERNAME ?? '', + password: process.env.NEO4J_PASSWORD ?? '', + }); + + await neogma.verifyConnectivity(); + QueryBuilder.queryRunner = neogma.queryRunner; +}); + +afterAll(async () => { + await neogma.driver.close(); +}); + +describe('Decorators', () => { + it('should define model name', () => { + @Node() + class User {} + + const metadata = getNodeMetadata(User); + + expect(metadata).toBeTruthy(); + + expect(metadata.name).toEqual(User.name); + }); + + it('should define model name, but allow label overrides', () => { + const labelOverride = 'Admin'; + @Node({ label: labelOverride }) + class User {} + + const metadata = getNodeMetadata(User); + + expect(metadata).toBeTruthy(); + + expect(metadata.name).toEqual(labelOverride); + }); + + it('should define model properties', () => { + @Node() + class User { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + } + + const metadata = getNodeMetadata(User); + + expect(metadata).toBeTruthy(); + + expect(metadata.properties).toHaveProperty('name'); + + expect(metadata.properties).toHaveProperty('age'); + }); + + it('should define model properties and schema', () => { + const userNameOptions = { + schema: { + type: 'string', + required: true, + }, + } as NodePropertyDecoratorOptions; + + const userAgeOptions = { + schema: { + type: 'number', + required: true, + }, + } as NodePropertyDecoratorOptions; + + @Node() + class User { + @Property(userNameOptions) + name: string; + + @Property(userAgeOptions) + age: number; + } + + const metadata = getNodeMetadata(User); + + expect(metadata).toBeTruthy(); + + expect(metadata.properties.name).toMatchObject(userNameOptions); + + expect(metadata.properties.age).toMatchObject(userAgeOptions); + }); + + it('should define model relationships', () => { + @Node() + class Order { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + orderNumber: number; + } + + const ordersRelationOptions = { + model: Order, + name: 'HAS_ORDER', + direction: 'out', + } as NodeRelationshipDecoratorOptions; + + @Node() + class User { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + + @Relationship(ordersRelationOptions) + orders: Order[]; + } + + const userMetadata = getNodeMetadata(User); + + expect(userMetadata).toBeTruthy(); + + expect(userMetadata.relationships).toHaveProperty('orders'); + + expect(userMetadata.relationships.orders).toMatchObject({ + ...ordersRelationOptions, + model: Order.prototype, + }); + + const orderMetadata = getNodeMetadata(Order); + + expect(orderMetadata).toBeTruthy(); + + expect(orderMetadata.properties).toHaveProperty('name'); + + expect(orderMetadata.properties).toHaveProperty('orderNumber'); + }); + + it('should parse model metadata to model factory creation params', () => { + @Node() + class User { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + } + + const userMetadata = getNodeMetadata(User); + + const parsedMetadata = parseNodeMetadata(userMetadata); + + expect(parsedMetadata).toBeTruthy(); + + expect(parsedMetadata.label).toBe(User.name); + + for (const property in userMetadata.properties) { + expect(parsedMetadata.schema[property]).toMatchObject( + userMetadata.properties[property].schema, + ); + } + }); + + it('should create a model from class definition', () => { + @Node() + class User { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + } + + neogma.addNode(User); + + expect(neogma.models).toHaveProperty(User.name); + + expect(neogma.models[User.name]).toBeTruthy(); + }); + + it('should populate the db with a record when createOne is called on the created model', async () => { + @Node() + class User { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + } + + type UserProps = Props; + + const Users = neogma.addNode(User); + + const userData: User = { + name: 'John', + age: 30, + }; + + const user = await Users.createOne(userData); + + expect(user).toBeTruthy(); + expect(user.name).toEqual(userData.name); + expect(user.age).toEqual(userData.age); + }); + + it('should populate the db with a record when createOne is called on the created model and its relationship', async () => { + const projectLabel = 'TeamProject'; + @Node({ label: projectLabel }) + class Project { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + teamSize: number; + } + + const projectsRelationOptions = { + model: Project, + name: 'HAS_PROJECT', + direction: 'out', + } as NodeRelationshipDecoratorOptions; + + @Node() + class Worker { + @Property({ + schema: { + type: 'string', + }, + }) + name: string; + + @Property({ + schema: { + type: 'number', + }, + }) + age: number; + + @Relationship(projectsRelationOptions) + projects: Project[]; + } + + type AllWorkerProps = Props; + + type WorkerProps = Omit; + + const Workers = neogma.addNode(Worker); + + const workerData = { + name: 'John', + age: 30, + projects: { + properties: [ + { + name: '3', + teamSize: 5, + }, + ], + }, + }; + + expect(neogma.models[projectLabel]).toBeTruthy(); + + const worker = await Workers.createOne(workerData); + + expect(worker).toBeTruthy(); + expect(worker.name).toEqual(workerData.name); + expect(worker.age).toEqual(workerData.age); + }); +}); diff --git a/src/Decorators/index.ts b/src/Decorators/index.ts new file mode 100644 index 0000000..92d6ba0 --- /dev/null +++ b/src/Decorators/index.ts @@ -0,0 +1,4 @@ +export * from './node'; +export * from './property'; +export * from './relationship'; +export * from './shared'; diff --git a/src/Decorators/node.ts b/src/Decorators/node.ts new file mode 100644 index 0000000..d36b40b --- /dev/null +++ b/src/Decorators/node.ts @@ -0,0 +1,27 @@ +import { setNodeName, addOptions } from './shared/node-service'; +import { NodeClassDecoratorOptions } from './shared/data-types'; + +export function Node(options?: NodeClassDecoratorOptions): Function; +export function Node(target: Function): void; +export function Node(arg: unknown): void | Function { + if (typeof arg === 'function') { + annotate(arg); + } else { + const options: NodeClassDecoratorOptions = { + ...(arg as object), + }; + return (target: Function | Object) => annotate(target, options); + } +} + +function annotate( + target: Function | Object, + options: NodeClassDecoratorOptions = {}, +): void { + setNodeName(target['prototype'], options.label || target['name']); + addOptions(target['prototype'], options); +} + +export type Props = { + [property in keyof U]: U[property]; +}; diff --git a/src/Decorators/property.ts b/src/Decorators/property.ts new file mode 100644 index 0000000..d28b0dc --- /dev/null +++ b/src/Decorators/property.ts @@ -0,0 +1,50 @@ +import { NodePropertyDecoratorOptions } from './shared/data-types'; +import { addProperty } from './shared/property-service'; +import { DataType } from './shared/data-types'; + +export function Property(arg: NodePropertyDecoratorOptions): Function { + return ( + target: any, + propertyName: string, + propertyDescriptor?: PropertyDescriptor, + ) => { + annotate( + target, + propertyName, + propertyDescriptor ?? + Object.getOwnPropertyDescriptor(target, propertyName), + arg, + ); + }; +} + +function annotate( + target: any, + propertyName: string, + propertyDescriptor?: PropertyDescriptor, + options: Partial = {}, +): void { + const parsedOptions: Partial = { + ...options, + }; + + if (!parsedOptions?.schema) { + parsedOptions.schema = { + ...parsedOptions.schema, + type: Reflect.getMetadata( + 'design:type', + target, + propertyName, + ) as DataType, + }; + } + + if (propertyDescriptor?.get) { + parsedOptions.get = propertyDescriptor.get; + } + if (propertyDescriptor?.set) { + parsedOptions.set = propertyDescriptor.set; + } + + addProperty(target, propertyName, parsedOptions); +} diff --git a/src/Decorators/relationship.ts b/src/Decorators/relationship.ts new file mode 100644 index 0000000..ae3fd77 --- /dev/null +++ b/src/Decorators/relationship.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { NodeRelationshipDecoratorOptions } from './shared/data-types'; +import { addRelation } from './shared/relationship-service'; + +export function Relationship( + options: NodeRelationshipDecoratorOptions, +): Function { + return (target: any, propertyName: string) => { + addRelation(target, propertyName, options); + }; +} diff --git a/src/Decorators/shared/data-types.ts b/src/Decorators/shared/data-types.ts new file mode 100644 index 0000000..7063a19 --- /dev/null +++ b/src/Decorators/shared/data-types.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { + ModelRelatedNodesI, + NeogmaInstance, + NeogmaModelStaticsI, +} from '../../ModelOps'; +import { Neo4jSupportedProperties, Neo4jSupportedTypes } from '../../Queries'; + +export type AnyObject = Record; + +export type PropertySchema = Revalidator.ISchema; + +export type DataType = + | 'string' + | 'number' + | 'integer' + | 'array' + | 'boolean' + | 'object' + | 'null' + | 'any'; + +export interface NodeProperties { + [propertyName: string]: Neo4jSupportedTypes; +} + +export interface NodeRelations { + [propertyName: string]: ModelRelatedNodesI< + object & { createOne: NeogmaModelStaticsI['createOne'] }, + NodeInstance + >; +} + +export interface NodeMethods { + [propertyName: string]: (this: NodeInstance) => T; +} + +export interface NodeStatics { + [propertyName: string]: Function; +} + +export type NodeInstance = NeogmaInstance< + NodeProperties, + NodeRelations, + NodeMethods +>; + +export interface NeogmaNodeMetadata { + name: string; + options: NodeClassDecoratorOptions; + properties: { + [propertyName: string]: NodePropertyDecoratorOptions; + }; + relationships: { + [relationAlias: string]: NodeRelationshipDecoratorOptions; + }; + methods: NodeMethodDecoratorOptions; + statics: NodeStaticDecoratorOptions; +} + +export interface NodeClassDecoratorOptions { + label?: string; +} + +export interface NodePropertyDecoratorOptions { + get?(this: M): unknown; + set?(this: M, val: unknown): void; + schema: PropertySchema; +} + +export type NodeRelationshipDecoratorOptions = { + model: Object | 'self'; + name: string; + direction: 'out' | 'in' | 'none'; + properties?: { + [propertyName: string]: { + property: string; + schema: PropertySchema; + }; + }; +}; +export type NodeMethodDecoratorOptions = {}; +export type NodeStaticDecoratorOptions = {}; diff --git a/src/Decorators/shared/index.ts b/src/Decorators/shared/index.ts new file mode 100644 index 0000000..04d8bf0 --- /dev/null +++ b/src/Decorators/shared/index.ts @@ -0,0 +1,5 @@ +export * from './data-types'; +export * from './node-service'; +export * from './property-service'; +export * from './relationship-service'; +export * from './utils'; diff --git a/src/Decorators/shared/node-service.ts b/src/Decorators/shared/node-service.ts new file mode 100644 index 0000000..b0760a8 --- /dev/null +++ b/src/Decorators/shared/node-service.ts @@ -0,0 +1,87 @@ +import { NodeClassDecoratorOptions } from './data-types'; + +const NODE_NAME_KEY = 'neogma:nodeName'; +const OPTIONS_KEY = 'neogma:options'; + +/** + * Sets node name from class by storing this + * information through reflect metadata + */ +export function setNodeName(target: any, nodeName: string): void { + Reflect.defineMetadata(NODE_NAME_KEY, nodeName, target); +} + +/** + * Returns node name from class by restoring this + * information from reflect metadata + */ +export function getNodeName(target: any): string { + return Reflect.getMetadata(NODE_NAME_KEY, target); +} + +/** + * Returns neogma define options from class prototype + * by restoring this information from reflect metadata + */ +export function getOptions(target: any): NodeClassDecoratorOptions | undefined { + const options = Reflect.getMetadata(OPTIONS_KEY, target); + + if (options) { + return { ...options }; + } +} + +/** + * Sets node definition options to class prototype + */ +export function setOptions( + target: any, + options: NodeClassDecoratorOptions, +): void { + Reflect.defineMetadata(OPTIONS_KEY, { ...options }, target); +} + +/** + * Adds options be assigning new options to old one + */ +export function addOptions( + target: any, + options: NodeClassDecoratorOptions, +): void { + setOptions(target, { + ...getOptions(target), + ...options, + }); +} + +/** + * Resolves all node getters of specified options object + * recursively. + * So that {node: () => Person} will be converted to + * {node: Person} + */ +export function resolveNodeGetter(options: NodeClassDecoratorOptions): any { + const maybeNodeGetter = (value) => + typeof value === 'function' && value.length === 0; + const isNode = (value) => value && value.prototype; + const isOptionObjectOrArray = (value) => value && typeof value === 'object'; + + return Object.keys(options).reduce( + (acc, key) => { + const value = options[key]; + + if (maybeNodeGetter(value)) { + const maybeNode = value(); + + if (isNode(maybeNode)) { + acc[key] = maybeNode; + } + } else if (isOptionObjectOrArray(value)) { + acc[key] = resolveNodeGetter(value); + } + + return acc; + }, + Array.isArray(options) ? [...options] : { ...options }, + ); +} diff --git a/src/Decorators/shared/property-service.ts b/src/Decorators/shared/property-service.ts new file mode 100644 index 0000000..4a2a1f9 --- /dev/null +++ b/src/Decorators/shared/property-service.ts @@ -0,0 +1,71 @@ +import { NodePropertyDecoratorOptions } from './data-types'; +import { deepAssign } from '../../utils/object'; + +const PROPERTIES_KEY = 'neogma:properties'; + +/** + * Returns model properties from class by restoring this + * information from reflect metadata + */ +export function getProperties( + target: any, +): Partial | undefined { + const properties = Reflect.getMetadata(PROPERTIES_KEY, target); + + if (properties) { + return Object.keys(properties).reduce((copy, key) => { + copy[key] = { ...properties[key] }; + + return copy; + }, {}); + } +} + +/** + * Sets properties + */ +export function setProperties(target: any, properties: object): void { + Reflect.defineMetadata(PROPERTIES_KEY, { ...properties }, target); +} + +/** + * Adds model property by specified property name and + * neogma property options and stores this information + * through reflect metadata + */ +export function addProperty( + target: any, + name: string, + options: Partial, +): void { + let properties = getProperties(target); + + if (!properties) { + properties = {}; + } + properties[name] = { ...options }; + + setProperties(target, properties); +} + +/** + * Adds property options for specific property + */ +export function addPropertyOptions( + target: any, + propertyName: string, + options: Partial, +): void { + const properties = getProperties(target); + + if (!properties || !properties[propertyName]) { + throw new Error( + `@Property annotation is missing for "${propertyName}" of class "${target.constructor.name}"` + + ` or annotation order is wrong.`, + ); + } + + properties[propertyName] = deepAssign(properties[propertyName], options); + + setProperties(target, properties); +} diff --git a/src/Decorators/shared/relationship-service.ts b/src/Decorators/shared/relationship-service.ts new file mode 100644 index 0000000..ca77900 --- /dev/null +++ b/src/Decorators/shared/relationship-service.ts @@ -0,0 +1,93 @@ +import { NodeRelationshipDecoratorOptions } from './data-types'; +import { deepAssign } from '../../utils/object'; +import { getNodeName } from './node-service'; + +const RELATIONS_KEY = 'neogma:relationships'; + +/** + * Returns model relationships from class by restoring this + * information from reflect metadata + */ +export function getRelations( + target: any, +): Record | undefined { + const relationships = Reflect.getMetadata(RELATIONS_KEY, target); + + if (relationships) { + return Object.keys(relationships).reduce((copy, key) => { + copy[key] = { ...relationships[key] }; + + return copy; + }, {}); + } +} + +/** + * Sets relationships + */ +export function setRelations( + target: any, + relationships: Record, +): void { + Reflect.defineMetadata(RELATIONS_KEY, { ...relationships }, target); +} + +/** + * Adds model relationships by specified relationship name and + * neogma relationship options and stores this information + * through reflect metadata + */ +export function addRelation( + target: any, + name: string, + options: NodeRelationshipDecoratorOptions, +): void { + let relationships = getRelations(target); + + if (!relationships) { + relationships = {}; + } + if (options.model !== 'self') { + const relatedModelMetadata = getNodeName(options.model['prototype']); + + if (!relatedModelMetadata) { + throw new Error( + `Either a @Model annotation is missing for class ${options.model['name']} referenced in ` + + `relationship "${name}" of class "${target.constructor.name}"` + + ` or annotation order is wrong.`, + ); + } + } + + relationships[name] = { + ...options, + model: options.model === 'self' ? 'self' : options.model['prototype'], + }; + + setRelations(target, relationships); +} + +/** + * Adds property options for specific property + */ +export function addRelationOptions( + target: any, + relationName: string, + options: NodeRelationshipDecoratorOptions, +): void { + const relationships = getRelations(target); + + if (!relationships || !relationships[relationName]) { + throw new Error( + `@Relationships annotation is missing for "${relationName}" of class "${target.constructor.name}"` + + ` or annotation order is wrong.`, + ); + } + + relationships[relationName] = deepAssign( + relationships[relationName], + options, + ); + + setRelations(target, relationships); +} diff --git a/src/Decorators/shared/utils.ts b/src/Decorators/shared/utils.ts new file mode 100644 index 0000000..2383d2f --- /dev/null +++ b/src/Decorators/shared/utils.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { Neo4jSupportedProperties } from 'Queries'; +import { NeogmaNodeMetadata, PropertySchema } from './data-types'; +import { getNodeName, getOptions } from './node-service'; +import { getProperties } from './property-service'; +import { getRelations } from './relationship-service'; +import { NeogmaModel } from '../../ModelOps'; + +type AnyObject = Record; + +type RelationshipsI = { + /** the alias of the relationship definitions is the key */ + [alias in keyof RelatedNodesToAssociateI]: { + /** the related model. It could be the object of the model, or "self" for this model */ + model: NeogmaModel | 'self'; + /** the name of the relationship */ + name: string; + /** the direction of the relationship */ + direction: 'out' | 'in' | 'none'; + /** relationship properties */ + properties?: { + /** the alias of the relationship property is the key */ + [relationPropertyAlias in keyof RelatedNodesToAssociateI[alias]['CreateRelationshipProperties']]: { + /** the actual property to be used on the relationship */ + property: keyof RelatedNodesToAssociateI[alias]['RelationshipProperties']; + /** validation for the property */ + schema: Revalidator.ISchema; + }; + }; + }; +}; + +export type NeogmaNodeFactoryParams< + Properties extends Neo4jSupportedProperties, + /** related nodes to associate. Label-NodeRelatedNodesI pairs */ + RelatedNodesToAssociateI extends AnyObject = Object, + /** interface for the statics of the model */ + StaticsI extends AnyObject = Object, + /** interface for the methods of the instance */ + MethodsI extends AnyObject = Object, +> = { + /** the schema for the validation */ + schema: { + [index in keyof Properties]: + | Revalidator.ISchema + | Revalidator.JSONSchema; + }; + /** the label of the nodes */ + label: string | string[]; + /** statics of the Node */ + statics?: Partial; + /** method of the Instance */ + methods?: Partial; + /** the id key of this model. Is required in order to perform specific instance methods */ + primaryKeyField?: Extract; + /** relationships with other models or itself. Alternatively, relationships can be added using Node.addRelationships */ + relationships?: Partial>; +}; + +export const getNodeMetadata = (target: Object | string) => { + if (typeof target === 'string') { + return { + name: getNodeName(target), + options: getOptions(target), + properties: getProperties(target), + relationships: getRelations(target), + } as NeogmaNodeMetadata; + } else { + return { + name: getNodeName(target['prototype']), + options: getOptions(target['prototype']), + properties: getProperties(target['prototype']), + relationships: getRelations(target['prototype']), + } as NeogmaNodeMetadata; + } +}; + +export const getRelatedNodeMetadata = (target: Object | Function) => { + return { + name: getNodeName(target), + options: getOptions(target), + properties: getProperties(target), + relationships: getRelations(target), + } as NeogmaNodeMetadata; +}; + +export const parseNodeMetadata = < + Properties extends Neo4jSupportedProperties, + /** related nodes to associate. Label-NodeRelatedNodesI pairs */ + RelatedNodesToAssociateI extends AnyObject = Object, + /** interface for the statics of the model */ + StaticsI extends AnyObject = Object, + /** interface for the methods of the instance */ + MethodsI extends AnyObject = Object, +>( + metadata: NeogmaNodeMetadata, +): NeogmaNodeFactoryParams< + Properties, + RelatedNodesToAssociateI, + StaticsI, + MethodsI +> => { + const { name, properties, relationships } = metadata; + const propertyNames = Object.keys(properties); + const props = propertyNames.map((propertyName) => { + const property = properties[propertyName]; + return { + [propertyName]: property.schema as PropertySchema, + }; + }); + const parsedProperties = Object.assign({}, ...props); + + let parsedRelations = undefined; + + if (relationships) { + const relationNames = Object.keys(relationships); + const rels = relationNames.map((relationName) => { + const relationship = relationships[relationName]; + return { + [relationName]: relationship, + }; + }); + parsedRelations = Object.assign({}, ...rels); + } + + return { + label: name, + schema: parsedProperties, + relationships: parsedRelations, + } as NeogmaNodeFactoryParams< + Properties, + RelatedNodesToAssociateI, + StaticsI, + MethodsI + >; +}; diff --git a/src/Errors/NeogmaDecoratorError.ts b/src/Errors/NeogmaDecoratorError.ts new file mode 100644 index 0000000..362c324 --- /dev/null +++ b/src/Errors/NeogmaDecoratorError.ts @@ -0,0 +1,22 @@ +import { NeogmaError } from './NeogmaError'; + +/** Decorator misuse error */ +export class NeogmaDecoratorError extends NeogmaError { + public message: NeogmaError['message']; + public data: { + description?: any; + actual?: any; + expected?: any; + }; + + constructor( + message: NeogmaDecoratorError['message'], + data?: NeogmaDecoratorError['data'], + ) { + super(message, data); + this.message = message || 'neogma decorator error'; + this.data = data || {}; + + Object.setPrototypeOf(this, NeogmaDecoratorError.prototype); + } +} diff --git a/src/Errors/NeogmaModelNotInitializedError.ts b/src/Errors/NeogmaModelNotInitializedError.ts new file mode 100644 index 0000000..92a3c72 --- /dev/null +++ b/src/Errors/NeogmaModelNotInitializedError.ts @@ -0,0 +1,15 @@ +import { NeogmaModel } from '../ModelOps'; + +export class NeogmaModelNotInitializedError extends Error { + message: string; + + constructor( + modelClass: NeogmaModel, + additionalMessage: string, + ) { + super(); + this.message = + `Model not initialized: ${additionalMessage} "${modelClass.getLabel()}" ` + + `needs to be added to a Neogma instance.`; + } +} diff --git a/src/Errors/index.ts b/src/Errors/index.ts index bcd3758..46b226e 100644 --- a/src/Errors/index.ts +++ b/src/Errors/index.ts @@ -1,4 +1,6 @@ export * from './NeogmaConstraintError'; export * from './NeogmaError'; export * from './NeogmaInstanceValidationError'; +export * from './NeogmaDecoratorError'; export * from './NeogmaNotFoundError'; +export * from './NeogmaModelNotInitializedError'; diff --git a/src/ModelOps/ModelOps.ts b/src/ModelOps/ModelOps.ts index 92cf947..9281ec5 100644 --- a/src/ModelOps/ModelOps.ts +++ b/src/ModelOps/ModelOps.ts @@ -141,7 +141,7 @@ type CreateDataI< > = Properties & Partial>; /** the statics of a Neogma Model */ -interface NeogmaModelStaticsI< +export interface NeogmaModelStaticsI< Properties extends Neo4jSupportedProperties, RelatedNodesToAssociateI extends AnyObject = Object, MethodsI extends AnyObject = Object, @@ -1761,7 +1761,7 @@ export const ModelFactory = < } // add to modelsByName - neogma.modelsByName[modelName] = Model; + neogma.models[modelName] = Model; return Model as unknown as NeogmaModel< Properties, diff --git a/src/Neogma.ts b/src/Neogma.ts index 36c70e9..0fc2828 100644 --- a/src/Neogma.ts +++ b/src/Neogma.ts @@ -1,9 +1,20 @@ import * as neo4j_driver from 'neo4j-driver'; import { Config, Driver, Session, Transaction } from 'neo4j-driver'; -import { NeogmaModel } from './ModelOps'; -import { QueryRunner, Runnable } from './Queries/QueryRunner'; +import { ModelFactory, NeogmaModel } from './ModelOps'; +import { + Neo4jSupportedProperties, + QueryRunner, + Runnable, +} from './Queries/QueryRunner'; import { getRunnable, getSession, getTransaction } from './Sessions/Sessions'; import { NeogmaConnectivityError } from './Errors/NeogmaConnectivityError'; +import { + AnyObject, + NeogmaNodeMetadata, + getNodeMetadata, + getRelatedNodeMetadata, + parseNodeMetadata, +} from './Decorators'; import { QueryBuilder } from './Queries'; const neo4j = neo4j_driver; @@ -109,4 +120,55 @@ export class Neogma { ...sessionConfig, }); }; + + public generateNodeFromMetadata = < + P extends Neo4jSupportedProperties, + R extends AnyObject = object, + M extends AnyObject = object, + S extends AnyObject = object, + >( + metadata: NeogmaNodeMetadata, + ): NeogmaModel => { + const parsedMetadata = parseNodeMetadata(metadata); + const relationships = parsedMetadata.relationships ?? []; + for (const relationship in relationships) { + const relatedNode = metadata.relationships[relationship].model; + if (relatedNode === 'self') { + continue; + } + const relatedNodeLabel = relatedNode['name']; + if (this.models[relatedNodeLabel]) { + relationships[relationship].model = this.models[relatedNodeLabel]; + } else { + const relatedNodeMetadata = getRelatedNodeMetadata(relatedNode); + relationships[relationship].model = + this.generateNodeFromMetadata(relatedNodeMetadata); + } + } + + return ModelFactory(parsedMetadata, this) as unknown as NeogmaModel< + P, + R, + M, + S + >; + }; + + public addNode = < + P extends Neo4jSupportedProperties, + R extends AnyObject = object, + M extends AnyObject = object, + S extends AnyObject = object, + >( + model: Object, + ): NeogmaModel => { + const metadata = getNodeMetadata(model); + return this.generateNodeFromMetadata(metadata); + }; + + public addNodes = (models: Object[]): void => { + for (const model of models) { + this.addNode(model); + } + }; } diff --git a/src/index.ts b/src/index.ts index b5c8ca3..586afdf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,7 @@ export * from './Neogma'; export * from './ModelOps'; export * from './Queries'; export * from './Sessions'; +export * from './Decorators'; export * as neo4jDriver from 'neo4j-driver'; +import 'reflect-metadata'; diff --git a/src/utils/object.ts b/src/utils/object.ts index b5130b1..2847f9e 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -1,2 +1,104 @@ export const isEmptyObject = (obj: Record): boolean => Object.entries(obj).length === 0 && obj.constructor === Object; + +/** + * Deep copies properties of all sources into target object. + * The last source overrides all properties of the previous + * ones, if they have the same names + */ +export function deepAssign( + target: T, + source1: S1, + source2: S2, + source3: S3, +): T & S1 & S2 & S3; +export function deepAssign( + target: T, + source1: S1, + source2: S2, +): T & S1 & S2; +export function deepAssign(target: T, source: S): T & S; +export function deepAssign(target: Object, source: S): S; +export function deepAssign(target: any, ...sources: any[]): any { + sources.forEach((source) => { + Object.getOwnPropertyNames(source).forEach((key) => + assign(key, target, source), + ); + if (Object.getOwnPropertySymbols) { + Object.getOwnPropertySymbols(source).forEach((key) => + assign(key, target, source), + ); + } + }); + return target; + + function assign( + key: string | number | symbol, + _target: any, + _source: any, + ): void { + const sourceValue = _source[key]; + + if (sourceValue !== void 0) { + let targetValue = _target[key]; + + if (Array.isArray(sourceValue)) { + if (!Array.isArray(targetValue)) { + targetValue = []; + } + const length = targetValue.length; + + sourceValue.forEach((_, index) => + assign(length + index, targetValue, sourceValue), + ); + } else if (typeof sourceValue === 'object') { + if (sourceValue instanceof RegExp) { + targetValue = cloneRegExp(sourceValue); + } else if (sourceValue instanceof Date) { + targetValue = new Date(sourceValue); + } else if (sourceValue === null) { + targetValue = null; + } else { + if (!targetValue) { + targetValue = Object.create(sourceValue.constructor.prototype); + } + deepAssign(targetValue, sourceValue); + } + } else { + targetValue = sourceValue; + } + _target[key] = targetValue; + } + } +} + +/** + * I clone the given RegExp object, and ensure that the given flags exist on + * the clone. The injectFlags parameter is purely additive - it cannot remove + * flags that already exist on the + * + * @param input RegExp - I am the regular expression object being cloned. + * @param injectFlags String( Optional ) - I am the flags to enforce on the clone. + * @source https://www.bennadel.com/blog/2664-cloning-regexp-regular-expression-objects-in-javascript.htm + */ +export function cloneRegExp(input: RegExp, injectFlags?: string): RegExp { + const pattern = input.source; + let flags = ''; + // Make sure the parameter is a defined string - it will make the conditional + // logic easier to read. + injectFlags = injectFlags || ''; + // Test for global. + if (input.global || /g/i.test(injectFlags)) { + flags += 'g'; + } + // Test for ignoreCase. + if (input.ignoreCase || /i/i.test(injectFlags)) { + flags += 'i'; + } + // Test for multiline. + if (input.multiline || /m/i.test(injectFlags)) { + flags += 'm'; + } + // Return a clone with the additive flags. + return new RegExp(pattern, flags); +} diff --git a/tsconfig.json b/tsconfig.json index cd7bb1b..c0fcc53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,9 @@ "outDir": "./dist", "baseUrl": "./src", "typeRoots": ["./node_modules/@types"], - "strictNullChecks": true + "strictNullChecks": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts"] diff --git a/yarn.lock b/yarn.lock index 2aa114d..32d442d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3098,6 +3098,11 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"