Skip to content

A library for composing data models from any number of sources.

License

Notifications You must be signed in to change notification settings

dstreet/polymod

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

98 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Polymod

Build Status Known Vulnerabilities npm

A library for composing data models from any number of sources. Inspired by GraphQL and Falcor.

Install

npm install --save polymod

License

MIT License

Documentation

Defining a model

Interacting with a model


Introduction

Polymod is a Node.js library for composing application data models. Unlike other data modeling libraries, such as Mongoose or Sequelize, Polymod is agnostic and is designed to interface with any data source so long as it conforms to a simple source interface. Additionally, each source in a Polymod model can come from a different data source. For instance, an application may need to pull data from a Postgres database as well as related session information from a Redis store. Similarly, an application may be an interface between two or web services. A Polymod model could consume resources from multiple RESTful APIs.

Models

A Model in Poly defines the data sources, queries, mutations, and data structure for a data model.

const { Model, Query } = require('polymod')

const OrderDetail = Model
	.create()

	// Add sources
	.addSource('order', new MemSource(store, 'orders'))
	.addSource('customer', new MemSource(store, 'customers'))
	.addSource('products', new MemSource(store, 'products'))
	
	// Add the default query
	.addQuery('default', orderQuery)

	// Add a mutation
	.addMutation('ship', shipOrder)

	// Describe the data structure
	.describe({
		shipped: {
			type: Boolean,
			required: true,
			default: () => false,
			data: ({ order }) => order.shipped
		},
		date: {
			type: {
				created: Date,
				payed: Date
			},
			default: () => ({ created: new Date() }),
			data: ({ order }) => ({
				created: order.dateCreated,
				payed: order.datePayed
			})
		},
		customer: {
			type: { name: String, address: String },
			required: true,
			data: ({ customer }) => ({
				name: customer.name,
				address: customer.address
			})
		},
		products: {
			type: [{ title: String, price: Number }],
			required: true,
			data: ({ products }) => products.map(product => ({
				title: product.title,
				price: product.price
			}))
		},
		total: {
			type: Number,
			data: ({ products }) => products.reduce((total, product) => {
				return total + product.price
			}, 0)
		}
	})

In this example, the model, OrderDetail defines data from three in-memory sources: orders, customers, and products. A default query is added to fetch the data from the sources (more on queries below), a mutation is added to ship an order (more on mutations below), and the data structure is defined with the following properties: shipped, date, customer, products, and total. Each of these properties defines a data function, which is used to transform the source data to the final document property. Three other attributes are defined on some of the data structure properties:

  • type: Defines the schema type of the property
  • default: A function to set the default value of a property
  • required: Whether the property is required or not

All of these properties, including data are optional. If data is not defined, then the property is considered write-only.

Sources

Sources are the interface between the model and the data sources. Polymod ships with a single source, MemSource, which interfaces with the in-memory storage provided by MemStore. Additionally, every model created with Polymod also implements the source interface, allowing models to also be used as sources for other models.

Creating a new source for use with Polymod is fairly straight forward. Sources are objects, which implement two methods: fetch and mutate.

The fetch method takes two parameters:

  • operation: The source operation to perform (i.e. 'read')
  • selector: The selector used to fetch the data

The mutate method takes a single parameter:

  • operations: An array of operations to be performed on the source. See mutations for more information.

Queries

Polymod queries are the interface for fetching a model's data from its sources. These are defined using the Query class. Every model must have at least one query, the 'default' query.

const orderQuery = Query
	.create()
	.addPopulation({
		name: 'order',
		operation: 'read',
		selector: ({ input }) => ({ id: input })
	})
	.addPopulation({
		name: 'customer',
		operation: 'read',
		requires: ['order'],
		selector: ({ order }) => ({ id: order.customer })
	})
	.addPopulation({
		name: 'products',
		operation: 'read',
		requires: ['order'],
		selector: ({ order }) => order.products.map(product => ({ id: product }))
	})

