scheming

2.1.7 • Public • Published

Scheming!

Build Status

Define powerful object schemas in javascript. Includes default values, getters, setters, validators, type checking or coercion. Builds reactive objects which you can watch for changes. Written to be extensible and customizable. Works on the browser or the server.

So what does it look like?

Scheming = require('Scheming')

User = Scheming.create 'User',
  name :
    type : String
    required : true
  email :
    type : String,
    required : true
    validate : (val) ->
      if val.match('@')
        return true
      else
        return 'An email address must have an @ symbol!'
  birthday : Date
  password :
    type : String
    setter : (val) ->
      return md5(val)

Group = Scheming.create 'Group',
  name : String
  dateCreated : {type : Date, default : -> Date.now()}
  users : [User]

jane = new User
  email : 'jane.gmail.com'
  birthday : '9/14/86'
  password : 'p@$$w0rd!'

console.log User.validate jane
# {name : 'Field is required.', email: 'An email address must have an @ symbol!'}

jane.name = 'jane'
jane.email = 'jane@gmail.com'

console.log User.validate jane
# null

API Docs

Scheming

Scheming.THROTTLE

Defines throttling strategies for resolution of changes to scheming models. If the selected option is not supported in the current environment, a console.warn will be issued and no change is made. Options are:

  • TIMEOUT sets a timeout of 0
  • IMMEDIATE uses setImmediate
  • ANIMATION_FRAME uses requestAnimationFrame

Scheming.TYPES

Defines the primitive types that can be assigned to a field in a schema definition. Each type defines a string name, an identifier, and a parser. A type may also optionally provide a constructor reference. For detailed reference, see the TYPE definitions in source. Note the Mixed type, which is effectively untyped, and will allow for any value to be assigned.

Below are the default types and the ways that you can reference them when defining a schema:

  • String Scheming.TYPES.String 'string' String
  • Number Scheming.TYPES.Number 'number' Number
  • Integer Scheming.TYPES.Integer 'integer'
  • Date Scheming.TYPES.Date 'date' Date
  • Boolean Scheming.TYPES.Boolean 'boolean' Boolean
  • Mixed Scheming.TYPES.Mixed '*'

For example, the following are equivalent:

Scheming.create {name : Scheming.TYPES.String}
Scheming.create {name : 'string'}
Scheming.create {name : String}

The Mixed type is intended for arbitrary untyped data, including nested objects, arrays, etc. Note that when watching properties of the Mixed type, a watch will fire on reference change (re-assignment), but will not fire on mutations of the value. If you need watches to propagate, then you need to use nested schemas so that Scheming knows about nested properties and can manage changes.

Custom types

You can extend the Scheming TYPES object to add support for custom types. For example:

Scheming.TYPES.Symbol =
  constructor : Symbol
  string : 'symbol'
  identifier : (val) ->
    typeof val == "symbol"
  parser : (val) ->
    Symbol(val)

# I can now declare Schemas with my new type
Scheming.create {name : Symbol}

Custom parsing and identifiers

In addition to declaring new types, you can modify the currently existing types. For example, say you don't like dealing with javascript Date objects, and would rather use with moment.js.

Scheming.TYPES.Date.identifier = moment.isMoment
Scheming.TYPES.Date.parser = moment

Person = Scheming.create birthday : Date

bill = new Person {birthday : '9/14/86'}

bill.birthday.format "YYYY MM DD"
# "1986 09 14"
# Bill's birthday is a momentjs object, and has the format method!

Scheming.NESTED_TYPES

In addition to the 'primitive' types defined in Scheming.TYPES, Schemas also support arrays values and nested schemas. Anywhere you can provide a type declaration, you can use the following nested types. This section assumes some knowledge of property configuration syntax. Take a look at the Schema.defineProperty docs.

Arrays

For any schema you can declare a property whose type is an array of values.

Simple arrays:

BlogPost = Scheming.create
  comments : [String] # an array of strings
  miscellaneous : ['*'] # an untyped array

post = new BlogPost()
post.comments = ['Hello', 'World']
post.miscellaneous = ['Stuff', 2, null, {}]x

