reifyhealth/specmonstah

Generate and process graphs of dependencies


Keywords
clojure, clojure-spec, database
License
MIT

Documentation

Specmonstah

Deps

[reifyhealth/specmonstah "2.0.0-alpha-1"]

Introduction

Specmonstah (Boston for "Specmonster") lets you generate and manipulate deeply-nested, hierarchical graphs of business data (what you typically store in a relational database) using a concise DSL. It's great for dramatically reducing the amount of boilerplate code you have to write for tests. It's similar in purpose to Ruby's factory_bot.

Specmonstah purpose

For example, say you need to test a function that inserts a Todo in a database: your foreign key constraints would require you to first insert the TodoList that the Todo belongs to, and the User that the TodoList and Todo belong to. Instead of having to write something like this:

(let [user      (create-user! {:username "bob" :email "bob@bob-town.com"})
      todo-list (create-todo-list! {:title         "Bob convention todos"
                                    :created-by-id (:id user)})]
  (create-todo! {:description "Book flight"
                 :todo-list-id (:id todo-list)
                 :created-by-id (:id user)}))

Specmonstah lets you write something like this:

(create! {:todo [[1 {:spec-gen {:description "Book flight"}}]]})

Specmonstah (SM) creates the User and TodoList, and ensures that the TodoList correctly references the User and the Todo correctly references the TodoList and user. Call me crazy, but I think the second snippet is preferable to the first.

I know this README is hella long but read this part at least

This README is long af. Total bummer, I know. I'm sorry. If all this text makes you want to run screaming for the hills, then all I ask is that you first check out the Short Sweet Example. It's some code that you can play with in a REPL that demonstrates some of the power and fun of using Specmonstah.

If that hooks you, then the rest of the README will walk you through Specmonstah's features and usage. SM is a specialized tool that introduces new concepts and vocabulary. It will take an hour or two to get comfortable with it, but once you do, that investment will pay huge dividends over time as you use it to write code that is more clear, concise, and easy to maintain. This guide has five parts to get you there:

  • The aforementioned Short Sweet Working Example
  • The Infomercial is a quick tour of the cool, surprising stuff you can do with Specmonstah, showing you why it's worth the investment
  • A detailed Tutorial walks you through the library. It's written so the first sections get you productive with the core concepts quickly, and later sections fill in more esoteric details.
  • The Usage Reference contains at-a-glance code snippets demonstrating the facets of Specmonstah usage
  • Because Specmonstah introduces new terms, we provide a Glossary

Short Sweet Example

To get started with the example, clone this repo:

git clone https://github.com/reifyhealth/specmonstah.git

Open examples/short-sweet/src/short_sweet.clj in your favorite editor and start a REPL. I've also included the code below in case for example you don't have access to a REPL because, say, you're in some kind of Taken situation and you only have access to a phone and you're using your precious battery life to go through this README.

The first ~70 lines of code include all the setup necessary for the examples to run, followed by snippets to try out with example output. Def play with the snippets :) Can you generate multiple todos or todo lists?

(ns short-sweet
  (:require [reifyhealth.specmonstah.core :as sm]
            [reifyhealth.specmonstah.spec-gen :as sg]
            [clojure.spec.alpha :as s]
            [clojure.spec.gen.alpha :as gen]
            [loom.io :as lio]))

;;-------
;; Begin example setup
;;-------

;; ---
;; Define specs for our domain entities

