ajv-orm v0.2.0
AJV ORM
Use AJV to grant SQL-like superpowers to JSON data ✨
Define data models using JSON Schema with custom keywords. Impose foreign key constraints on CRUD operations. Use getters, setters, and methods to enhance data with functionality.
Table of Contents
- AJV ORM
Install
npm i ajv ajv-orm
Example
Setup
import Ajv from 'ajv'
import Orm from 'ajv-orm'
const ajv = new Ajv()
const orm = new Orm(ajv)
Define Models
Define data Models using JSON Schema w/ custom keywords.
const Person = orm.createModel({
$id: 'Person',
$primaryKey: 'name', // property "name" will be marked as required
properties: {
name: { type: 'string' },
age: { type: 'number', default: 21 }, // Default property values!
parent: { $foreignKey: 'Person' }, // Foreign keys!
heritage: {
$getter() { // Getters!
return this.parent ? `${this.parent} > ${this.name}` : this.name
},
},
exclaim: {
$method(words) { // Methods!
console.log(`${this.name}: "${words}!"`)
},
},
},
})
Working With Data
// Create Model Objects
const Alice = Person.create({ name: 'Alice' })
const Elizabeth = Person.create({ name: 'Elizabeth', age: 95 })
const Charles = Person.create({ name: 'Charles', age: 72, parent: 'Elizabeth' })
// OR
const Alice = Person.create('Alice')
const Elizabeth = Person.create('Elizabeth', { age: 95 })
const Charles = Person.create('Charles', { age: 72, parent: 'Elizabeth' })
Person.objects // { Alice, Elizabeth, Charles }
// CRUD methods
Person.create('Foo')
Person.get('Foo') // Object for Person::Foo
Person.update('Foo', { age: 9000 }) // set to { name: "Foo", age: 9000 }
Person.patch('Foo', { parent: 'Elizabeth' }) // set Foo.parent only
Person.remove('Foo')
// Getters and methods in action!
Alice.heritage // "Alice"
Charles.heritage // "Elizabeth > Charles"
Elizabeth.exclaim('Hello World') // Elizabeth: "Hello World!"
// Foreign key relationships are enforced
Person.create('Foo', { parent: 'Bar' }) // err: Person::Bar does not exist
Person.update('Alice', { parent: 'Bar' }) // err: Person::Bar does not exist
Person.remove('Elizabeth') // err: Person::Charles still references this
Person.patch('Charles', { parent: undefined }) // remove reference
Person.remove('Elizabeth') // works now!
// Search Model Objects w/ JS
Person.find({ name: (name) => name.startsWith('A') }) // finds: [Person::Alice]
// Validation
const { valid, error } = Person.validate({ parent: 'Elizabeth' })
if (!valid) throw error // err: property "name" is required
Concepts
ORM (Object-Relational Mapping)
In a nutshell, an ORM is a sort of "virtual object database" that lets you describe data types and their relationships to each other, and creates an easy way to work with the data using those descriptions. ORMs also enable functional additions to raw data, such as computed properties and methods.
Models
Models are descriptions of object data types.
const Element = orm.createModel({
$id: 'Element',
$primaryKey: 'name', // automatically marked as required property
properties: {
name: { type: 'string' },
},
})
Element.id // "Element"
Element.primaryKey // "name"
Element.schema // Full JSON Schema description
Created Models are registered in orm.models
by their ID. They can also be retrieved with orm.getModel()
.
orm.models // { Element }
Element === orm.models.Element // true
Element === orm.getModel('Element') // true
Model Objects
Models can instantiate Model Objects which conform to their schemas:
const fire = Element.create({ name: 'fire' })
// or
const fire = Element.create('fire', { /* other data if needed */ })
Created Model Objects are stored in Model.objects
by their primary key. They can also be retrieved with Model.get()
.
const water = Element.create('water')
Element.objects
// {
// fire: { name: 'fire' },
// water: { name: 'water' }
// }
water === Element.objects.water // true
water === Element.get('water') // true
You can also query a Model's Objects with Model.find()
:
Element.find() // [{ name: 'fire' }, { name: 'water' }]
Element.find({ name: (n) => n.length === 4 }) // [{ name: 'fire' }]
Default Values
Model.create()
will apply JSON Schema default values to the Model Object:
const Item = orm.createModel({
$id: 'Item',
$primaryKey: 'name',
properties: {
name: { type: 'string' },
cost: { type: 'number', default: 0 }, // defaults to 0
equipped: { type: 'boolean', default: false }, // defaults to false
},
})
Item.create('sword') // { name: "sword", cost: 0, equipped: false }
CRUD Methods
Models supports 6 CRUD methods: create, get
, find
, update, patch, and remove.
Item.create('belt', { cost: 5, equipped: true })
Item.get('belt')
Item.find({ equipped: false }) // find multiple objects
Item.update('belt', { cost: 10, equipped: false }) // overwrite
Item.patch('belt', { cost: 8 }) // a partial update
Item.remove('belt')
Primary Keys
A primary key is a unique identifier among a Model's Objects. Attempting to create another Object with the same primary key will throw an Error.
Element.create('fire') // err: Element::fire already exists
The helper function getPK()
can be used to get the primary key of a given Model Object:
const earth = Element.create('earth')
getPK(earth) // "earth"
Foreign Keys
Model Objects can reference other Model Objects via primary keys. This is called a foreign key relationship.
const Spell = orm.createModel({
$id: 'Spell',
$primaryKey: 'name',
properties: {
name: { type: 'string' },
element: { $foreignKey: 'Element' },
},
})
Spell.create('fireball', { element: 'fire' })
Spell.create('tornado', { element: 'air' }) // err: Element::air does not exist
Element.create('air')
Spell.create('tornado', { element: 'air' }) // now it works!
A Model Object's foreign keys are stored as a JavaScript Set containing the referenced Model Objects, or undefined
if none exist. This data is accessible via a helper function:
import { getForeignKeys } from 'ajv-orm'
const tornado = Spell.objects.tornado
const fks = getForeignKeys(tornado) // Set<Element::air>
fks.forEach((element) => console.log(element.name)) // 'air'
const air = Element.objects.air
getForeignKeys(air) // undefined - air doesn't point to other Model Objects
Finding foreign keys from a given object is easy. The reverse - finding foreign keys on other objects which refer to your given object - is harder. The ORM holds this information in an internal cache and exposes it through a method orm.getRefsToObject()
:
const fire = Element.objects.fire
orm.getRefsToObject(fire) // Set<Spell::fireball, Potion::molotov>
Self References
Models and Model Objects can have foreign key relationships with themselves:
const Person = orm.createModel('Person', 'name', {
name: { type: 'string', required: true },
favoritePerson: { $foreignKey: 'Person' },
})
Person.create('Narcissus', { favoritePerson: 'Narcissus' })
Two-Way Relationships
There are situations where foreign keys could reference Model Objects which do not yet exist. One common case is when you want to create two Model Objects which refer to one another. For these situations, use orm.batchEditObjects()
to create multiple objects at once:
orm.batchEditObjects({
Person: {
Merlin: ['create', { favoritePerson: 'Arthur' }],
Arthur: ['create', { favoritePerson: 'Merlin' }],
},
})
Model Object IDs
Model Objects can be identified by a ModelID::primaryKey
pattern, called a Model Object ID.
The $modelObjectID
keyword is like $foreignKey
for Model Object IDs:
const Mage = orm.createModel('Mage', {
name: { type: 'string', required: true },
focus: {
$modelObjectID: ['Spell', 'Element'],
},
})
Mage.create({ name: 'Pyromancer', focus: 'Element::fire' })
Mage.create({ name: 'Storm Caller', focus: 'Spell::tornado' })
You can get the Model Object ID for a given Model Object with the helper method getModelObjectID()
:
import { getModelObjectID } from 'ajv-orm'
getModelObjectID(fire) // "Element::fire"
Getters, Setters, and Methods
The custom keywords $getter
, $setter
, and $method
let you attach JavaScript code to Model Objects:
const Potion = orm.createModel({
$id: 'Potion',
$primaryKey: 'name',
properties: {
name: { type: 'string' },
uses: { type: 'number', default: 3 },
empty: {
$getter() {
return this.uses <= 0
},
$setter(val) {
if (val === true) {
Potion.patch(this.name, { uses: 0 })
}
},
},
drink: {
$method(amount = 1) {
if (this.empty) throw "It's empty!"
if (amount > this.uses) throw 'Not enough left...'
Potion.patch(this.name, { uses: this.uses - amount })
},
},
refill() {
$method() {
Potion.patch(this.name, { uses: 3 })
}
}
},
})
const healthPotion = Potion.create('Health Potion')
// { name: "Health Potion", uses: 3, empty: false, drink() }
healthPotion.drink(2)
healthPotion.uses // 1
healthPotion.empty = true
healthPotion.drink() // error; "It's empty!"
healthPotion.refill()
healthPotion.empty // false
healthPotion.uses // 3
⚠️ Methods and Setters should avoid mutating the Model Object directly, as the new data would not be validated. Use
Model.patch()
orModel.update()
instead.
API
JSON Schema Keywords
AJV custom keywords. The ORM registers them with AJV during instantiation.
$primaryKey
The schema declares the key of an entry in the schema's properties
object. Only top-level properties are supported as primary keys.
$foreignKey
The schema declares a Model ID to target. The data is valid if the property's value is a primary key of a registered Model Object of the targeted Model.
$modelObjectID
The schema declares an array of Model IDs to target, or true
to target any Model. Data is valid if the property value is a Model Object ID (ModelID::primaryKey
) corresponding to a registered Model Object.
See: Concepts > Model Object IDs
$getter
The schema declares a function with no arguments. The function is attached to the Model Object as a getter.
The $getter
keyword has some caveats:
Not support in JSON Schema's
patternProperties
oradditionalProperties
Not supported in nested object
properties
Because it relies on JavaScript functions, it is not serializable to JSON.
See: Concepts > Getters, Setters, and Methods
$setter
The schema declares a function with exactly 1 argument. The function is attached to the Model Object as a setter.
The $setter
keyword has the same caveats as $getter
.
$setter
and $getter
can be used in combination. This pattern is referred to as a "computed property".
See: Concepts > Getters, Setters, and Methods
$method
The schema declares a function with any number of arguments. The function is attached to the Model Object as a method.
The $method
keyword has the same caveats as $getter
.
See: Concepts > Getters, Setters, and Methods
Internal Keywords
These custom keywords are defined for internal use.
$isFunction
The schema defines a boolean. When true
, the data value is valid if it's a function. When false
, the data is valid if it's not a function.
Internally used by $getter
, $setter
, and $method
to validate that their schema values are functions. This capability is not part of the normal JSON Schema spec, since it only concerns itself with JSON-serializable types.
ORM
The core class to instantiate AJV ORM.
import Ajv from 'ajv'
import Orm from 'ajv-orm'
const ajv = new Ajv()
const orm = new Orm(ajv)
The ORM registers custom JSON Schema keywords on the passed AJV instance.
orm.ajv
The AJV instance passed to the ORM when it was initialized. Used for schema validation and applying default values.
orm.models
A registry mapping Model IDs to Models.
orm.createModel()
Create and register a Model. The primaryKey
is automatically marked as required.
See: Concepts > Models
orm.getModel()
Retrieve a Model by its id
, or by onr of its Objects. Same as orm.models[modelID]
.
orm.removeModel()
Delete a Model and its Object. Fails if there are external references to any of the Objects.
orm.batchEditObjects()
Takes a nested map of Model IDs and primary keys to descriptions of actions to perform on the corresponding Model Object.
Action descriptions are arrays with 1 or 2 items. The first item is the name of a CRUD operation to execute ("create"
, "update"
, "patch"
, or "remove"
). The second item is an optional Model Object Definition to be used in the CRUD operation as needed.
Operations are validated as a batch before execution.
orm.batchEditObjects({
Element: {
fire: ['remove'],
water: ['create'],
},
Spell: {
blowOffSteam: ['update', { element: 'water' }],
},
Potion: {
firewater: ['patch', { element: 'water' }],
},
})
See also: Concepts > Two-Way Relationships
orm.getObject()
Retrieve a Model Object by its Model Object ID.
orm.getRefsToObject()
Determine which Model Objects refer to the passed Model Object. Returns a JavaScript Set of Model Objects, or undefined
if the passed Model Object has no references.
const fire = Element.objects.fire
orm.getRefsToObject(fire) // Set<Spell::fireball, Potion::molotov>
See also: Concepts > Foreign Keys
Model
A Model describes a data type as defined by a Model Definition.
Model ID
A string which uniquely identifies a Model in the ORM. It has the following constraints:
must have non-zero length
must not contain
"::"
(because it is used in Model Object IDs)
Model Definition
A JSON Schema with the following constraints:
The schema must describe an object. The field
type: "object"
will be provided for you and can be omitted.The schema must declare an
$id
property whose value is a valid Model ID.The schema must declare a
$primaryKey
- a custom keyword provided by AJV ORM. The primary key should be the name of one of theproperties
defined in the schema. The primary key will be marked asrequired
.The schema must declare at least one
property
- the primary key.
Model.id
Reflects Model.schema.$id
.
Element.id // "Element"
Model.primaryKey
Reflects Model.schema.$primaryKey
.
Element.primaryKey // "name"
Model.schema
The validated Model Definition JSON Schema, with the following default properties included:
$schema
is"http://json-schema.org/draft-07/schema#"
type
is"object"
required
is an array that contains at least the primary key property name, merged with any other required properties declared in the definition.
Model.ObjectPrototype
Model Objects use this as their prototype.
Model.objects
A registry mapping primary keys to Model Objects.
Elements.objects
// {
// fire: { name: 'fire' },
// water: { name: 'water' },
// earth: { name: 'earth' },
// air: { name: 'air' }
// }
Model.is()
Given a Model Object, returns whether or not the Object's Model is this Model.
Element.is(fire) // true, fire is an Element
Element.is(swprd) // false, sword is an Element
It does not check if the Model Object is still registered:
Element.remove(fire)
Element.is(fire) // still true; the fire Object contains a reference to Element
Model.validate()
Validate a Model Object Definition against the Model's schema using the internal AJV instance. Returns an object containing 3 properties:
valid
- Boolean indicating validity.foreignKeys
- A Set of object references from valid foreign keys encountered, orundefined
if the definition contained no foreign keys.error
- When invalid, this is anAjvValidationError
, which is a synopsis of the AJV errors that includes enhanced error messages for foreign key errors. Throw the error or read its message. it or Foreign Keys encountered whether or the the true if valid, else returns the AJV errors. AJV errors for invalid foreign keys are replaced with more useful custom error messages.
// Valid response
const response = Spell.validate({ name: 'fireball', element: 'fire' })
// { valid: true, foreignKeys: Set<Element::fire> }
// Invalid response
const response = Spell.validate({ element: 'fire' })
if (!response.valid) throw response.error // property "name" is required
Model.create()
Creates a Model Object from a Model Object Definition.
Item.create({ name: 'shield', cost: 10 })
Optionally, the primary key and the rest of the data can be passed separately.
Item.create('belt', { cost: 5 })
When the primary key is the only required property, you can omit the object:
Item.create('ring')
Model.get()
Retrieves a Model Object by its primary key. Returns undefined
if an object with that primary key was not found.
Item.get('shield')
Model.find()
Returns an array of matching Model Objects. The method can be used in several ways:
// no args retrieves all Model Objects
Item.find()
// pass an object to filter Model Objects by key-value pairs
Item.find({ equipped: false })
// pass a function for full flexibility
Item.find((item) => item.name.startsWith('S'))
// use objects w/ functions for dynamic value comparisons
Item.find({
name: (name) => name.startsWith('R'),
cost: (cost) => cost < 10,
})
Model.update()
Mutates an existing Model Object, overwriting all of its mutable properties to match the new Model Object Definition. Uses the primary key value of the Model Object Definition to target the Model Object.
Item.update({ name: 'shield', cost: 10, equipped: false })
Optionally, the primary key and the rest of the data can be passed separately.
Item.update('belt', { cost: 5, equipped: true })
Primary Keys cannot be updated - remove the Model Object and create a new one instead.
Model.patch()
Mutates mutable properties of an existing Model Object by merging a partial Model Object Definition into it. Uses the primary key value of the Model Object Definition to target the Model Object.
Item.patch({ name: 'sword', cost: 15, equipped: true })
Item.patch({ name: 'shield', cost: 10 }) // does not augment property "equipped"
Item.patch({ name: 'belt', equipped: true }) // does not augment property "cost" "equipped"
Optionally, the primary key and the rest of the data can be passed separately.
Item.patch('belt', { equipped: true })
Model.remove()
Deletes a Model Object from the registry given its primary key, or an object containing the primary key.
Item.remove({ name: 'belt', /* all other data is ignored */ })
// or
Item.remove('water')
Model Object
A JavaScript object created by and registered within a Model. It contains:
The properties from the Model Object Definition used to create it
The Model's default values
$getter
properties registered as JavaScript object getters$setter
properties registered as JavaScript object setters$method
properties registered as JavaScript object methodsFour symbolic properties.
Mutable and Immutable Properties
The following property types are immutable:
- the primary key
$getter
s,$setter
s, and$method
s- symbolic properties
All other properties are mutable via Model.update()
and Model.patch
.
Symbolic Properties
Each Model Object has four keys which are represented by JavaScript Symbols so that they cannot clash with any of the properties defined on the Model schema. These symbols are exported for users to import as needed, but are also accessible via helper functions.
import { SYMBOLS } from 'ajv-orm'
ModelObject[SYMBOLS.MODEL]
Symbolic property whose value is a reference to the Model Object's corresponding Model.
Also accessible via the helper function getModel()
.
ModelObject[SYMBOLS.PRIMARY_KEY]
Symbolic property whose value is the primary key of the passed Model Object.
Also accessible via the helper function getPK()
.
ModelObject[SYMBOLS.MODEL_OBJECT_ID]
Symbolic property whose value is a getter which returns the Model Object ID of the passed Model Object.
Also accessible via the helper function getModelObjectID()
.
ModelObject[SYMBOLS.FOREIGN_KEYS]
Symbolic property whose value is the Model Object's foreign keys to other Model Objects. The value is undefined
if the Model Object has no foreign keys.
Also accessible via the helper function getForeignKeys()
.
Model Object ID
A string of the form "ModelID::primaryKey"
which uniquely identifies a Model Object within the ORM.
See: Concepts > Model Object IDs
Model Object Definition
A JavaScript object used to create or update Model Objects, if successfully validated against the Model's schema.
Model Object Foreign Keys
A Javascript Set containing references to Model Objects, or undefined
if the Model Object has no foreign keys.
A Model Object's foreign keys can be obtained via the getForeignKeys()
helper function.
Model Object Helper Functions
getModel()
Returns the Model of the passed Model Object.
getPK()
Returns the primary key of the passed Model Object.
getModelObjectID()
Returns the Model Object ID passed of the passed Model Object.
getForeignKeys()
Returns the the Model Object's foreign keys.
About
Built as part of the DirectDB project.
Special thanks to AJV and JSON Schema for making this possible, and Django for showing the way.
Built with VS Code, TypeScript, Volta, Jest, ESLint, Prettier, Yarn, ESBuild, and tsup.
ISC Licensed