Modeling complex JavaScript object advanced relationships
We've already come a long way implementing an ActiveRecord-like JavaScript object system. While we've already covered models, records and queries extensively, I want to revisit the relationships between objects. The current implementation is fairly basic and I believe we can do plenty more to make it more powerful.
Directory structure
This time, we'll simply add a new model under our models
directory. We'll also implement the relevant factory for it to match the rest of the implementation. And we'll make a few updates in the Model
class and our models to support the new features.
src/ โโโ config/ โ โโโ settings.js โโโ core/ โ โโโ model.js โ โโโ recordSet.js โ โโโ serializer.js โ โโโ factory.js โโโ models/ โ โโโ author.js โ โโโ category.js โ โโโ post.js โโโ scripts/ โ โโโ autoload.js โ โโโ console.js โโโ serializers/ โโโ postSerializer.js โโโ postPreviewSerializer.js spec/ โโโ factories/ โโโ authorFactory.js โโโ categoryFactory.js โโโ postFactory.js
Housekeeping
Before we begin, I want to address a couple of things to make the rest of the implementation easier. Firstly, some cleaning up in the Model
class's prepare
method. Then, we'll add our new Category
model and factory, and finally, we'll add a relationships to our Post
model.
Refactoring prepare
In the past, we've heavily relied on the prepare
method of the Model
class to set up our models. As we're going to add even more logic to it, the arguments will start becoming a little hard to manage. Let's switch from a list of arguments to an object.
// ... export default class Model { // ... static prepare(model, { fields, validations = [], relationships = [] }) { // ... } // ... }
This small change will break everything, so we need to update the models accordingly.
import Model from '#src/core/model.js'; import Post from '#src/models/post.js'; export default class Author extends Model { static { // Prepare storage for the Author model super.prepare(this, { fields: [ ['name', { type: 'string', allowEmpty: false }], ['surname', 'string'], ['email', { type: 'string', unique: true, inspectable: false }], ], validations: [record => record.email.includes('@')], }); } // ... }
import Model from '#src/core/model.js'; import Author from '#src/models/author.js'; export default class Post extends Model { static { // Prepare storage for the Post model super.prepare(this, { fields: [ ['title', { type: 'string', allowEmpty: false }], ['content', 'string'], ['publishedAt', { type: 'date', defaultValue: new Date() }], ['authorId', 'number'], ], }); } // ... }
Adding the Category
model
We'll now create a Category
model and a relevant CategoryFactory
to match the rest of the implementation. We'll use this model as the main example for some of our relationships later in the article.
import Model from '#src/core/model.js'; import Post from '#src/models/post.js'; export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], }); } constructor(data) { super(data); } get posts() { return Post.where({ categoryId: this.id }); } }
import Factory from '#src/core/factory.js'; import Category from '#src/models/category.js'; const idSequence = Factory.sequence(); const titleSequence = Factory.sequence(n => `Category #${n}`); const base = { id: idSequence, title: titleSequence, }; export default class CategoryFactory extends Factory { static { super.prepare(this, Category, base); } }
But wait! Posts don't have a categoryId
field!, I hear you say. Let's add it, then!
import Model from '#src/core/model.js'; import Author from '#src/models/author.js'; import Category from '#src/models/category.js'; export default class Post extends Model { static { // Prepare storage for the Post model super.prepare(this, { fields: [ ['title', { type: 'string', allowEmpty: false }], ['content', 'string'], ['publishedAt', { type: 'date', defaultValue: new Date() }], ['authorId', 'number'], ['categoryId', 'number'], ], }); } // ... get category() { return Category.find(this.categoryId); } }
Now all of our posts are related to categories, much like they would in a real-world blogging setup. The relationship is one-to-many, where a post can belong to only one category, but a category can have many posts, same as it is with authors.
Relationship definitions
We've previously defined relationships via the use of a field that is then used by an attribute method to fetch the related records. This works alright, but scaling the project will become a bit of a hassle. We'll now introduce a new way to define relationships, which will make the implementation more scalable.
In order to create relationship definitions, we'll lean on the prepare
method once more. We will be adding a relationships
array as part of our options object. The first element of the array will be the type of relationship, whereas the second one will be the related model.
Belongs to
Again, drawing inspiration from ActiveRecord, we'll start with the belongsTo
relationship. This relationship is used when a record belongs to another record. In this case, the record that belongs to the other record is the one defining the foreign key.
In our case, this is the Post
model, which belongs to an Author
via the authorId
field. The same applies to the relationship from Post
to Category
via the categoryId
field.
Let's start by adding the logic for the belongsTo
relationship in the Model
class.
// ... const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1); const decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1); const toSingular = str => str.replace(/s$/, ''); export default class Model { static models = {}; static instances = {}; static indexedInstances = {}; static getterCache = {}; static prepare(model, { fields, validations = [], relationships = [] }) { const name = model.name; if (Model.models[name]) throw new Error(`Model ${name} has already been prepared`); else Model.models[name] = model; // Create an array for each model to store instances if (!Model.instances[name]) Model.instances[name] = []; // Cache getters, using a WeakMap for each model/key pair if (!Model.getterCache[name]) Model.getterCache[name] = {}; model.validations = validations || []; model.indexes = []; model.fields = {}; let relationshipFields = []; relationships.forEach(relationship => { let [relationshipType, target] = relationship; if (relationshipType === 'belongsTo') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(target)}Id`; relationshipFields.push([foreignKey, 'number']); Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); ['id', ...fields, ...relationshipFields].forEach(field => { // ... }); // ... } // ... }
As you can see, we've added a new models
static variable to the Model
class. This serves as a storage for all initialized models. We then ensure that relationships are stored as field definitions in relationshipFields
, which will then be used to create the individual fields automatically.
Finally, we arrive at the getter definition. If you remember from previous articles, we use Object.defineProperty
towards the end of the prepare
method to redefine getters, so that they can utilize the getterCache
for performance reasons. In order to retain this performance enhancement, we need to create our new getter as configurable
, so that it can be then redefined later down the line.
Additionally, you may notice the targetModel
declaration is inside the get
method. This is a crucial point to take note of, as it ensures that the target model is fetched only when the getter is accessed. This is important, as models can be initialized in any order and, if we move it outside of the getter, it may not be available at definition time.
Having done all this, we can now update our Post
model to include the belongsTo
relationships. We'll have to delete the authorId
, categoryId
, author
and category
fields and methods, as they are no longer needed. And, this way, we can drop the imports for Author
and Category
as well.
import Model from '#src/core/model.js'; // Delete the imports for Author and Category export default class Post extends Model { static { // Prepare storage for the Post model super.prepare(this, { fields: [ ['title', { type: 'string', allowEmpty: false }], ['content', 'string'], ['publishedAt', { type: 'date', defaultValue: new Date() }], // Delete the authorId and categoryId fields ], relationships: [ ['belongsTo', 'author'], ['belongsTo', 'category'], ], }); } // ... // Delete the author and category methods }
Has one
The previous code looks a lot tidier now and we can easily add more relationships in the future without having to worry about the implementation details. While we do not have any hasOne
relationships in our current setup, we'll go ahead an implement the logic regardless.
// ... export default class Model { // ... static prepare(model, { fields, validations = [], relationships = [] }) { // ... relationships.forEach(relationship => { let [relationshipType, target] = relationship; if (relationshipType === 'belongsTo') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(target)}Id`; relationshipFields.push([foreignKey, 'number']); Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else if (relationshipType === 'hasOne') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(target)]; return targetModel.where({ [foreignKey]: this.id }).first; }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); // ... } // ... }
As you can see, very little has changed from the previous setup. Instead of using the find
method on the other model, we use where
with a query that utilizes the foreignKey
to find the related record. This is a very simple implementation, but it should be enough for most use cases.
As the hasOne
relationships expects the foreignKey
to be on the other model, we don't need to add any additional fields to the current model, unlike belongsTo
relationships.
Has many
Similar to the hasOne
relationship, we can implement a hasMany
relationship. To be honest, this is almost the exact same piece of code, except we'll remove the first
method call at the end of the getter.
// ... export default class Model { // ... static prepare(model, { fields, validations = [], relationships = [] }) { // ... relationships.forEach(relationship => { let [relationshipType, target] = relationship; if (relationshipType === 'belongsTo') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(target)}Id`; relationshipFields.push([foreignKey, 'number']); Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else if (relationshipType === 'hasOne') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(target)]; return targetModel.where({ [foreignKey]: this.id }).first; }, configurable: true, }); } else if (relationshipType === 'hasMany') { const propertyName = decapitalize(target); const foreignKey = `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(toSingular(target))]; return targetModel.where({ [foreignKey]: this.id }); }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); // ... } // ... }
Ok, apart from that little detail, you may have noticed that we also ensure the model name is singular. This is on purpose, so we can declare the relationship in a more human-readable way. For example, in the Author
model, we can declare a hasMany
relationship to Post
as posts
instead of post
.
Let's go ahead and add a hasMany
relationship to the Author
model. We'll also delete the posts
method, as it is no longer needed, as well as the import for the Post
model. While we're at it, let's do the exact same for the Category
model.
import Model from '#src/core/model.js'; // Delete the import for Post export default class Author extends Model { static { // Prepare storage for the Author model super.prepare(this, { fields: [ ['name', { type: 'string', allowEmpty: false }], ['surname', 'string'], ['email', { type: 'string', unique: true, inspectable: false }], ], validations: [record => record.email.includes('@')], relationships: [['hasMany', 'posts']], }); } // ... // Delete the posts method }
import Model from '#src/core/model.js'; // Delete the import for Post export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], relationships: [['hasMany', 'posts']], }); } // ... // Delete the posts method }
Relationship customization
So far so good, but customization is definitely going to be an issue in the long run. I want to create a taxonomy of categories, too. This will require categories that have a parent category, which is a category itself. This is a self-referential relationship and we'll need to add a little bit of logic to handle it. And we need to address naming conventions, too.
Foreign keys
Up until this point, foreign keys have been dictated by the target model's name. This is a good default, but it's not always going to work. We may want to go down the field definition route and allow our second array element to be an object. If it is, we can let it define a target
(the model name) and a foreignKey
(the field name). This will allow us to effectively decouple the two.
// ... export default class Model { // ... static prepare(model, { fields, validations = [], relationships = [] }) { // ... relationships.forEach(relationship => { let [relationshipType, options] = relationship; if (typeof options === 'string') options = { target: options }; if (relationshipType === 'belongsTo') { const propertyName = decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(options.target)}Id`; relationshipFields.push([foreignKey, 'number']); Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else if (relationshipType === 'hasOne') { const propertyName = decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.where({ [foreignKey]: this.id }).first; }, configurable: true, }); } else if (relationshipType === 'hasMany') { const propertyName = decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(toSingular(options.target))]; return targetModel.where({ [foreignKey]: this.id }); }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); // ... } // ... }
Great! Let's apply this to create a parent category relationship in the Category
model.
import Model from '#src/core/model.js'; export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], relationships: [ [ 'belongsTo', { target: 'category', foreignKey: 'parentId' }, ], ], }); } constructor(data) { super(data); } static root(records) { return records.where({ parentId: null }).first; } }
const rootCategory = new Category({ title: 'Root', parentId: null }); const subCategory = new Category({ title: 'Sub', parentId: rootCategory.id }); subCategory.category; // [Category #0x00000000]: { id: 0, title: 'Root', parentId: null }
Not quite what we were looking for. While the foreign key has the correct name and the relationship is applied, the relationship attribute is still called category
. We can fix this!
Aliases
If you were guessing we're going to implement aliases via the as
property, you're absolutely right! This will allow us to define a custom name for the relationship attribute. Let's go ahead and modify the prepare
method one last time.
// ... export default class Model { // ... static prepare(model, { fields, validations = [], relationships = [] }) { // ... relationships.forEach(relationship => { let [relationshipType, options] = relationship; if (typeof options === 'string') options = { target: options, as: decapitalize(options) }; if (relationshipType === 'belongsTo') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(options.target)}Id`; relationshipFields.push([foreignKey, 'number']); Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else if (relationshipType === 'hasOne') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.where({ [foreignKey]: this.id }).first; }, configurable: true, }); } else if (relationshipType === 'hasMany') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(toSingular(options.target))]; return targetModel.where({ [foreignKey]: this.id }); }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); // ... } // ... }
Now, we can make update the Category
model to reflect the new as
property.
import Model from '#src/core/model.js'; export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], relationships: [ [ 'belongsTo', { target: 'category', foreignKey: 'parentId', as: 'parent' }, ], ], }); } constructor(data) { super(data); } static root(records) { return records.where({ parentId: null }).first; } }
const rootCategory = new Category({ title: 'Root', parentId: null }); const subCategory = new Category({ title: 'Sub', parentId: rootCategory.id }); subCategory.parent; // [Category #0x00000000]: { id: 0, title: 'Root', parentId: null }
Self-referential relationships
What we did with the parent
relationship is what one would call a self-referential relationship. This is a relationship where a record can be related to another record of the same model. This concept can come in handy in various scenarios.
Another way to use this, for example, is to create the inverse relationship, where a category can have many subcategories. There's nothing stopping us from defining it in the Category
model, same as before.
import Model from '#src/core/model.js'; export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], relationships: [ [ 'belongsTo', { target: 'category', foreignKey: 'parentId', as: 'parent' }, ], [ 'hasMany', { target: 'category', foreignKey: 'parentId', as: 'children' }, ], ], }); } constructor(data) { super(data); } static root(records) { return records.where({ parentId: null }).first; } }
const rootCategory = new Category({ title: 'Root', parentId: null }); const subCategory = new Category({ title: 'Sub', parentId: rootCategory.id }); rootCategory.children; // [Category #0x00000001]: { id: 1, title: 'Sub', parentId: 0 }
And there you have it! We've successfully implemented self-referential relationships in our model, both in the form of a parent category and a category with subcategories.
Conclusion
After a long and winding road, we've finally implemented a very powerful relationship system for our models. We can now define relationships quickly and easily, customize them and even implement self-referential relationships. This is a very powerful setup and can help us scale our project to new heights.
I hope you found this deep dive interesting and that you've discovered a new way to think about complexity. This is going to be the last article of the series, at least for now. If you've made it this far, thank you for sticking with me. Join the GitHub discussion via the link below, and let me know if you liked the series or if you have any questions.
Addendum: Code summary
Before I leave you, here's the entire implementation of this series. You can use this as a reference or as a starting point for your own projects. Enjoy!
You can also browse through the Code Reference on GitHub.
View the complete implementation
const settings = { loader: { modules: [ '#src/core', '#src/models', '#src/serializers', '#spec/factories', ], }, };
import RecordSet from '#src/core/recordSet.js'; import util from 'util'; util.inspect.styles.record = 'blue'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1); const decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1); const toSingular = str => str.replace(/s$/, ''); export default class Model { static models = {}; static instances = {}; static indexedInstances = {}; static getterCache = {}; static prepare(model, { fields, validations = [], relationships = [] }) { const name = model.name; if (Model.models[name]) throw new Error(`Model ${name} has already been prepared`); else Model.models[name] = model; // Create an array for each model to store instances if (!Model.instances[name]) Model.instances[name] = []; // Cache getters, using a WeakMap for each model/key pair if (!Model.getterCache[name]) Model.getterCache[name] = {}; model.validations = validations || []; model.indexes = []; model.fields = {}; let relationshipFields = []; relationships.forEach(relationship => { let [relationshipType, options] = relationship; if (typeof options === 'string') options = { target: options, as: decapitalize(options) }; if (relationshipType === 'belongsTo') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(options.target)}Id`; relationshipFields.push([foreignKey, 'number']); // Note: targetModel MUST be evaluated inside the getter, as it could // be defined after the current model Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.find(this[foreignKey]); }, configurable: true, }); } else if (relationshipType === 'hasOne') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(options.target)]; return targetModel.where({ [foreignKey]: this.id }).first; }, configurable: true, }); } else if (relationshipType === 'hasMany') { const propertyName = options.as || decapitalize(options.target); const foreignKey = options.foreignKey || `${decapitalize(name)}Id`; Object.defineProperty(model.prototype, propertyName, { get() { const targetModel = Model.models[capitalize(toSingular(options.target))]; return targetModel.where({ [foreignKey]: this.id }); }, configurable: true, }); } else { throw new Error(`Invalid relationship type: ${relationshipType}`); } }); ['id', ...fields, ...relationshipFields].forEach(field => { const isAlias = Array.isArray(field); const fieldName = isAlias ? field[0] : field; if (!fieldName || model.fields[fieldName]) throw new Error(`Invalid field name in ${name}`); let fieldOptions = { type: 'any', allowEmpty: true, defaultValue: null, unique: false, inspectable: true, }; if (fieldName === 'id') fieldOptions = { ...fieldOptions, type: 'number', allowEmpty: false, unique: true, }; if (isAlias) { if (typeof field[1] === 'string') fieldOptions.type = field[1]; else if (typeof field[1] === 'object') fieldOptions = { ...fieldOptions, ...field[1] }; else throw new Error( `Invalid field definition for ${fieldName} in ${name}` ); } const { type: dataType, allowEmpty, defaultValue, unique, inspectable, } = fieldOptions; let dataTypeChecker; if (dataType === 'any') dataTypeChecker = value => value !== null; else if (['string', 'boolean', 'number'].includes(dataType)) dataTypeChecker = value => typeof value === dataType; else if (dataType === 'date') dataTypeChecker = value => value instanceof Date; else throw new Error(`Invalid data type for ${fieldName} in ${name}`); const fieldTypeChecker = allowEmpty ? value => value === null || dataTypeChecker(value) : dataTypeChecker; let fieldChecker = fieldTypeChecker; if (unique) { model.indexes.push(fieldName); const uniqueChecker = value => !Model.indexedInstances[name][fieldName].has(value); fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value); } model.fields[fieldName] = { fieldChecker, defaultValue, inspectable }; }); // Create a map to speed up queries if (!Model.indexedInstances[name]) { Model.indexedInstances[name] = model.indexes.reduce((acc, index) => { acc[index] = new Map(); return acc; }, {}); } Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach( ([key, descriptor]) => { // Find getter functions, create the WeakMap, redefine the getter if (typeof descriptor.get === 'function') { Model.getterCache[name][key] = new WeakMap(); Object.defineProperty(model.prototype, key, { get() { if (!Model.getterCache[name][key].has(this)) { // This calls the getter function and caches the result Model.getterCache[name][key].set( this, descriptor.get.call(this) ); } return Model.getterCache[name][key].get(this); }, }); } } ); } constructor(data) { const modelName = this.constructor.name; Object.entries(this.constructor.fields).forEach( ([fieldName, { fieldChecker, defaultValue }]) => { this[fieldName] = data[fieldName] ?? defaultValue; if (!fieldChecker(this[fieldName])) { throw new Error( `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}` ); } } ); this.constructor.validations?.forEach(validation => { if (!validation(this, Model.instances[modelName])) { throw new Error( `Invalid data for ${modelName} model: ${JSON.stringify(this)}` ); } }); // Store the instance in the instances and indexedInstances Model.instances[modelName].push(this); this.constructor.indexes.forEach(index => { Model.indexedInstances[modelName][index].set(this[index], this); }); } static get all() { return RecordSet.from(Model.instances[this.name] || []); } static where(query) { return this.all.where(query); } static order(comparator) { return this.all.order(comparator); } static scope(...scopes) { return scopes.reduce((acc, scope) => this[scope](acc), this.all); } static find(id) { return Model.indexedInstances[this.name].id.get(id); } static findBy(fieldAndValue) { const entries = Object.entries(fieldAndValue); if (entries.length !== 1) throw new Error('findBy method must receive a single field/value pair'); const [fieldName, value] = entries[0]; return this.indexedInstances[this.name][fieldName].get(value); } [customInspectSymbol](depth, options) { const modelName = this.constructor.name; const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, '0')}`; const inspectable = Object.entries(this.constructor.fields).reduce( (obj, [fieldName, { inspectable }]) => { if (inspectable) obj[fieldName] = this[fieldName]; return obj; }, {} ); if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, 'record'); return `${options.stylize(`[${modelName} #${id}]: {`, 'record')}${util .inspect({ ...inspectable }, { ...options, depth: depth - 1 }) .slice(1, -1)}${options.stylize('}', 'record')}`; } }
export default class RecordSet extends Array { where(query) { return RecordSet.from( this.filter(record => { return Object.keys(query).every(key => { // If function use it to determine matches if (typeof query[key] === 'function') return query[key](record[key]); // If array, use it to determine matches if (Array.isArray(query[key])) return query[key].includes(record[key]); // If single value, use strict equality return record[key] === query[key]; }); }) ); } order(comparator) { return RecordSet.from(this.sort(comparator)); } pluck(attribute) { return RecordSet.from(super.map(record => record[attribute])) } select(...attributes) { return RecordSet.from(super.map(record => attributes.reduce((acc, attribute) => { acc[attribute] = record[attribute]; return acc; }, {}) )); } get first() { return this[0]; } get last() { return this[this.length - 1]; } }
export default class Serializer { static prepare(serializer, serializableAttributes) { serializer.serializableAttributes = []; serializableAttributes.forEach((attribute) => { const isAlias = Array.isArray(attribute); const attributeName = isAlias ? attribute[0] : attribute; if (!attributeName) return; const alias = isAlias ? attribute[1] : null; serializer.serializableAttributes.push(attributeName); Object.defineProperty(serializer.prototype, attributeName, { get() { if (!isAlias) return this.subject[attributeName]; if (typeof alias === "string") return this.subject[alias]; if (typeof alias === "function") return alias(this.subject, this.options); return undefined; }, }); }); } constructor(subject, options = {}) { this.subject = subject; this.options = options; } static serialize(subject, options) { return new this(subject, options).serialize(); } static serializeArray(subjects, options) { return subjects.map((subject) => this.serialize(subject, options)); } serialize() { return this.constructor.serializableAttributes.reduce( (acc, attribute) => { acc[attribute] = this[attribute]; return acc; }, {}, ); } }
import Model from '#src/core/model.js'; const sequenceSymbol = Symbol('sequence'); const isSequence = value => value && typeof value[sequenceSymbol] === 'function'; export default class Factory { static factoryMap = new Map(); static modelMap = new Map(); static prepare(factory, model, base = {}, traits = {}) { const modelName = model.name; const factoryBase = {}; Object.keys(base).forEach(key => { const value = base[key]; const getter = isSequence(value) ? value[sequenceSymbol] : () => value; Object.defineProperty(factoryBase, key, { get: getter, enumerable: true, }); }); if (!Factory.modelMap.has(modelName)) Factory.modelMap.set(modelName, factory); Object.defineProperty(factory, 'build', { value: function (...desiredTraits) { const data = { ...factoryBase }; desiredTraits.forEach(trait => { if (typeof trait === 'string') Object.assign(data, traits[trait]); else if (typeof trait === 'object') Object.assign(data, trait); else if (typeof trait === 'function') Object.assign(data, trait(data)); }); return new model(data); }, }); } static sequence = (fn = n => n) => { let i = 0; const sequence = () => fn(i++); return { [sequenceSymbol]: sequence }; }; static build(model, ...desiredTraits) { return Factory.modelMap.get(model).build(...desiredTraits); } static buildArray(model, count, ...desiredTraits) { return Array.from({ length: count }, () => Factory.build(model, ...desiredTraits) ); } static clear(model) { Model.instances[model] = []; Model.indexedInstances[model] = new Map(); Model.getterCache[model] = {}; } static clearAll() { Model.instances = {}; Model.indexedInstances = {}; Model.getterCache = {}; } }
import { readdir } from 'node:fs/promises'; import settings from '#src/config/settings.js'; const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1); const autoload = async () => { const moduleMap = new Map(); for (const path of settings.loader.modules) { // Read each directory (this requires a path relative to the project root) const moduleFiles = await readdir(path.replace(/^#/, './')); for (const moduleFile of moduleFiles) { // Convert the file name to a module name (e.g., post.js -> Post) const moduleName = capitalize(moduleFile.split('.')[0]); if (!moduleMap.has(moduleName)) { // Dynamically import the module and add it to the map const module = await import(`${path}/${moduleFile}`); moduleMap.set(moduleName, module.default); } else throw new Error(`Duplicate class name: ${moduleName}`); } } // Convert the map to an object and return it, so that it can be exported return Object.fromEntries(moduleMap.entries()); }; const modules = await autoload(); export default { ...modules, settings };
import repl from 'node:repl'; import modules from '#src/scripts/autoload.js'; // Start the REPL server const replServer = repl.start(); // Set up a history file for the REPL replServer.setupHistory('repl.log', () => {}); // Add the autoloaded modules to the REPL context Object.entries(modules).forEach(([moduleName, module]) => { replServer.context[moduleName] = module; });
import Model from '#src/core/model.js'; export default class Post extends Model { static { // Prepare storage for the Post model super.prepare(this, { fields: [ ['title', { type: 'string', allowEmpty: false }], ['content', 'string'], ['publishedAt', { type: 'date', defaultValue: new Date() }], ], relationships: [ ['belongsTo', 'author'], ['belongsTo', 'category'], ], }); } constructor(data) { super(data); } static published(records) { return records.where({ isPublished: true }); } static byNew(records) { return records.order((a, b) => b.publishedAt - a.publishedAt); } get isPublished() { return this.publishedAt <= new Date(); } }
import Model from '#src/core/model.js'; // Delete: import Post from '#src/models/post.js'; export default class Author extends Model { static { // Prepare storage for the Author model super.prepare(this, { fields: [ ['name', { type: 'string', allowEmpty: false }], ['surname', 'string'], ['email', { type: 'string', unique: true, inspectable: false }], ], validations: [record => record.email.includes('@')], relationships: [['hasMany', 'posts']], }); } constructor(data) { super(data); } get fullName() { return this.surname ? `${this.name} ${this.surname}` : this.name; } }
import Model from '#src/core/model.js'; export default class Category extends Model { static { // Prepare storage for the Category model super.prepare(this, { fields: [['title', { type: 'string', allowEmpty: false }]], relationships: [ ['hasMany', 'posts'], [ 'belongsTo', { target: 'category', foreignKey: 'parentId', as: 'parent' }, ], [ 'hasMany', { target: 'category', foreignKey: 'parentId', as: 'children' }, ], ], }); } constructor(data) { super(data); } static root(records) { return records.where({ parentId: null }).first; } }
import Serializer from '#src/core/serializer.js'; export default class PostSerializer extends Serializer { static { super.prepare(this, [ 'title', ['content', post => `<p>${post.content}</p>`], ['date', post => { const date = new Date(post.publishedAt); return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); }], ['author', (post, options) => { const author = post.author; const result = { name: author.fullName }; if (options.showEmail) result.email = author.email; return result; }] ]); } }
import Serializer from '#src/core/serializer.js'; export default class PostPreviewSerializer extends Serializer { static { super.prepare(this, [ 'title', ['date', post => { const date = new Date(post.publishedAt); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }], ['author', post => post.author.fullName], ['url', post => `/posts/${post.id}`] ]); } }
import Factory from '#src/core/factory.js'; import Author from '#src/models/author.js'; const idSequence = Factory.sequence(); const base = { id: idSequence, name: 'Author', surname: 'Authorson', email: 'author@authornet.io', }; export default class AuthorFactory extends Factory { static { super.prepare(this, Author, base); } }
import Factory from '#src/core/factory.js'; import Post from '#src/models/post.js'; const idSequence = Factory.sequence(); const titleSequence = Factory.sequence(n => `Post #${n}`); const base = { id: idSequence, title: titleSequence, content: 'Post content', }; const traits = { published: { publishedAt: new Date(), }, unpublished: { publishedAt: null, }, }; export default class PostFactory extends Factory { static { super.prepare(this, Post, base, traits); } }
import Factory from '#src/core/factory.js'; import Category from '#src/models/category.js'; const idSequence = Factory.sequence(); const titleSequence = Factory.sequence(n => `Category #${n}`); const base = { id: idSequence, title: titleSequence, parentId: null, }; export default class CategoryFactory extends Factory { static { super.prepare(this, Category, base); } }