;; The ::id should be a positive int, and to generate it we increment
;; the number stored in `id-seq`. This ensures unique ids and produces
;; values that are easier for humans to understand
(def id-seq (atom 0))
(s/def ::id (s/with-gen pos-int? #(gen/fmap (fn [_] (swap! id-seq inc)) (gen/return nil))))

(s/def ::username string?)

(s/def ::user (s/keys :req-un [::id ::username]))

(s/def ::title string?)
(s/def ::todo-list-id ::id)
(s/def ::assigned-id ::id)
(s/def ::todo (s/keys :req-un [::id ::title ::todo-list-id ::assigned-id]))

(s/def ::owner-id ::id)
(s/def ::todo-list (s/keys :req-un [::id ::owner-id ::title]))


;; ---
;; The schema defines specmonstah `ent-types`, which roughly
;; correspond to db tables. It also defines the `:spec` for generting
;; ents of that type, and defines ent `relations`
(def schema
  {:user      {:spec   ::user
               :prefix :u}
   :todo-list {:spec      ::todo-list
               :relations {:owner-id [:user :id]}
               :prefix    :tl}
   :todo      {:spec      ::todo
               
               :relations {:assigned-id  [:user :id]
                           :todo-list-id [:todo-list :id]}
               :spec-gen  {:title "default todo title"}
               :prefix    :t}})

;; A vector of inserted records we can use to show that entities are
;; inserted in the correct order
(def example-db (atom []))

(defn insert*
  "Simulates inserting records in a db by conjing values onto an atom"
  [{:keys [data] :as db} {:keys [ent-type spec-gen]}]
  (swap! example-db conj [ent-type spec-gen]))

(defn insert [query]
  (reset! id-seq 0)
  (reset! example-db [])
  (-> (sg/ent-db-spec-gen {:schema schema} query)
      (sm/visit-ents-once :inserted-data insert*))
  ;; normally you'd return the expression above, but return nil for
  ;; the example to not produce overwhelming output
  nil)


;;-------
;; Begin snippets to try in REPL
;;-------

;; Return a map of user entities and their spec-generated data
(-> (sg/ent-db-spec-gen {:schema schema} {:user [[3]]})
    (sm/attr-map :spec-gen))
;; =>
{:u0 {:id 8, :username "9wkka4XFJC0"},
 :u1 {:id 7, :username "3cy8TV0L3yWm5GWQrjT6a"},
 :u2 {:id 9, :username "59RDV1qIm6y3ya189Ut2fkFu48"}}

;; You can specify a username and id
(-> (sg/ent-db-spec-gen {:schema schema} {:user [[1 {:spec-gen {:username "Meeghan"
                                                                :id       100}}]]})
    (sm/attr-map :spec-gen))
;; =>
{:u0 {:id 100, :username "Meeghan"}}

;; Generating a todo-list generates the user the todo-list belongs,
;; with foreign keys correct
(-> (sg/ent-db-spec-gen {:schema schema} {:todo-list [[1]]})
    (sm/attr-map :spec-gen))
;; =>
{:tl0 {:id 15, :owner-id 14, :title "Ew"},
 :u0  {:id 14, :username "sxk0t6r7tU4j3p924"}}
    
;; Generating a todo also generates a todo list and user
(-> (sg/ent-db-spec-gen {:schema schema} {:todo [[3]]})
    (sm/attr-map :spec-gen))
{:t0  {:id           32,
       :title        "default todo title",
       :todo-list-id 30,
       :assigned-id  29},
 :tl0 {:id 30, :owner-id 29, :title "PeLGbe7geh7NZUoZ045r0F6uRXDSY"},
 :u0  {:id 29, :username "0we6EsNgFZN5U9009P2Av3E5Y0d64X"}}

;; The `insert` function shows that records are inserted into the
;; simulate "database" (`example-db`) in correct dependency order:
(insert {:todo [[1]]})
@example-db
[[:user {:id 1, :username "k8NmZ27QV5p08db624Y2Vk0H"}]
 [:todo-list {:id 2, :owner-id 1, :title "zhMNFuoQ2lhalrp00mv2I38"}]
 [:todo {:id           4,
         :title        "default todo title",
         :todo-list-id 2,
         :assigned-id  1}]]

Infomercial

In the time-honored tradition of infomercials everywhere, these snippets gloss over a lot of details to reveal the truest, purest essence of a product. If you want to go all FDA on me and validate the claims, check out the full source.

The code below will shout at show you how you can generate and insert data for a forum's database. Here's an entity relationship diagram for the database:

Forum ERD

One thing the diagram doesn't capture is that, for the like type, there's a uniqueness constraint on post-id and created-by-id. Also, every instance of created-by-id and updated-by-id refers to a user, but including that in the diagram would just clutter it.

Insert entity hierarchy in dependency order, with correct foreign keys

Posts have a foreign key referencing a Topic, Topics reference a Topic Category, and all of these reference a User. The snippet below shows that you can specify that you want one Post created, and Specmonstah will ensure that the other entities are created and inserted in dependency order:

(insert {:post [[1]]})
@mock-db
; =>
[[:user {:id 1 :username "K7X5r6UVs9Mm2Eks"}]
 [:topic-category {:id 2 :created-by-id 1 :updated-by-id 1}]
 [:topic {:id 5
          :topic-category-id 2
          :title "ejJ2B88UZo2NK2sMuU4"
          :created-by-id 1
          :updated-by-id 1}]
 [:post {:id 9 :topic-id 5 :created-by-id 1 :updated-by-id 1}]]

The insert function is an example of code you might write to manage the relationship between specmonstah-generated data and your own database. In this case, insert simulates inserting records in a db by conjing entities on an mock-db atom. The maps were generated using clojure.spec. Notice that all the foreign keys line up.

Specify different users

In the previous example, all entities referenced the same User. In this one, the Topic's created-by-id will reference a new user:

(insert {:topic [[:t0 {:refs {:created-by-id :custom-user}}]]
         :post [[1]]})
@mock-db
; =>
[[:user {:id 1 :username "gMKGTwBnOvB0xt"}]
 [:topic-category {:id 2 :created-by-id 1 :updated-by-id 1}]
 [:user {:id 5 :username "2jK0TXCU2UcBM89"}]
 [:topic {:id 6
          :topic-category-id 2
          :title "cmo2Vg8DQByz302c"
          :created-by-id 5
          :updated-by-id 1}]
 [:post {:id 10 :topic-id 6 :created-by-id 1 :updated-by-id 1}]]

Two users, one with :id 1 and another with :id 5. The topic's :created-by-id attribute is 5, and all other User references are 1.

Multiple entities

What if you want to insert 2 or 3 or more posts?

(insert {:post [[3]]})
@mock-db
; =>
[[:user {:id 1 :username "yB96fd"}]
 [:topic-category {:id 2 :created-by-id 1 :updated-by-id 1}]
 [:topic {:id 5
          :topic-category-id 2
          :title "KEh29Ru7aVVg2"
          :created-by-id 1
          :updated-by-id 1}]
 [:post {:id 9 :topic-id 5 :created-by-id 1 :updated-by-id 1}]
 [:post {:id 13 :topic-id 5 :created-by-id 1 :updated-by-id 1}]
 [:post {:id 17 :topic-id 5 :created-by-id 1 :updated-by-id 1}]]

Just say "I want 3 posts" and Specmonstah delivers.

Uniqueness constraints

You can't have two Likes that reference the same Post and User; in other words, a User can't Like the same Post twice. Specmonstah will automatically generate unique Users if you specify multiple Likes:

(insert {:like [[3]]})
@mock-db
; =>
[[:user {:id 1 :username "T2TD3pAB79X5"}]
 [:user {:id 2 :username "ziJ9GnvNMOHcaUz"}]
 [:topic-category {:id 3 :created-by-id 2 :updated-by-id 2}]
 [:topic {:id 6
          :topic-category-id 3
          :title "4juV71q9Ih9eE1"
          :created-by-id 2
          :updated-by-id 2}]
 [:post {:id 10 :topic-id 6 :created-by-id 2 :updated-by-id 2}]
 [:like {:id 14 :post-id 10 :created-by-id 1}]
 [:like {:id 17 :post-id 10 :created-by-id 2}]
 [:user {:id 20 :username "b73Ts5BoO"}]
 [:like {:id 21 :post-id 10 :created-by-id 20}]]

Three Likes, Three different Users, and we're not violating the uniqueness constraint. With just one line of code. I think this feature is particularly cool.

Polymorphic relations

Whereas foreign keys in RDBMSs must reference records in a specific table, some databases like Datomic have reference types attributes that can reference any entity at all. You might want to use this in your forum so that users can like either Topics or Posts. Specmonstah handles this use case.

There are two snippets below. In the first, you say you want to create three :polymorphic-likes with {:ref-types {:liked-id :post}}. Specmonstah generates 3 likes that refer to a post. The second snippet includes {:ref-types {:liked-id :topic}}, so the likes refer to a topic. Polymorphic references compose with uniqueness constraints, so three users are created, just like in the previous snippet.

(insert {:polymorphic-like [[3 {:ref-types {:liked-id :post}}]]})
@mock-db
[[:user {:id 1 :username "gI3q3Y6HR1uwc"}]
 [:user {:id 2 :username "klKs7"}]
 [:topic-category {:id 3 :created-by-id 2 :updated-by-id 2}]
 [:topic {:id 6
          :topic-category-id 3
          :title "RF6g"
          :created-by-id 2
          :updated-by-id 2}]
 [:post {:id 10 :topic-id 6 :created-by-id 2 :updated-by-id 2}]
 [:polymorphic-like {:id 14 :liked-id 10 :created-by-id 1}]
 [:polymorphic-like {:id 17 :liked-id 10 :created-by-id 2}]
 [:user {:id 20 :username "Gcf"}]
 [:polymorphic-like {:id 21 :liked-id 10 :created-by-id 20}]]


(insert {:polymorphic-like [[3 {:ref-types {:liked-id :topic}}]]})
@mock-db
[[:user {:id 1 :username "5Z382YCNrJB"}]
 [:topic-category {:id 2 :created-by-id 1 :updated-by-id 1}]
 [:topic {:id 5
          :topic-category-id 2
          :title "i3"
          :created-by-id 1
          :updated-by-id 1}]
 [:user {:id 9 :username "dJtC"}]
 [:polymorphic-like {:id 10 :liked-id 5 :created-by-id 9}]
 [:polymorphic-like {:id 13 :liked-id 5 :created-by-id 1}]
 [:user {:id 16 :username "8ZS"}]
 [:polymorphic-like {:id 17 :liked-id 5 :created-by-id 16}]]

Visualization

Sometimes you want to inspect all the work that Specmonstah is doing for you. One way to do that is to produce an image of the entities Specmonstah produces, and their relationships:

(lio/view (:data (sm/add-ents {:schema schema} {:like [[2]]})))

like graph

This shows that that two Likes were generated (l0 and l1). The Likes are applied to a Post (p0), and so forth.

And that brings the infomercial to a close. If you're ready to learn how you, too, can accomplish these amazing feats, read on!

Tutorial

Specmonstah was born out of a need to replace brittle, repetitive code for creating deeply-nested hierarchies of data in unit tests. This tutorial will show you how to use Specmonstah specifically for this use case. Along the way you'll learn how to make the most of Specmonstah by understanding how it's not implemented to support writing unit tests per se, but to support the more fundamental operations of generating and manipulating entity graphs.

In trying to figure out how to explain this I had the thought, "Specmonstah is all about data and the stuff you can do with that data. It felt super profound, like I had gotten a direct glimpse of the underlying structure of the cosmos. Real "shower thoughts" moment.

We'll start with a high-level overview of how Specmonstah's data and operations support the overall goal of aiding testing by generating and inserting records in a database in dependency order.

Data & Operations

Specmonstah's data and operations can be summarized as:

  • Data
    • ent db
      • schema
      • ent graph
      • ent attrs
  • Operations
    • add ents
    • add ent attrs (ent visitation)
    • create views of the ent graph

To explain each of these bullets, we'll work through the following questions:

  • How does Specmonstah generate data for database insertion?
  • How does Specmonstah insert records in the correct order?

Data generation happens in two phases:

  1. You add ents to an ent db's ent graph
  2. You add the data generated by clojure.spec to each ent as an ent attr

Let's unpack this.

Specmonstah works by generating an ent graph. Say you want to generate and insert three todos that references a todo-list, where the todos and the todo-list all reference a user. Specmonstah accomplishes this by first creating a graph like this:

Simple todo example

In the graph above, we call the :todo, :todo-list, and :user nodes ent types and the rest ents. We use these names in Specmonstah to indicate that these graph nodes take on a particular meaning in Specmonstah:

Ent type. An ent type is analogous to a relation in a relational database, or a class in object-oriented programming. It differs in that relations and classes define all the attributes of their instances, whereas ent types don't. Ent types define how instances are related to each other. For example, a Todo schema might include a :description attribute, but the :todo ent type doesn't. The :todo ent type does specify that a :todo instances reference :todo-list instances.

Ent types are represented as nodes in the ent graph (let's abbreviate that with EG), with directed edges going from ent types to their instances. It's rare that you'll interact with ent types directly.

Ent. An ent is an instance of an ent type. Ents have names (:t0, :u0, etc), and reference other ents. They're represented as nodes in the EG, with directed edges going from ents to the ents they reference; there's a directed edge from :tl0 to :u0 because :tl0 references :u0. The graph's topology is used to ensure that :u0 gets inserted before :tl0.

In creating the above graph, we would say that we add ents to an ent db. An ent db is a map that contains an ent graph.

The ent db also contains a schema. The schema describes how ents of different types refer to each other, and it's used to construct the directed edges between ents.

So that's the first step of data generation: You add ents to an ent db's ent graph. After that, you add the data generated by clojure.spec to each ent as an ent attr.

For example, the ents :u0 and :tl0 are not maps. They're just a graph node, and as such they cannot be inserted in a database. Specmonstah uses clojure.spec to generate data for :u0 and :tl0 and then associates the data with :u0 and :tl0 as an ent attr. You can think of this as being represented using a map like this, with :spec-gen as the ent attr:

{:u0  {:spec-gen {:username "billy"
                  :id       1}}
 :tl0 {:spec-gen {:id       2
                  :owner-id 1
                  :title    "my todaloo list"}}}

The process of adding ent attrs is called visitation. Visiting ent nodes is kind of like mapping: when you call map on a seq, you apply a mapping function to each element, creating a new seq from the mapping function's return values. By the same token, when you visit ents you apply a visiting function to each ent. The visiting function's return value is stored as an attribute on the ent - in this case, :spec-gen.

Visitation happens in reverse topologically sorted order, meaning that since :tl0 has directed edge pointing to :u0, the visiting function is applied to :u0 before :tl0. This is how the spec data generating visiting function is able to correctly set :owner-id to 1:

  1. The visiting function is applied to :u0, generating the :id 1
  2. The visiting function is applied to :tl0. It's able to use the edge from :tl0 to :u0 to look up :u0's :id and setting that as the value for :owner-id.

This visitation process is used to insert records in a database. The insertions happen in the correct order, satisfying foreign key constraints.

One more note: When you play with Specmonstah in a REPL, you'll notice that it generates a lot of data. Specmonstah provides a bunch of functions that project different views of the ent db so that you can focus just on whatever's relevant for you.

That covers the main data structures and operations:

  • Data
    • ent db
      • schema
      • ent graph
      • ent attrs
  • Operations
    • add ents
    • add ent attrs (ent visitation)
    • create views of the ent graph

Now that you have the broad picture of how Specmonstah works, let's start exploring the details with source code. The rest of the tutorial consists of sections with corresponding clojure files under the directory, each introducing new concepts. You'll have the best experience if you follow along in a REPL.

01: ent db

In this section you're going to learn about the ent db. Open reifyhealth.specmonstah-tutorial.01:

(ns reifyhealth.specmonstah-tutorial.01
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]))

(def schema
  {:user {:prefix :u}})

(defn ex-01
  []
  (sm/add-ents {:schema schema} {:user [[3]]}))

Throughout the tutorial, I'll use functions named ex-01, ex-02, etc, to illustrate some concept. When you call (ex-01), it returns:

(ex-01) ;=>
{:schema {:user {:prefix :u}}
 :data {:nodeset #{:u1 :u0 :u2 :user}
        :adj {:user #{:u1 :u0 :u2}}
        :in {:u0 #{:user} :u1 #{:user} :u2 #{:user}}
        :attrs {:user {:type :ent-type}
                :u0 {:type :ent, :index 0, :ent-type :user, :query-term [3]}
                :u1 {:type :ent, :index 1, :ent-type :user, :query-term [3]}
                :u2 {:type :ent, :index 2, :ent-type :user, :query-term [3]}}}
 :queries ({:user [[3]]})
 :relation-graph {:nodeset #{:user} :adj {} :in {}}
 :types #{:user}
 :ref-ents []}

ex-01 invokes the function call (sm/add-ents {:schema schema} {:user [[3]]}). You'll always call sm/add-ents first whenever you use Specmonstah. It takes two arguments, an ent db and a query, and returns an ent db.

ent db's are at the core of Specmonstah; most functions take an ent db as their first argument and return an ent db. The ent db is conceptually similar to the databases you're familiar with. Its :schema key refers to an entity schema, just as an RDBMS includes schema information. In this case, the schema is {:user {:prefix :u}}, which is as simple a schema as possible. In later sections, you'll learn more about schemas and how they're used to define relationships and constraints among ents.

The ent db's :data key refers to a graph representing ents, their relationships, and their ent attributes (as opposed to business attributes). In this ent db there are three users, :u0, :u1, and :u2. There aren't any ent relationships because our schema didn't specify any, but each ent does have attributes: :type, :index, :ent-type, and :query-term. I know I've said it multiple times already, but these are ent attributes, which are distinct from business attributes. The latter are the attributes related to whatever domain you're modeling; for users, these might include name, username, email address, and so forth. As you go through the tutorial, you'll see how a lot of Specmonstah functions involve reading and updating ents' attributes.

The graph also includes nodes for ent types; you can see :user, and ent-type, under the :nodeset key of the graph. This is used internally.

It happens that the graph is produced by loom, a sweet little library for working with graphs. Specmonstah doesn't try to hide this implementation detail from you: it's entirely possible you'll want to use one of loom's many useful graph functions to interact with the ent db's data. You might, for instance, want to render the graph as an image. Try this in your REPL:

(lio/view (:data (ex-01)))

At the same time, viewing the full loom graph is often overwhelming and unnecessary. The following functions help you project a more useful view of the ent db:

(-> (ex-01)
    (sm/ents-by-type))

(-> (ex-01)
    (sm/ent-relations :u0))

(-> (ex-01)
    (sm/all-ent-relations))

I encourage you to try these functions out in the REPL. Some of them reveal information about ent relationships and so will only be useful when we're working with more than one ent type; try those functions out in the next section as well as you can get a better idea of what they do.

Back to the ent db: The rest of the keys (:queries, :relation-graph, :types, :ref-ents) are used internally to generate the ent db, and can safely be ignored.

Adding ents to an ent db is the first step whenever you're using Specmonstah. The two main ingredients for building an ent db are the query and schema. In the next section, we'll explain how schemas work, and section 3 will explain queries.

02: Schemas

Here's the source for this chapter:

(ns reifyhealth.specmonstah-tutorial.02
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]))

(def schema
  {:user      {:prefix :u}
   :todo-list {:prefix    :tl
               :relations {:owner-id [:user :id]}}})


(defn ex-01
  []
  (sm/add-ents {:schema schema} {:todo-list [[2]]}))

The ent db's schema defines ent types. It's implemented as a map where keys are the ent types' names and values are their definitions. In the code above, schema defines two ent types, :user and :todo-list. The ent type definitions include two keys, :prefix and :relations.

:prefix is used by add-ents to name the ents it creates. For example, in ex-01, we produce an ent db that has two todo-lists and a user:

(defn ex-01
  []
  (sm/add-ents {:schema schema} {:todo-list [[2]]}))

(lio/view (:data (ex-01)))

(-> (ex-01) (sm/ents-by-type))

(-> (ex-01) (sm/ent-relations :u0))

(-> (ex-01) (sm/all-ent-relations))

prefixes

The todo-lists are named :tl0 and :tl1, and the user is :u0. There's a pattern here: every generated ent is named :{schema-prefix}{index}.

The schema's :relations key is used to specify how ents are related to each other. The :todo-list definition includes :relations {:owner-id [:user :id]}, specifying that a :todo-list should reference a :user. The relation also specifies that the :todo-list's :owner-id should be set to the :user's :id, information that will be used when we use spec-gen to generate records for these ents.

It's because of this relation that the query {:todo-list [[2]]} results in the :user :u0 being created even though the query doesn't explicitly mention :user, and that the :todo-lists :tl0 and :tl1 reference :u0.

03: Queries

Section 3's source file begins:

(ns reifyhealth.specmonstah-tutorial.03
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]))