Arrays with validation, defaults, etc. Here is a blog post which requires 2 or more comments to be valid. Note that the configuration is being applied to the array itself, not to the members of the array.

BlogPost = Scheming.create
  comments :
    type : [String]
    default : []
    required : true
    validate : (comments) ->
      return comments.length >= 2

Explicit Schemas

Any Schema can have a property whose type is another nested schema. This can be used to create any depth of nesting, or to create circular type definitions. In all cases, a Schema constructor is a valid type definition. When a value is a assigned to a property whose type is Schema, if it is not already an instance of that Schema, it value will be run through the Schema constructor as part of parsing.

Simple nested schemas:

Car = Schema.create
  make : String
  model : String

Person = Schema.create
  name : String
  car : Car

mark = new Person {name : 'mark'}

# Explicit construction and assignment
# At the time of assignment, civic is already an instance of Car
# so the Car constructor will not be invoked a second time
civic = new Car {make : 'honda', model : 'civic'}
mark.car = civic

# Implicit construction
# At the time of assignment, the value is a plain object. Therefore
# the object is passed to the Car  constructor (or in strict mode,
# an error is thrown)
mark.car = {make : 'toyota', model : 'corolla'}
mark.car instanceof Car # true

This is fine for one-way type references. However, it is easy to conceive of data models with circular type references. What do we do in this case? The simple solution is to create both schemas first, then define their properties afterwards, so that the Schema references are valid.

Simple circular type references:

Person = Schema.create()
Car = Schema.create()

Person.defineProperties
  name : String
  car : Car

Car.defineProperties
  make : String
  model : String
  owner : Person

This model still presents a problem. What if my schemas are declared in different files? What if I don't want to juggle references, or be careful about the order in which schemas are declared? This is where lazy initialization comes in. When you create a Schema, you have the option to name it. See the docs on Scheming.create and Scheming.get to understand how to name and retrieve named schemas.

If you have registered a named schema, you can create a type reference to that schema using the syntax 'Schema:Name'. Scheming will accept this as a valid type reference without evaluating immediately or throwing errors. The Schema reference will not be retrieved until the first time the identifier or parser is invoked. So as long as you have declared all of your schemas before you start creating instances, you don't have to worry about it. Note that lazy initialization will throw an error if the Schema reference does not exist at the time of initialization.

Lazy initialization:

# I am registering the Schema with the name 'Person'
Person = Schema.create 'Person',
  name : String
  car : 'Schema:Car'

# This would throw an error, because 'Schema:Car' does not resolve to a registered Schema
bill = new Person
  name : 'Bill'
  car : {make : 'honda', model : 'civic'}

# Now I am creating and registering the 'Car' Schema
Car = Schema.create 'Car',
  make : String
  model : String
  # This reference is using the registered name of the Schema 'Person'
  owner : 'Schema:Person'

# Success!
bill = new Person
  name : 'Bill'
  car : {make : 'honda', model : 'civic'}

Implicit Schemas

What we have seen so far is the ability to explicitly create Schemas and reference them as types. While this is extremely powerful, sometimes you just want to declare nested objects on your Schema. When you do this, new anonymous Schemas are implicitly created and assigned as the type.

In the example below, we create a blog post that has some flat properties, and creates two implicit schemas. The first is the author property, the second is the comments property. Each of these cause an anonymous schema to be created, and any assignment to that value will run the assigned object through the corresponding Schema constructor

Blog = Scheming.create
  title : String
  content : String
  posted : Date
  author :
    name : String
    age : Number
  comments : [{
    text : String
    posted : Date
  }]

Note one subtlety: the syntax for Complex Configuration and implicit schemas is basically the same. In both cases you are using property names and nested objects. Scheming determines whether to treat a nested object as property configuration or a nested schema based on the presence of the type key. This effectively makes type a reserved word for implicit Schemas.

# Oops! In the example below, author is not a nested schema.
# It is a property with a primitive type of string.
Blog = Scheming.create
  author :
    name : String
    age : Number
    type : String

Scheming.DEFAULT_OPTIONS

The default options used when Scheming.create is invoked. If you prefer for all schemas to be created with the seal or strict options set to true, you can modify the default options. See the options on Scheming.create for details.

