Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Odoyle's Rules Cookbook

## Initializing values

Suppose we have an aggregate:

```clojure
(o/ruleset
{#_...
::agregate-things
[:what
[thing-id :thing/name thing-name]
[thing-id :thing/owner person-id]
:then-finally
(->> (o/rules o/*session* ::aggregate-things)
(group-by :thing-id)
(o/insert o/*session* :global :domain/things)
(o/reset!))]})
```

The trouble is that `:thing/owner` may be an attribute of some things, but not all of them. We want to aggregate all the things.

The solution is to use a rule that initializes a `nil` value for `:thing/owner`:

```clojure
(o/ruleset
{#_...
::initialize-thing-owner
[:what [_ :thing/id id]
:when (not (o/contains? o/*session* id :thing/owner))
:then (o/insert! id :thing/owner nil)]})
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using {:then not=} here you could use o/contains? to check if the fact already exists:

[:what
 [_ :thing/id id]
 :then
 (when-not (o/contains? o/*session* id :thing/owner)
   (o/insert! id :thing/owner nil))]

The problem with using {:then not=} here is that you may actually need to update :thing/id for some reason, and if you do, it'll set the :thing/owner to nil even if it already had a value.

Copy link
Copy Markdown
Author

@thomascothran thomascothran Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! I've run into that limitation with {:then not=} and worked around it sometimes when I've needed to. Using o/contains? is perfect for this.

Updated to follow your suggestion

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oakes - one mistake I started making here pretty quickly was to do this:

[:what
 [_ :thing/id id]
 :when (not (o/contains? o/*session* id :thing/owner))
 :then
 (o/insert! id :thing/owner nil)]

This case can happen, which is strange:

[:what
 [id :thing/id id]
 [id :thing/owner owner]
 :when (not (o/contains? o/*session* id :thing/owner))
 :then
 (o/insert! id :thing/owner nil)]

In this case, there is a match, which is counter-intuitive to me.

Copy link
Copy Markdown
Owner

@oakes oakes Jul 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe *session* isn't bound to anything in :when blocks. I need to fix that. I'm about to board a plane but I'll look into that soon.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you can, try the latest commit. It should fix this problem.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you get a chance to try the latest commit and see if it fixes this?

```

The value may not always be nil. It may also often be 0 or another neutral value. Note that `{:then not=}` prevents the rule from firing more than once. (However, if `[id :thing/id id]` is used, this will not work.)

## Build a Query System

Instead of gathering the data required for your rules, shaping it into the form you need it, and inserting it with `o/insert`, you can use rules to make the process automatic.

For example:

```clojure
(ns app.rules
(:require [odoyle.rules :as o]
[app.thing.db :refer [fetch-thing]]))
;; fetch-thing queries the db
;; which returns a namespaced map

(def rules
(o/ruleset
{::fetch-thing
[:what [_ :thing/id id {:then not=}]
[:then (o/insert! id (fetch-thing id))]]}))
```

Now, instead of calling `fetch-thing` and then `o/insert` from the outside, you can use `(o/insert <some-id> :thing/id <some-id>)`, and the rule will fetch for you.

The power of this approach is that O'Doyle rules becomes a query engine.

Suppose that you have `thing/owner` attribute, which is a set of the things a person owns. Combining two fetchers reveals the power of this approach:

```clojure
(ns app.rules
(:require [odoyle.rules :as o]
[app.person.db :refer [fetch-person]]
[app.thing.db :refer [fetch-thing]]))

(def rules
(o/ruleset
{::fetch-thing
[:what [_ :thing/id id {:then not=}]
:then ;; fetch-thing returns a namespaced map
(o/insert! id (fetch-thing id))] ;; `{:thing/name "name", :thing/owner 123, #_...}`
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this example, is the fetch-thing fn getting the data from the session via o/query-all, or from somewhere else?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this example, fetch-thing is returning a map from the database, not from the session. (The map here is flattened and namespaced.)

The benefit of this in my use cases is that I can put a single id for an object, and the rules will fetch all the related objects. This saves me from having to gather everything up before hand, which can often span quite a few related entities and domains.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense. Is there any benefit to doing the db query from inside a rule, vs querying and inserting from the outside? I tend to avoid interacting with external things (databases, rendering, etc) from inside rules, though this is probably OK. One big gotcha that I mention on the readme is using query-all from within a rule that queries another rule. That could cause problems because it eliminates reactivity. But if you're just querying some external db then it won't be reactive anyway.

Copy link
Copy Markdown
Author

@thomascothran thomascothran Jul 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting the queries in the rules handles the joins for you automatically, somewhat like what Pathom does. If I insert one id for an entity, the rules engine will make all the queries, find all the relations, load all the related entities, and execute the rules against them all.

I've found this to be extremely useful.

To give a concrete example: for my main use case, we have a library of rules used by different services in a microservice environment. If I'm in the service that owns an entity, I want to pass in a db connection so I can use transactions. But an entity in a service is almost always related to entities in another service. Those entities I want to fetch over a network call to another service (or from Redis, depending on what it is).

Again, I might be using the library from the front end, in which case I want to make a network call for entities, but need to make those calls differently (authorization rules).

I can pass a triple to the rules engine indicating which service the rules engine is being used from. Then the rules govern how an entity is loaded based on the fact indicating where it is being called from. These are written once for all services, but handle all the different use cases.

So the rules engine turns into a pretty powerful query system + rules engine. For my use case, the query part is as much part of the value proposition as the "normal usage" of the rules.

(One potential drawback is that all these network calls need to be blocking. We typically block on network calls anyway, so this hasn't been an issue.)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of ::fetch-thing there is no join. When you use this technique are you normally querying with more than just an id, so a join is needed? Just want to understand the use case.


::fetch-person
[:what [_ :person/id id {:then not=}]
:then (o/insert! id (fetch-person-id))] ;; fetch-person returns a namespaced map

::person-owner-link
[:what [_ :thing/owner person-id {:then not=}]
:then (o/insert! person-id :person/id person-id)]}))
```

The `::person-owner-link` links `:thing/owner` to `:person/id`. The call to `fetch-thing` returns a map with `:thing/owner`. This is inserted into the session.

`::person-owner-link` then fires, inserting a `:person/id`. This in turn triggers `::fetch-person` to query the database for all the `:person` information, and insert it into the session.

By providing a single id, all related domain entities can be fetched, whether from a database or a service.


## Splitting rule sets by namespaces

If you have a number of different rulesets, you can split them by namespaces, and then merge them into a single ruleset.

For example:

```clojure
(ns app.rules
(:require [odoyle.rules :as o]
[app.domain-a.rules :as a]
[app.domain-b.rules :as b]))

(def base-rules
(o/ruleset
{::some-rule ...}))

(def ruleset
(reduce into [base-rules a/ruleset b/ruleset]))
```