(def schema
  {:user      {:prefix :u}
   :todo-list {:prefix    :tl
               :relations {:owner-id [:user :id]}}})
(defn ex-01
  []
  (sm/add-ents {:schema schema} {:todo-list [[2]]}))

Queries are used to specify what ents should get generated. The term query might throw you off because usually it's used to refer to the language for retrieving records from a database. In Specmonstah, I think of queries as allowing you to express, generate the minimal ent-db necessary for me to retrieve the ents I've specified.

In ex-01, the query passed to sm/add-ents is {:todo-list [[2]]}. This is like saying, I want two :todo-lists. Create an ent-db with the minimum ents needed so that I can retrieve them. Because :todo-list ents must refer to a :user, Specmonstah generates the :user ent :u0. Specmonstah only generates one :user, not two, because that's the minimum needed to satisfy the query.

Queries are maps, where each key is the name of an ent type, and each value is a vector of query terms. In the query {:todo-list [[2]]}, :todo-list is an ent type and [2] is a query term.

Each query term is a vector where the first element is either a number or an ent name. When you provide a number, as in [2], you're instructing SM to generate that many ents of the given ent type, and to name them according to its default naming system. As we saw in the last section, SM names ents by appending an index to the ent type's :prefix. The :prefix for :todo-list is :tl, so the :todo-list ents are named :tl0 and :tl1. Figuring out what to name your test data is one of the tedious aspects of testing that SM handles for you.