Scheming.setThrottle(strategy)

Sets the throttling strategy for resolving changes. Valid options are defined by Scheming.THROTTLE.

Scheming.registerQueueCallback(callback)

Registers a callback for when the first change is queued. This callback is guaranteed to be called only once before changes are resolved. This is useful for testing.

Scheming.unregisterQueueCallback(callback)

Unregisters the callback registered with Scheming.registerQueueCallback

Scheming.registerResolveCallback(callback)

Registers a callback to be called when a change is resolved. This callback is guaranteed to be called only once after a queued change and won't be called again until new queued changes are resolved. This is useful for testing.

it 'should update my DOM', (done) ->
  Scheming.registerResolveCallback ->
    expect($('.bill')[0]).to.have.text 'bill'
  bill.name = 'bill'

This can also be used for test runners like Protractor to hook into Angular:

Scheming.registerQueueCallback $browser.$$incOutstandingRequestCount
Scheming.registerResolveCallback -> $browser.$$completeOutstandingRequest(angular.noop)

Scheming.unregisterResolveCallback

Unregisters the callback registered with Scheming.registerResolveCallback

Scheming.get(name)

Retrieves a schema that has been built using Scheming.create.

Scheming.create([name], schema, [opts])

Creates a new Schema constructor.

  • name string optional If provided, registers the scheme with the given name. This must be defined if you wish to retrieve the schema later using the Scheming.get method. It is also necessary for lazy initialization of nested Schema types.
  • schema object A configuration which defines your new schema. Each key represents a supported field, each value a property configuration. See Schema.defineProperty for full specification.
  • opts object optional Allows for some additional configuration of your Schema. All options default to false, but the default values can be modified via Scheming.defaultOptions.
    • opts.seal boolean If true, instances of the schema object are sealed. That is, you will not be able to attach arbitrary values to the objects not explicitly defined in the schema.
    Person = Scheming.create {name : String}, {seal : true}
    
    bill = new Person {name : 'bill', age : 19}
    bill.home = 'Colorado'
    bill.name # 'bill'
    bill.age  # undefined
    bill.home # undefined
    
    • opts.strict boolean If true, when values are assigned to an instance of the schema object, they will not be type coerced. Instead, assignment will throw an error if the assigned value does not match the expected type. This allows for strict typing checking at runtime.
    Person = Scheming.create {age : Number}, {strict : true}
    
    bill = new Person()
    bill.age = 9   # success
    bill.age = '9' # Error : Error assigning '9' to age. Value is not of type number.
    
  • returns Schema

Schema

The constructor function returned by Scheming.create. Constructs objects based on the property definitions outlined in the schema. When you invoke the constructor, you can pass a model with initial values to be applied to the instance.

Person = Scheming.create
  name : String
  age : Number

lisa = new Person
  name : 'lisa'
  age : 8

Schema.defineProperty(property, configuration)

Defines properties on your Schema. This is where you specify properties and their expected type, define default values, getters, setters, and validators.

  • property string The property name.
  • configuration object or TYPE The property configuration. This object determines how the property is configured, and can get a bit complicated.

Simple type configuration

If you do not need any of the other features, you can simply provide a type. You can reference the primitive types in any of the ways outlined in Schema.TYPES

Schema.create 'Person'
    name : String
    age : Number
    birthday : Date

Complex configuration

For more complex field configuration, pass a configuration object. The configuration object supports the following keys, outlined below:

Schema.create
  age :
    type : Number
    default : 2
    getter : (val) -> val * 2
    setter : (val) -> val * 2
    validate : (val) -> val % 2 == 0
    required : true
  • type TYPE A valid type reference as outlined in Schema.TYPES
  • default value or function Specifies the default value a field should take if it is not defined in the constructor. If a function, the function is executed and the return value is set as the default.
  • getter function A getter function that is invoked on the data value before retrieval. Takes the original value as input, the returned value is returned on retrieval. Getter functions are invoked with the this context of the instance, and can be used to define virtual fields.
  • setter function A setter function that is invoked on the data before assignment. Setters are executed BEFORE type checking and parsing. The result of your setter function will still be run through type parsing to guarantee type integrity. Setter functions are not invoked if the value assigned is null or undefined. Setter functions are invoked with the this context of the instance.
  • validate function or Array of functions Validator functions, which are invoked when you run validation on a schema instance. Validators take the value as an input, and should return true if validation passes. They should return a string or throw an error indicating the error if validation occurs. If a validator returns any value that is not true or a string, validation will fail with a generic error message. See Schema.validate for details on how validation works. Validation functions are invoked with the this context of the instance.
  • required boolean A special validator that indicates whether the field is required.

