0.2.0 • Published 3 years ago

ajv-orm v0.2.0

Weekly downloads
-
License
ISC
Repository
github
Last release
3 years ago

NPM GitHub Workflow Status Node.js Versions

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

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() or Model.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.

See: Concepts > 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.

See: Concepts > Foreign Keys

$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 or additionalProperties

  • 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 the properties defined in the schema. The primary key will be marked as required.

  • 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, or undefined if the definition contained no foreign keys.

  • error - When invalid, this is an AjvValidationError, 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:

Mutable and Immutable Properties

The following property types are immutable:

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.

See: Concepts > Foreign Keys

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

0.2.0

3 years ago

0.1.2

3 years ago

0.1.1

3 years ago

0.1.0

3 years ago