On the other hand, if you do want to name an ent, you can provide a keyword as the first element in the query term, as in ex-02:

(defn ex-02
  []
  (sm/add-ents {:schema schema} {:todo-list [[:my-todo-list]
                                                 [:my-todoodle-do-list]]}))

(lio/view (:data (ex-02)))

custom names

Here, you're naming your :todo-list ents :my-todo-list and, in a fit of whimsy, :my-todoodle-do-list.

You can add as many query terms to an ent type as you want, mixing numbers and ent names as you please:

(defn ex-03
  []
  (sm/add-ents {:schema schema} {:todo-list [[1]
                                                 [:work]
                                                 [1]
                                                 [:cones-of-dunshire-club]]}))

custom names

Query terms take a second argument, an options map map, which is used to further tune the creation of the ent. The next section will show you how to use the options map to generate unique references.

04: refs

In all the examples so far, all the todo lists have referred to the same user, :u0. What if you want to create two todo lists, but you want them to belong to different users? Here's how you could do that:

(ns reifyhealth.specmonstah-tutorial.04
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]))

(def schema
  {:user      {:prefix :u}
   :todo-list {:prefix    :tl
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix :t
               :relations {:todo-list-id [:todo-list :id]}}})
(defn ex-01
  []
  (sm/add-ents {:schema schema} {:todo-list [[2 {:refs {:owner-id :my-own-sweet-user}}]
                                                 [1]]}))