Schema.defineProperties(properties)

A convenience method for defining Schema properties in bulk.

Schema.validate(instance)

Validates an instance of the schema and all child schema instances. Checks for required fields and runs all validators. Validators are not run on fields which are not defined.

  • returns errors object An object with any validation errors, where each key is the path that failed validation, and each value is an array of error messages. If validation passes, will return null.

Validation success

If validation succeeds (including if no validators are defined), validate() returns null

Person = Scheming.create {name : String}

bill = new Person {name : 'bill}

errors = Person.validate bill # null

Validation failure messages

A validator function should return true if it passes. Any other return value will be treated as validation failure. If a validator returns a string, that string will be treated as the failure message. If a validator throws an error at any point, the error.message property will be treated as the failure message. Otherwise, the validation will fail with a generic error message.

If multiple validators are defined, all will be run against the value. The errors object will return error messages for all validators that failed.

Person = Scheming.create
  name :
    type : String
    validate : [
      -> return "Error number one"
      -> throw new Error "Error number two"
      -> return true
      -> return false
    ]

bill = new Person()

errors = Person.validate bill
# returns null, because bill object does not have a name defined, and name is not required

bill.name = 'bill'
errors = Person.validate bill
# {name : ["Error number one", "Error number two", "Validation error occurred."]}

Required and Validators

The required configuration is a special validator that checks if the value is defined. If required validation fails, other validators will not be run. This means that validators are guaranteed to receive a value, and do not need to do null checking.

Person = Scheming.create
  name :
    type : String
    required : true
    validate : [
      -> return "Error number one"
      -> throw new Error "Error number two"
      -> return true
      -> return false
    ]

bill = new Person()

errors = Person.validate bill
# {name : ["Field is required."]}

Instance

The object instance returned by newing up a Schema constructor.

Instance.watch([properties], listener)

Watches a schema instance for changes. The registered listener function will fire asynchronously each time one or more of the specified properties change.

  • properties [optional] String || [String] Specifies the properties to watch on the instance. Accepts a string representing a single property or an array of strings representing one or more properties. If no properties are specified, the entire object will be watched for a change on any property. Will throw an error if any property being watched is not declared as part of the Schema.
  • listener Function function(newVal, oldVal) The listener function that will be called each time the watched properties change.
    • Listener functions are called asynchronously with a setTimeout of 0. If multiple changes are made to an instance in a synchronous block of code, the listener will not be called for each change. It will be called once, with all changes aggregated.
    • A listener function is always called when the watch is set, even if there were no changes to the watched properties.
    • Listener functions are invoked with the current and previous values of the watched properties. If a watch is set against a single property, newVal and oldVal will be the new and previous values. If the watch is set against multiple properties, newVal and oldVal will be objects whose key / value pairs represent the watcher property names and their respective values.
    • In the case of a watch against the entire object, newVal and oldVal will represent the current and previous state of all properties of the object. However. Note that newVal is NOT a reference to the instance object, it is a plain object whose key / value pairs represent the current state of the instance.
    • Watches are "deep". This means changes to nested schemas are propagated up to the parent elements.
  • returns Function The unwatch function. Setting a watch always returns a function that, when invoked, will clear the watch listeners and allow for garbage collection. When setting a watch it is always your responsibility to clean up when you are done with it to avoid memory leaks & unexpected behavior.

IMPORTANT Watchers and change detection depend on the set functionality of Object.defineProperty. This means that changes made by mutating a value will not necessarily be detected. The scheming library does overwrite the most common array mutators (splice, pop, push, etc.) for a seamless experience. But other less common mutations like Date setters will not be picked up. When in doubt, only manipulate schema instance data via assignment with the = operator, and do not use mutators.

Examples

For all examples, we will use the following schema. For more examples, see the tests.

Person = Scheming.create 'Person',
  name : String
  age : Number
  mother : 'Schema:Person'
  friends : ['Schema:Person']

lisa = new Person()

Watching a single property

When a watch is set, it will always be called with the current value, even if no changes were made to the object.

lisa.watch 'name', (newVal, oldVal) ->
  # will be called when the event queue clears
  newVal == undefined
  oldVal == undefined

Synchronous changes will be reflected when the watcher fires.

lisa.name = 'lisa'
lisa.watch 'name', (newVal, oldVal) ->
  newVal == 'lisa'
  oldVal == undefined

Multiple synchronous changes are rolled up

lisa.watch 'name', (newVal, oldVal) ->
  # this listener is called once
  newVal == 'lisa'
  oldVal == undefined

lisa.name = 'a'
lisa.name = 'b'
lisa.name = 'lisa'

Watching multiple properties

lisa.watch ['name', 'age'], (newVal, oldVal) ->
  # newVal == {name : 'lisa', age : 7}
  # oldVal == {name : undefined, age : undefined}

lisa.name = 'lisa'
lisa.age = 7

Cleaning up a watch

unwatch = lisa.watch 'name', (newVal, oldVal) ->
  newVal == 'lisa'

lisa.name = 'lisa'
# some time later...
unwatch()
lisa.name = 'a' # watch no longer fires

Changelog

v2.1.6

  • Removed lodash as a dependency. Utility methods used were implemented in this project to decrease upgrade pains when lodash comes out with new versions (especially in browser usage)
  • Added property/fuzz testing for utility functions. These tests can be moved into their own task later if it become a problem

v2.1.5

  • Update docs with unwatch, better explanation of Mixed types.
  • Update browser build so that lodash is not included. Lodash is already listed as a bower dependency, and this drops the size of the deliverable significantly. NOTE In most cases this should be a non-breaking change. However, if you are using scheming without using bower-main-files or similar, you may need to add a <script> tag to your html to include lodash on your page.

v2.1.4

  • Set bower lodash dependency to 2.x || 3.x to reduce bower version conflicts on the client. Because they suck.

v2.1.3

  • Add defensive semicolons to the built browser file to avoid conflicts in concatenation.

v2.1.1

  • Fixed an issue where queueCallback was being called more than once within a resolve cycle if any changes were propagated.

v2.1.0

  • Fixed an issue with array mutators where assigning an array to a skeeping model and then mutating that array would cause unexpected behavior
  • Significantly update code structure, breaking source up into multiple files

v2.0.0

  • Reverse order of setter and parser functions. It makes more sense for parsers to be invoked AFTER setter function to guarantee typing, and to allow for custom parsing in setters.

v1.4.0

  • TravisCI!
  • Adds registerQueueCallback, registerResolveCallback, unregisterQueueCallback, and unregisterResolveCallback functions, exposing hooks into Scheming's change management events.
  • Fixed issues with watching of arrays and propagating of changes in arrays of nested schemas, dodging the need to ever cloneDeep. Performance improvement!

v1.3.0

  • Flag construction of schema instances as initialization, so that initializing a scheming model does not count as changes that will propagate on internal watches.
  • Fix a bug where multiple changes to an array of nested schemas was not correctly capturing the change state.
  • Polyfill array mutating functions that forces a clone and re-assignment so that otherwise mutating changes are captured.

v1.2.0

  • Added setThrottle method to allow for different throttling strategies for change resolution on scheming models.

v1.1.0

  • Update addWatcher so that internal watchers do not queue an immediate fire for performance improvements
  • Setters and getters are now invoked with the this context of the calling instance
  • Add flush to the public API, add deprecated warning for _flush

v1.0.0

  • Initial release

Dependents (1)

Package Sidebar

Install

npm i scheming

Weekly Downloads

28

Version

2.1.7

License

none

Last publish

Collaborators

  • autoric
  • nicholasboll