Queries define populations, which instruct the model on how to fetch data from a the source. A population is an object with the following properties:

  • name: The source name
  • operation: The source operation to use
  • requires: The populations which must be complete before this population
  • selector: A function that takes any available input and source data and returns a selector for the source

Mutations

By default Polymod sources are immutable. In order to allow source data to be mutated, mutations need to be defined by the model. Mutations are defined as an array of operations by source.

const shipOrder = [
	{
		source: 'post',
		operations: (input, { post }) => ([
			{
				name: 'update',
				selector: { id: post.id },
				data: { shipped: true }
			}
		]),
		results: ([ post ]) => post
	}
]

The mutation array should contain objects with the following properties:

  • source: The source being mutated
  • operations: A function returning an array of operations
  • results: A function that returns data from the operations

The operations function takes the mutation input data, and object containing the existing source data. The function should return an array of operations, which is an object containing the operation name, the mutation selector (optional), and the data to be mutated (also optional).

The results function is passed an array with each of the operation results as an element in the array.

get()

Every model has a get method, which executues the default query with the given input. The returned value should either be a Document or an array of Documents. The data for a document can be retrieved using the data property.

const doc = await OrderDetail.get(1)

console.log(doc.data)
/*
{
	shipped: false,
	date: {
		created: '2017-01-01',
		payed: null
	},
	customer: {
		name: { first: 'John', last: 'Smith' },
		adddress: {
			street: '300 BOYLSTON AVE E',
			city: 'SEATTLE',
			state: 'WA',
			zip: 98012
		}
	},
	products: [
		{
			title: 'You Don\'t Know JS: Up & Going',
			price: 4.99
		},
		{
			title: 'JavaScript: The Good Parts',
			price: 21.93
		}
	],
	total: 26.92
}
*/

query()

The query method executes a named query with the given input. For example, the default query could also be executed as and the result is the same:

const doc = await OrderDetail.query('default', 1)

mutate()

The mutate method allows access to the model's mutations and can be called in one of two ways:

  • mutate(name, data): This will execute the mutation with name name
  • mutate(dataObject): This will treat each property in dataObject as a mutation. In order to do this, the property name must be a defined mutation.
const [ newDoc, error ] = await doc.mutate('ship')

In either instance, the returned value of the mutate method is an array with two elements: the new document, and an error if the mutation failed for some reason. If the mutation was successful, error will be undefined. However, if there was an error, newDoc will be null.

create()

The create method, as its name suggests, is used to create new model documents. However, in order to create documents, an initializer needs to be defined for the model. This is done using the setInitializer model method.

OrderDetail.setInitializer([
	{
		source: 'order',
		operations: input => ([
			{
				name: 'create'
			}
		]),
		results: ([ order ]) => order
	}
], {
	customer: Number,
	products: [Number]
})

The initializer is simply a special mutation that is called to mutate the data sources as needed. The second, and optional argument, is the type schema for input data. If the model has a descriptor, the properties defined here, will override any types in the descriptor.

const [ doc, error ] = await OrderDetail.create({
	customer: 2,
	products: [1]
})

remove()

remove is a method of a Document instance, and like the create method, a special mutation needs to be defined before documents can be removed. This is achieved using the setRemove method.

OrderDetail.setRemove([
	{
		source: 'order',
		operations: ({ input }) => ([
			{
				name: 'remove',
				selector: { id: input }
			}
		])
	}
])

The returned value from the remove method is an object with properties for each mutated source, and values containing the operation results.

const removed = await doc.remove()

describe()

When calling the describe method without any parameters, the model will return the defined data descriptor types.

console.log(OrderDetail.describe())

/*
{
	shipped: {
		type: Boolean,
		required: true
	},
	date: {
		type: {
			created: Date,
			payed: Date
		}
	},
	customer: {
		type: { name: String, address: String },
		required: true
	},
	products: {
		type: [{ title: String, price: Number }],
		required: true
	},
	total: {
		type: Number
	}
}
*/

About

A library for composing data models from any number of sources.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published