(lio/view (:data (ex-01)))

custom refs

What's new in this example is the map {:refs {:owner-id :my-own-sweet-user}}. It resulted in two todo lists, :tl0 and :tl1, referring to a :user ent named :my-own-sweet-user instead of :u0. Let's break this down.

In the schema, :todo-list includes this relations definition:

:relations {:owner-id [:user :id]}

This means, :todo-lists refer to a user via the :owner-id attribute. Remember that queries are essentially telling Specmonstah, generate the minimal ent-db necessary for me to retrieve the ents I've specified, so when you call the add-ents function and instruct SM to generate a :todo-list, SM's default behavior is to satisfy this schema definition by creating a :user and naming it according to its default naming system. Internally, the ent db tracks that the :todo-list refers to the :user via the :owner-id attribute.

However, when your query includes the option {:refs {:owner-id :my-own-sweet-user}}, you're saying, I want the user that :owner-id refers to to be named :my-own-sweet-user. One way you might use this would be to write a test ensuring that users can't modify each others' todo lists.

If you look back at the schema for this section, you'll notice it introduced a new ent type, :todo, and :todos reference :todo-lists. What if you wanted to create two todo lists, each with one todo and each belonging to a different user? Here's how you could do that:

(defn ex-02
  []
  (sm/add-ents {:schema schema} {:todo-list [[1]
                                                 [1 {:refs {:owner-id :hamburglar}}]]
                                     :todo      [[1]
                                                 [1 {:refs {:todo-list-id :tl1}}]]}))

(lio/view (:data (ex-02)))

implicit refs

Before reading the explanation of how this works, indulge the educator in me and take a moment to write or say your own explanation. Quizzing yourself like this is an effective way to clarify and retain your understanding. There's all kinds of studies that show it; it's called "the testing effect" and it's one of the best ways to learn.

Let's break this query down term by term:

{:todo-list [[1]
             [1 {:refs {:owner-id :hamburglar}}]]
 :todo      [[1]
             [1 {:refs {:todo-list-id :tl1}}]]}

Under :todo-list, [1] tells Specmonstah to create a :todo-list. It's given the default name :tl0. Since you didn't specify any refs, it refers to the default user, :u0.

The next query term, [1 {:refs {:owner-id :hamburglar}}], instructs SM to create a :todo-list that refers to a user named, of all things, :hamburglar. This :todo-list is given the default name of :tl1.

Under :todo, [1] tells Specmonstah to create a :todo with a default name and default refs. Therefore, :t0 refers to :tl0.

The next term, [1 {:refs {:todo-list-id :tl1}}], tells SM that the next :todo should refer to the :todo-list named :tl1. We specified the :tl1 here because we know that Specmonstah's simple, consistent naming system will produce that name for the second :todo-list specified in the query.

The point of all this is that you can rely on Specmonstah's naming system to reliably and concisely establish the properties and relations of the ent db you want to generate. If you don't want to keep track of Specmonstah's implicit names, you can name things explicitly:

(defn ex-03
  []
  (sm/add-ents {:schema schema} {:todo-list [[:tl0]
                                                 [:tl1 {:refs {:owner-id :hamburglar}}]]
                                     :todo      [[1 {:refs {:todo-list-id :tl0}}]
                                                 [1 {:refs {:todo-list-id :tl1}}]]}))

05: Progressive construction

At the beginning of this tutorial, I told you that add-ents takes a schema as an argument:

(sm/add-ents {:schema schema} {:todo [[1]]})

You may have wondered why the map {:schema schema} is used, rather than just directly passing in schema; why don't we just write this?

(sm/add-ents schema {:todo [[1]]})

The reason is that add-ents actually takes an ent db as its first argument. When you pass in {:schema schema}, you're passing in an ent db with no data. However, you can take the return value of add-ents and pass it in as the first argument to further calls to add-ents:

(ns reifyhealth.specmonstah-tutorial.05
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]))

(def schema
  {:user      {:prefix :u}
   :todo-list {:prefix    :tl
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix :t
               :relations {:todo-list-id [:todo-list :id]}}})

(defn ex-01
  []
  (let [ent-db-1 (sm/add-ents {:schema schema} {:todo-list [[1]]})
        ent-db-2 (sm/add-ents ent-db-1 {:todo-list [[1] [1 {:refs {:owner-id :hamburglar}}]]})]
    (lio/view (:data ent-db-1))
    (lio/view (:data ent-db-2))))

(ex-01)

Additional calls to add-ents are additive; they will never alter existing ents, and will only add new ents. The first call, (sm/add-ents {:schema schema} {:todo-list [[1]]}), produces a :todo-list named :tl0 referencing a :user named :u0:

progressive generation 1

That ent db is passed to the next call, (sm/add-ents ent-db-1 {:todo-list [[1] [1 {:refs {:owner-id :hamburglar}}]]}). This creates two more todo lists:

progressive generation

