oracle-samples/clara-rules

Refactor defrule and defquery for ClojureScript to better support customization

Opened this issue · 8 comments

Currently, if I were to write a custom rule/query definition DSL in ClojureScript, the only choice would be to author macros that convert the new DSL into defrule's or defquery's in terms of the clara DSL. It would be easier to simply emit the data structures which are the result of the parsing and transformation performed by defrule and defquery. But both macros currently require a side-effect in CLJS to interact with the compiler, an implementation detail which could potentially change. Therefore it would be useful to refactor the macros such that they will accept the data structure, perform any side-effects, and emit the appropriate def statement.

Another scenario is when using the clara DSL, but there is a need to transform the resultant data structures. An example I encountered was using custom fact types, where the defrecord type was not immediately available to the clara compiler and thus default destructuring was not applied. Without explicit destructuring the CLJS compiler gives lots of warnings about undefined symbols, and there are cases where bugs occur when the local symbol collides with some other symbol in scope. This can be avoided with a custom macro that transforms the the rule/query definition, and again the easiest path is to transform the data-structure rather than emitting another version of defrule or defquery, which is enabled by making the rule-parsing logic available as a public function to be consumed by the custom macros, in addition to the change described in the first paragraph.

The proposed refactoring for defrule would look something like this:

(defn build-rule
  [name & body]
  (let [doc (if (string? (first body)) (first body) nil)
        body (if doc (rest body) body)
        properties (if (map? (first body)) (first body) nil)
        definition (if properties (rest body) body)
        {:keys [lhs rhs]} (dsl/split-lhs-rhs definition)

        production (cond-> (dsl/parse-rule* lhs rhs properties {})
                           name (assoc :name (str (clojure.core/name (com/cljs-ns)) "/" (clojure.core/name name)))
                           doc (assoc :doc doc))]
    production))

(defn defrule!
  [name production]
  (add-production name production)
  `(def ~name
     ~production))

(defmacro defrule
  [name & body]
  (defrule! name (apply build-rule name body)))

Similar refactoring would apply to defquery.

That basic refactoring seems worthwhile. +1 to having a more data-oriented ClojureScript rule definition interface for users. Since the clara.macros namespace is deprecated as a direct interaction point with users (as opposed to something leveraged by externally facing functions/macros, for which it isn't deprecated) we may want to set things up in such a way that the user can call them from another namespace, whether via actually moving them, setting up an alias to them, or something else. I'll have to think on what the best way to approach that is.

You could argue that the use-case for direct access of the refactored bits is for authoring externally-facing functions/macros, i.e. making something that replaces some of the functionality in the current DSL, as opposed to something used in the defining a specific rule/query/session logic. That said, a lot of what's in clara.macros probably doesn't need to every be used for this purpose, so perhaps a dedicated NS for DSL authors?

@WilliamParker I'm not sure the idea of having a clara.macros namespace really makes sense in the future. There is better support now for CLJS ns's with respect to macros. Also, things like implicit macro loading and macro inference would be good things to take advantage of better.

I like the idea of explicitly exposing some functionality to "alternative DSL" implementation authors.

After staring at the code and playing around, I think the one other useful place for custom DSLs to hook in to clara is clara.macros/productions->session-assembly-form. If I wanted to use some mechanism other than namespaces to group rule defs, I would need to call this function to build the session. That seems to be as low-level as a DSL would need to go, since any deeper gets into the specifics of the Rete implementation.

One other observation from my experimenting: env/*compiler* is just an atom associated with each namespace being compiled. If I (def productions (atom {})) in clara.macros and use this instead, everything seems to work fine. Perhaps there are other reasons to use env/*compiler*? It is potentially restrictive. For example, if I refactor defrule! etc. into it's own clara.dsl namespace, using env/*compiler* breaks things, because clara.macros gets a different version than clara.dsl. Adding the productions atom explicitly to clara.dsl, storing productions there, and referencing from clara.macros seems to work fine.

I went ahead and submitted a PR for this, need it for my own work so I thought it was worthwhile to throw over the fence for further discussion.

One other thing that looks useful is allowing for keyword names in clara.macros/build-rule.

Is there anything left for us to address here before closing the issue? The discussion about using keywords as production names can continue at #371.