hodur-engine
hodur-engine copied to clipboard
Hodur is a domain modeling approach and collection of libraries to Clojure. By using Hodur you can define your domain model as data, parse and validate it, and then either consume your model via an A...
Hodur Engine
Hodur is a descriptive domain modeling approach and related collection of libraries for Clojure.
By using Hodur you can define your domain model as data, parse and validate it, and then either consume your model via an API making your apps respond to the defined model or use one of the many plugins to help you achieve mechanical, repetitive results faster and in a purely functional manner.
This repo is the Hodur core engine that parses your model definitions and exposes a meta-API around it. For a list of what you can do once your model is in Hodur check here.
Motivation
For a deeper insight into the motivations behind Hodur, check the motivation doc.
Getting Started
Hodur has a highly modular architecture. Hodur Engine (this project) is always required as it provides the meta-database functions and APIs consumed by plugins.
Add hodur-engine
as a dependency in your deps.edn
file:
{:deps {hodur/engine {:mvn/version "0.1.9"}}}
Either require
Hodur as part of your ns
definition or directly:
(require '[hodur-engine.core :as hodur])
In order to initialize an atom
representing the meta-database of
your model call function hodur/init-schema
:
(def meta-db (hodur/init-schema
'[Person
[^String first-name
^String last-name]]))
In the above example, we are defining a Person
entity with a
first-name
and a last-name
both tagged as the scalar type
String
.
Alternatively, Hodur can be initialized by raw EDN paths or from your
classpath using a File
(i.e. clojure.java.io/resource
):
(def meta-db (-> "schemas/person.edn"
io/resource
hodur/init-path))
Hodur's usefulness can be seen when used in conjunction with several
plugins that take care of the mechanical aspects of your
application. For the sake of getting started, we are also adding
hodur-datomic-schema
, a plugin that creates Datomic Schemas out of
your model to the deps.edn
file:
{:deps {hodur/engine {:mvn/version "0.1.5"}
hodur/datomic-schema {:mvn/version "0.1.0"}}}
You should require
it any way you see fit:
(require '[hodur-datomic-schema.core :as hodur-datomic])
Let's expand our Person
model above by "tagging" the Person
entity
for Datomic. You can read more about the concept of tagging for
plugins in the sessions below but, in short, this is the way we, model
designers, use to specify which entities we want to be exposed to
which plugins.
(def meta-db (hodur/init-schema
'[^{:datomic/tag-recursive true}
Person
[^String first-name
^String last-name]]))
The hodur-datomic-schema
plugin exposes a function called schema
that generates your model as a Datomic schema payload:
(def datomic-schema (hodur-datomic/schema meta-db))
When you inspect datomic-schema
, this is what you have:
[{:db/ident :person/first-name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :person/last-name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}]
Assuming the Datomic client API is bound to datomic
, and your
connection to the Database cluster is bound to db-conn
, you can
simply transact your schema like this:
(datomic/transact db-conn {:tx-data datomic-schema})
Several other plugins are available and you can also write your own. The following sections detail not only how to model your domain but also these options in further detail.
Hodur Plugins
For visualization/documentation:
- hodur-graphviz-schema: generates beautiful GraphViz diagrams of your domain
- hodur-visualizer-schema: generates a dynamically, hot-reloaded version of your domain on a web browser
Schemas for persistent systems:
- hodur-datomic-schema: generates Datomic Cloud compatible schemas
- hodur-contentful-schema: generates Contentful compatible schemas
Schemas for inbound interfaces:
- hodur-lacinia-schema: generates Lacinia (GraphQL) schemas
Schemas for validation/data-generation:
- hodur-spec-schema: generates Clojure Spec schemas
Experimental adapters:
- hodur-lacinia-datomic-adapter: experimental utilities for bridging GraphQL queries and mutations into Datomic
Model Definition
Entities, fields and parameters
In Hodur Entities are the highest level representation of a model. An entity has any number of fields that qualify such entity.
For instance, an employee
entity may have an employee-number
, a
name
and a salary
as three distinct fields. An entity
can have
as many fields as you need.
Fields can have any number of parameters. Parameters qualify the
field. For instance, a hypothetical height
field could have a
parameter specifying which unit
to use when interpreting this
field (CENTIMETERS
or FEET
for instance).
Basic structure
Hodur can be initialized by either a series of EDN files (using
function init-path
) or vectors (using function init-schema
).
A domain model is a vector of tuples of symbols and sub-vectors. The symbols represent entity names and the sub-vectors represent fields.
An Employee
entity with name
and salary
as fields could be
defined as:
[Employee
[name
salary]]
With this setup we are not specifying what name
and salary
are. It
might be a good idea to do something like this:
[Employee
[^String name
^Float salary]]
Types are defined using a meta payload to the symbol that represents the field or the parameter. You can read more about scalar types below.
Types can also be represented by the more explicit meta object:
[Employee
[^{:type String} name
^{:type Float} salary]]
Entities are also considered types therefore, if an Employee
has a
supervisor
who's also an Employee
you might write:
[Employee
[^String name
^Float salary
^Employee supervisor]]
You could want a height
field that can return the employee's height
in a particular unit:
[Employee
[^String name
^Float salary
^Employee supervisor
^Integer height [^Unit unit]]
^{:enum true}
Unit
[CENTIMETERS FEET]]
There's quite a bit going on here that you can explore in detail in
the sections below. But here's a summary. First we've added the field
height
to the Employee
entity. It returns an Integer
and it also
expects a parameter called unit
of the type Unit
.
We've defined Unit
separately as an enum (you can see more details
in the sections below). Unit
can be either CENTIMETER
or FEET
.
Scalar types
Hodur has five primitive scalar types that can be composed with your
own entities to design your model. Four of them are quite
self-explanatory: String
, Float
, Integer
and Boolean
.
The last two are highly opinionated and are DateTime
and ID
.
Hodur's plugins must have reasonable defaults to represent each one of these scalar types. Plugins may also expose finer grained controls to manage type precision (for instance 32bit integers vs 64bit integers).
Cardinalities
One employee may have a series of reportees. This kind of cardinality
is defined with the :cardinality
meta marker:
[Employee
[^{:type String} name
^{:type Float} salary
^{:type Employee
:cardinality [0 n]} reportees]]
In this example we are telling Hodur that reportees
can be anywhere
from 0
employees to n
employees.
You can be as specific as you want. A cardinality of [4]
means
exactly 4
entries; [3 5]
means 3
to 5
. If :cardinality
is
unspecified, it's assumed as [1]
.
Optional fields and parameters
Fields and parameters are required by default. In other words, plugins
must implement mechanisms to avoid null
problems if a field or
parameter is mandatory.
If you want to make a field optional, use the :optional
meta marker
on the field:
[Employee
[^{:type String} first-name
^{:type String
:optional true} middle-name
^{:type String} last-name]]
If you want to make a parameter optional, use the :optional
meta
marker on the parameter:
[QueryRoot
[employees [^{:type String
:optional true} search-term]]]
A common pattern is to make a parameter optional while also assigning
a default value to it with :default
:
[QueryRoot
[employees-by-location [^{:type String
:optional true
:default "HQ"} location]]]
Special entity markers
Interfaces and Implementations
Entities can be marked as :interface
which can be used by plugins
that explore such a concept. Entities that implement an interface use
the :implements
marker to indicate which interface(s) they
implement:
[^{:interface true}
Pet
[^String name]
^{:implements Pet}
Dog
[^String bark]
^{:implements Pet}
Cat
[^String mewow]]
The :implements
marker also accepts a vector with a series of
interfaces that the entity implements.
Enums
Enums are special kind of entities. They can assume one of the values defined as fields. Enum fields do not support parameters.
Enums are marked with :enum
:
[Employee
[^String name
^Float salary
^Employee supervisor
^Integer height [^Unit unit]]
^{:enum true}
Unit
[CENTIMETERS FEET]]
Unions
Unions are very similar to interfaces, but they don't get to specify any common fields between the types. They are useful when a certain field or parameter can be any one of the specified entities within the union.
In the following example the search
field of the QueryRoot
entity
returns a collection of SearchItem
which are unions of Employee
and Company
:
[Employee
[^String name
^Float salary]
Company
[^String address]
^{:union true}
SearchItem
[Employee Company]
QueryRoot
[^{:type SearchItem
:cardinality [0 n]}
search [^String term]]]
Documentation and deprecation
Entities, fields, and parameters can all be documented by using marker
:doc
.
[^{:doc "A representation of an Employee"}
Employee
[^{:type String
:doc "The employee's name"} name
^{:type Float
:doc "The employee's salary"} salary]]
Entities, fields, and parameters can additionally be marked for
deprecation by using the marker :deprecation
. Deprecation is a
string that describes the reasons for the deprecation as well as
points to alternatives.
[^{:doc "A representation of an Employee"}
Employee
[^{:type String
:doc "The employee's name"}
name
^{:type Float
:doc "The employee's salary"}
salary
^{:type Float
:deprecation "This field will be fully removed by December. Please use `name` instead."}
first-name]]
Tagging
In general, plugins should only process entities, fields, and
parameters that have been tagged for them. I.e. a datomic
plugin
will have a particular tagging marker such as :datomic/tag
that
needs to be added to each symbol you want the plugin to process.
The following example tags Employee
and its fields first-name
and
last-name
for the datomic
plugin.
[^{:datomic/tag true}
Employee
[^{:type String
:datomic/tag true} first-name
^{:type String
:datomic/tag} last-name]
Project
[^{:type String} name]]
Recursive tagging
Tagging can be very repetitive so Hodur provides features for tagging in a recursive fashion. The example above could be rewritten with:
[^{:datomic/tag-recursive true}
Employee
[^{:type String} first-name
^{:type String} last-name]
Project
[^{:type String} name]]
This kind of scenario is ideal for entities that have several fields and/or parameters.
The marker :<plugin>/tag-recursive
can also have filters such as
:only
and :except
.
The following example will only tag the Employee
entity and the
fields first-name
and last-name
:
[^{:datomic/tag-recursive {:only [Employee first-name last-name]}}
Employee
[^{:type String} first-name
^{:type String} middle-name
^{:type String} last-name]]
The following example would achieve the same result as above but by
tagging everything but middle-name
:
[^{:datomic/tag-recursive {:except [middle-name]}}
Employee
[^{:type String} first-name
^{:type String} middle-name
^{:type String} last-name]]
Default tagging
Some times you just want to tag everything you are sending as part of
a group of entities. In these scenarios you need to first name the
very first symbol of your group default
and then mark it. Hodur will
apply whatever you mark on default
to all items in the group.
In the following example, Hodur will tag everything for the datomic
plugin:
[^{:datomic/tag true}
default
Employee
[^{:type String} first-name
^{:type String} last-name]
Project
[^{:type String} name]]
The special default
symbol can also be used to carry other markers
down into the group's items but the general usage is for tagging.
Naming conventions
Hodur does not care about naming conventions. However, it does delegate naming choices fully to plugins. The way Hodur achieves this is by internally converting whatever naming convention was used in the symbols into several options. This is done by leveraging [[https://github.com/qerub/camel-snake-kebab][camel-snake-kebab]].
Meta API
Once your model gets parsed, Hodur will retain an in-memory meta-database that can be queried by either plugins or your implementation proper.
The API is exposed as a DataScript API atom and DataScript proper is a dependency of Hodur. Therefore, you can require DataScript and use its query directly.
The example below uses both pull
and a Datalog query to return all
the items which are marked with a :datomic/tag
.
(require '[datascript.core :as d])
(d/q '[:find [(pull ?e [*]) ...]
:where
[?e :datomic/tag true]]
@c)
Attributes are named with qualified keywords in four different categories:
-
:type/...
: all entities (AKA types) -
:field/...
: all fields -
:param/...
: all parameters -
<plugin>/...
: plugin names should qualify keywords (see:datomic/tag
above)
Naming
For entities, fields, and parameters the provided name in the model is
exposed as either :type/name
, :field/name
, and
:param/name
. Additionally, Hodur generates indexes with:
-
/kebab-case-name
-
/PascalCaseName
-
/camelCaseName
-
/snake_case_name
Entity Markers API
Entities have Boolean attributes for interfaces, enums and unions:
:type/interface
, :type/enum
, and :type/union
respectively.
Field Markers API
TBD: :field/type
and :field/parent
(:field/_parent
) :field/cardinality
Param Markers API
TBD: :param/type
and :param/parent
(:param/_parent
) :param/cardinality
Authoring Plugins
TBD: choose naming convention, use d/q, filter by
Bugs
If you find a bug, submit a GitHub issue
Help!
This project is looking for team members who can help this project succeed! If you are interested in becoming a team member please open an issue.
License
Copyright © 2018 Tiago Luchini
Distributed under the MIT License (see LICENSE).