The default naming system picks up where it left off, giving the todo lists the names :tl1 and :tl2. :tl1 references the existing user, :u0, and :tl2 references a new user from the :refs, :hamburglar. When progressively generating an ent-db, you can expect Specmonstah to behave as if all queries were passed as a single query to a single call of add-ents.


Everything you've learned up to this point has focused on generating an ent db: you've learned a bit about how to use schemas and queries together to concisely specify what ents to create. You've also learned how to customize the relationships with the :refs query option.

In the next couple sections, you'll learn about how Specmonstah uses visitation to generate and insert business data.

06: spec-gen

If you're not familiar with clojure.spec, check out the spec guide on clojure.org. It's very well-written.

Our code:

(ns reifyhealth.specmonstah-tutorial.06
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]
            [clojure.spec.alpha :as s]
            [clojure.spec.gen.alpha :as gen]
            [reifyhealth.specmonstah.spec-gen :as sg]))

(s/def ::id (s/and pos-int? #(< % 100)))
(s/def ::not-empty-string (s/and string? not-empty #(< (count %) 20)))

(s/def ::username ::not-empty-string)
(s/def ::user (s/keys :req-un [::id ::username]))

(s/def ::name ::not-empty-string)
(s/def ::owner-id ::id)
(s/def ::todo-list (s/keys :req-un [::id ::name ::owner-id]))

(s/def ::details ::not-empty-string)
(s/def ::todo-list-id ::id)
(s/def ::todo (s/keys :req-un [::id ::details ::todo-list-id]))

(def schema
  {:user      {:prefix :u
               :spec   ::user}
   :todo-list {:prefix    :tl
               :spec      ::todo-list
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix    :t
               :spec     ::todo
               :relations {:todo-list-id [:todo-list :id]}}})

(defn ex-01
  []
  {:user      (gen/generate (s/gen ::user))
   :todo-list (gen/generate (s/gen ::todo-list))
   :todo      (gen/generate (s/gen ::todo))})

We define some simple specs to generate a little dummy data. Here's what the raw generated data looks like:

(ex-01) ;=>
{:user      {:id 2, :username "qI0iNgiy"}
 :todo-list {:id 4, :name "etIZ3l6jDO7m9UR5P", :owner-id 11}
 :todo      {:id 1, :details "1K85jiEU3L366NTx1", :todo-list-id 2}}

That's useful, but we can't insert that into a test database because the foreign keys wouldn't match the values they reference. The :todo-list's :user-id, for example, is 11, where the :user's :id is 2.

We can use reifyhealth.specmonstah.spec-gen/ent-db-spec-gen to generate data and then assign the foreign keys:

(defn ex-02
  []
  (:data (sg/ent-db-spec-gen {:schema schema} {:todo [[1]]})))

(ex-02)
; =>
{:nodeset #{:todo-list :tl0 :t0 :u0 :todo :user},
 :adj {:todo #{:t0},
       :t0 #{:tl0},
       :todo-list #{:tl0},
       :tl0 #{:u0},
       :user #{:u0}},
 :in {:t0 #{:todo}, :tl0 #{:todo-list :t0}, :u0 #{:tl0 :user}},
 :attrs {:todo {:type :ent-type},
         :t0 {:type :ent,
              :index 0,
              :ent-type :todo,
              :query-term [1],
              :loom.attr/edge-attrs {:tl0 {:relation-attrs #{:todo-list-id}}},
              :spec-gen {:id 1, :details "uhr5LSa", :todo-list-id 8}},
         :todo-list {:type :ent-type},
         :tl0 {:type :ent,
               :index 0,
               :ent-type :todo-list,
               :query-term [:_],
               :loom.attr/edge-attrs {:u0 {:relation-attrs #{:owner-id}}},
               :spec-gen {:id 8, :name "xbamqBULZ", :owner-id 42}},
         :user {:type :ent-type},
         :u0 {:type :ent,
              :index 0,
              :ent-type :user,
              :query-term [:_],
              :spec-gen {:id 42, :username "abrfR4s1I15"}}}}

Oh wow, OK. That's a lot to look at. Let's step through it.

We're looking at the value for the ent db's :data key. This is the loom graph that we've looked at in earlier sections, the graph returned by add-ents that captures ents and their relationships. Under the :attrs key, you can see that each ent (:t0, :tl0, and :u0) now has the attribute :spec-gen. Under :spec-gen is a map that's been generated using clojure.spec, except that the foreign keys have been updated to be correct.

Sometimes you want to view the data that clojure.spec has generated. To make that easier, Specmonstah has the reifyhealth.specmonstah.core/attr-map function:

(defn ex-03
  []
  (-> (sg/ent-db-spec-gen {:schema schema} {:todo [[1]]})
      (sm/attr-map :spec-gen)))

(ex-03)
;; =>
{:tl0 {:id 21, :name "0N2xKMNwM8uO", :owner-id 19}
 :t0  {:id 4, :details "PGf92", :todo-list-id 21}
 :u0  {:id 19, :username "fz774"}}

attr-map returns a map where the keys are ent names and the values are the value of the given node attribute (:spec-gen here) for each ent. There's a convenience function that combines sg/ent-db-spec-gen and sm/attr-map, sg/ent-db-spec-gen-attr:

(defn ex-04
  []
  (sg/ent-db-spec-gen-attr {:schema schema} {:todo [[1]]}))

(ex-04)
;; =>
{:tl0 {:id 51, :name "VO1161Id66DJRftxq", :owner-id 90}
 :t0 {:id 91, :details "qaQ0e5Bfa6B", :todo-list-id 51}
 :u0 {:id 90, :username "82d71j551NVMFj4"}}

07: spec gen customization and omission

You can override the values produces by ent-db-spec-gen in the query:

(ns reifyhealth.specmonstah-tutorial.07
  (:require [reifyhealth.specmonstah.core :as sm]
            [loom.io :as lio]
            [clojure.spec.alpha :as s]
            [clojure.spec.gen.alpha :as gen]
            [reifyhealth.specmonstah.spec-gen :as sg]))

(s/def ::id (s/and pos-int? #(< % 100)))
(s/def ::not-empty-string (s/and string? not-empty #(< (count %) 20)))

(s/def ::username ::not-empty-string)
(s/def ::user (s/keys :req-un [::id ::username]))

(s/def ::name ::not-empty-string)
(s/def ::owner-id ::id)
(s/def ::todo-list (s/keys :req-un [::id ::name ::owner-id]))

(s/def ::details ::not-empty-string)
(s/def ::todo-list-id ::id)
(s/def ::todo (s/keys :req-un [::id ::details ::todo-list-id]))

(def schema
  {:user      {:prefix :u
               :spec   ::user}
   :todo-list {:prefix    :tl
               :spec      ::todo-list
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix    :t
               :spec     ::todo
               :relations {:todo-list-id [:todo-list :id]}}})

(defn ex-01
  []
  (sg/ent-db-spec-gen-attr {:schema schema}
                           {:user [[1 {:spec-gen {:username "bob"}}]]
                            :todo [[1 {:spec-gen {:details "get groceries"}}]]}))

(ex-01)
;; =>
{:tl0 {:id 48, :name "C8Cj51DSbZIb69Z", :owner-id 2}
 :t0  {:id 21, :details "get groceries", :todo-list-id 48}
 :u0  {:id 2, :username "bob"}}

You can also specify that you don't want an ent to reference one of the ents defined in its schema. For example, if a :todo-list's :owner-id is optional and you don't want it to be present, you could do this:

(defn ex-02
  []
  (sg/ent-db-spec-gen-attr {:schema schema} {:todo-list [[1 {:refs {:owner-id ::sm/omit}}]]}))

(ex-02)
;; =>
{:tl0 {:id 2, :name "v"}}

::sm/omit prevents the referenced ent from even being created in the ent db. spec generation respects this and omits :owner-id from the map it generates. If you want :owner-id to be nil, you'd have to specify that like this:

(defn ex-03
  []
  (sg/ent-db-spec-gen-attr {:schema schema} {:todo-list [[1 {:refs     {:owner-id ::sm/omit}
                                                             :spec-gen {:owner-id nil}}]]}))

(ex-03)
;; =>
{:tl0 {:id 2, :name "pijm" :owner-id nil}}

08: Visiting functions

You now have most of the pieces you need to generate and insert fixture data into a test database. Now you just need to... actually insert the data! To insert data you must visit each ent in the ent db with a visiting function, and that's what you'll learn to do in this section and the next.

Earlier I wrote,

Visiting ent nodes is kind of like mapping: when you call map on a seq, you apply a mapping function to each element, creating a new seq from the mapping function's return values. When you visit ents, you apply a visiting function to each ent. The visiting function's return value is stored as an attribute on the ent (remember that ents are implemented as graph nodes, and nodes can have attributes).

You've actually already seen a visiting function at work. In the last couple sections you learned how to use spec to generate a value for each ent. The generated value was stored under the :spec-gen attribute; that's because the sg/ent-db-spec-gen you called actually applies a visiting function to the ent db it generates. Let's create our own visiting function so you can see how this works:

(ns reifyhealth.specmonstah-tutorial.08
  (:require [reifyhealth.specmonstah.core :as sm]))


(def schema
  {:user      {:prefix :u}
   :todo-list {:prefix    :tl
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix    :t
               :relations {:todo-list-id [:todo-list :id]}}})

(defn announce
  [db ent-name visit-key]
  (str "announcing... " ent-name "!"))

(defn ex-01
  []
  (-> (sm/add-ents {:schema schema} {:todo [[1]]})
      (sm/visit-ents :announce announce)
      (get-in [:data :attrs])))

(ex-01)
;; =>
{:todo {:type :ent-type},
 :t0
 {:type :ent,
  :index 0,
  :ent-type :todo,
  :query-term [1],
  :loom.attr/edge-attrs {:tl0 {:relation-attrs #{:todo-list-id}}},
  :announce "announcing... :t0!"},
 :todo-list {:type :ent-type},
 :tl0
 {:type :ent,
  :index 0,
  :ent-type :todo-list,
  :query-term [:_],
  :loom.attr/edge-attrs {:u0 {:relation-attrs #{:owner-id}}},
  :announce "announcing... :tl0!"},
 :user {:type :ent-type},
 :u0
 {:type :ent,
  :index 0,
  :ent-type :user,
  :query-term [:_],
  :announce "announcing... :u0!"}}

(ex-01) creates an ent db, applies a visiting function, and then looks up the :attrs key in the graph associated with the ent db's :data key. The :attrs key is where loom stores each node's attributes. We can see that each ent (:u0, :t0, :tl0) has an attribute with the key :announce and the value of "announcing... :ent-name!". Let's walk through this. You call the function ex-01, whose body is:

(-> (sm/add-ents {:schema schema} {:todo [[1]]})
    (sm/visit-ents :announce announce)
    (get-in [:data :attrs]))

sm/add-ents builds the ent db and passes it to sm/visit-ents. sm/visit-ents takes three arguments: the ent db, a visit key, and a visiting function. Then, internally, sm/visits-ents iterates overs each ent in the ent db, passing the ent's name to the visiting function along with the db and visit key, using the return value to assign an attribute to the ent.

So in the above example, the ents are :u0, :tl0, and :t0, and sm/visit-ents iterates over them in that order. For :u0, it passes the following arguments to the visiting function announce:

  1. the ent db
  2. the ent name, :u0
  3. the visit key, :announce

The return value of announce is "announcing... :u0!", and that gets associated with the :announce key under the ent's attributes.

I'm concerned that this part of the tutorial isn't clear enough, and I hope to be able to improve it. In the mean time, if you're still struggling with visiting functions, try guessing what would happen if you changed the arguments to sm/visit-ents, then actually change the arguments and see if your guess was correct.

09: An insertion visiting function

In this section you'll look at how you could insert the data Specmonstah has generated into a database. We'll be adding the data to an atom, but you can apply the idea to your own database. The code:

(ns reifyhealth.specmonstah-tutorial.09
  (:require [reifyhealth.specmonstah.core :as sm]
            [reifyhealth.specmonstah.spec-gen :as sg]
            [clojure.spec.alpha :as s]))

(s/def ::id (s/and pos-int? #(< % 100)))
(s/def ::not-empty-string (s/and string? not-empty #(< (count %) 20)))

(s/def ::username ::not-empty-string)
(s/def ::user (s/keys :req-un [::id ::username]))

(s/def ::name ::not-empty-string)
(s/def ::owner-id ::id)
(s/def ::todo-list (s/keys :req-un [::id ::name ::owner-id]))

(s/def ::details ::not-empty-string)
(s/def ::todo-list-id ::id)
(s/def ::todo (s/keys :req-un [::id ::details ::todo-list-id]))

(def schema
  {:user      {:prefix :u
               :spec   ::user}
   :todo-list {:prefix    :tl
               :spec      ::todo-list
               :relations {:owner-id [:user :id]}}
   :todo      {:prefix    :t
               :spec     ::todo
               :relations {:todo-list-id [:todo-list :id]}}})

(def database (atom []))

(defn insert
  [db ent-name visit-key]
  (let [{:keys [spec-gen ent-type] :as attrs} (sm/ent-attrs db ent-name)]
    (when-not (visit-key attrs)
      (swap! database conj [ent-type spec-gen])
      true)))

(defn ex-01
  []
  (reset! database [])
  (-> (sg/ent-db-spec-gen {:schema schema} {:todo [[1]]})
      (sm/visit-ents :insert insert))
  @database)

(ex-01)
;; =>
[[:user {:id 6, :username "Ov0zaH57lTk86bAh"}]
 [:todo-list {:id 23, :name "9", :owner-id 6}]
 [:todo {:id 2, :details "hf", :todo-list-id 23}]]

The specs and schema should be familiar by now. Looking at the ex-01 function, we see that it calls sg/ent-db-spec-gen. As you saw earlier, this creates the ent db and uses clojure.spec to generate a map for each ent, storing the map under the ent's :spec-gen attribute. The resulting ent db is passed to sm/visit-ents with the visit key :insert and visiting function insert.

insert works by:

  • Checking whether this ent has already been inserted
  • If not, updating the database atom by conjing a vector of the ent's type and the value generated by spec-gen.

Let's go through insert line by line:

(defn insert
  [db ent-name visit-key]
  (let [{:keys [spec-gen ent-type] :as attrs} (sm/ent-attrs db ent-name)]
    (when-not (visit-key attrs)
      (swap! database conj [ent-type spec-gen])
      true)))

In the let binding you get the ent's attributes with sm/ent-attrs. The next line, (when-not (visit-key attrs) ...), checks whether insert has already visited this ent. (I'll explain why you want to perform this check soon.) If the ent hasn't been visited, the database gets updated by conjing a vector of the ent-type and spec-gen. The database atom ends up with a value like this:

[[:user {:id 6, :username "Ov0zaH57lTk86bAh"}]
 [:todo-list {:id 23, :name "9", :owner-id 6}]
 [:todo {:id 2, :details "hf", :todo-list-id 23}]]

Each ent is inserted in dependency order: :user first, then :todo-list, then :todo.

Now let's revisit (when-not (visit-key attrs) ...). You want to perform this check because of Specmonstah's progressive construction feature: as we covered in 05: Progressive construction, it's possible to pass an ent-db to successive calls to sm/add-ents. If you added more ents and wanted to insert, you wouldn't want to re-insert previous ents. ex-02 demonstrates this:

(defn ex-02
  []
  (reset! database [])
  (-> (sg/ent-db-spec-gen {:schema schema} {:todo [[1]]})
      (sm/visit-ents :insert insert)
      (sg/ent-db-spec-gen {:todo [[3]]})
      (sm/visit-ents :insert insert))
  @database)

(ex-02)
;; =>
[[:user {:id 23, :username "0B1E5Iq4QWz4q"}]
 [:todo-list {:id 16, :name "gt", :owner-id 23}]
 [:todo {:id 17, :details "wN92", :todo-list-id 16}]
 [:todo {:id 3, :details "cQOav9DBqI8M57", :todo-list-id 16}]
 [:todo {:id 8, :details "Ek065tC78bD9wEJwLa", :todo-list-id 16}]
 [:todo {:id 5, :details "9", :todo-list-id 16}]]

The :user, :todo-list, and :todo ents from the first call to ent-db-spec-gen are only inserted once, even though they are visited by insert multiple times.

In fact, recall that ent-db-spec-gen internally calls sm/add-ents and then calls the sg/spec-gen visiting function. sg/spec-gen is written with this same principle in mind: it can visit the ent db multiple times, and won't overwrite any existing values. The pattern is common enough that Specmonstah provides the sm/visit-ents-once which you can use instead of sm/visit-ents:

(defn insert-once
  [db ent-name visit-key]
  (swap! database conj ((juxt :ent-type :spec-gen) (sm/ent-attrs db ent-name)))
  true)

(defn ex-03
  []
  (reset! database [])
  (-> (sg/ent-db-spec-gen {:schema schema} {:todo [[1]]})
      (sm/visit-ents-once :insert insert-once)
      (sg/ent-db-spec-gen {:todo [[3]]})
      (sm/visit-ents-once :insert insert-once))
  @database)

(ex-03)
;; =>
[[:user {:id 2, :username "S"}]
 [:todo-list {:id 2, :name "58Zb3p0Y75BJZS1m", :owner-id 2}]
 [:todo {:id 2, :details "OHqbuPz", :todo-list-id 2}]
 [:todo {:id 13, :details "3v7rllHTps1r6gN3", :todo-list-id 2}]
 [:todo {:id 27, :details "5nn24T029w7Z9KBUXE", :todo-list-id 2}]
 [:todo {:id 2, :details "zV37csm519blvP3r", :todo-list-id 2}]]

With this coverage of visit-ents and visit-ents-once, you should be able to handle most use cases. Specmonstah handles other corner cases (like inserting records in the correct order when there are cycles.) This guide is a work in progress, and those usages aren't covered yet. However, every use case is covered in the test suite, so if you're running into an issue, check the tests first.

More use cases

TODO

  • Uniqueness constraints
  • binding
  • polymorphism
  • collection constraint
  • handling cycles

Usage Reference

TODO

Glossary

TODO

  • ent
  • ent db
  • ent attr
  • schema
  • visiting
  • visit key
  • visiting function
  • constraints
  • refs
  • bind

Contributing

I'm looking to exercise Specmonstah 2 against the following use cases:

  • Generating data for unit tests. What to look for:
    • Are there surprises?
    • Can it handle deeply nested combinations of :coll and :uniq relationships?
    • Does binding work as expected?
    • Is it easy to retrieve the views of the specmonstah db needed for a test? For example, if you want to generate 2 Todos for insertion, but a TodoList and a User also get generated, can you access just the Todos with minimal code?
  • Generating seed data.
    • Can you easily and clearly specify an entire database? For example, can you express "I want to create a db with 3 todo lists belonging to 2 users, where one list has 5 items, one has 1, and one has 0".
    • Can you easily tweak the above? For example if you want to create an additional todo list but leave everything else the same, or generate only empty todo lists.
  • Progressively generating and mapping a database.
    • Does anything unexpected happen if you create an entity database and map over it to perform inserts over multiple calls?