diff --git a/src/main/clojure/clara/macros.clj b/src/main/clojure/clara/macros.clj index 8c989af2..3d1d8251 100644 --- a/src/main/clojure/clara/macros.clj +++ b/src/main/clojure/clara/macros.clj @@ -200,8 +200,34 @@ {:type (com/effective-type type) :alpha-fn (com/compile-condition type (first args) constraints fact-binding env) - :children (vec beta-children) - }))) + :children (vec beta-children)}))) + +(defn productions->session-assembly-form + [productions options] + (let [beta-graph (com/to-beta-graph productions) + ;; Compile the children of the logical root condition. + beta-network (gen-beta-network (get-in beta-graph [:forward-edges 0]) beta-graph #{}) + + alpha-graph (com/to-alpha-graph beta-graph) + alpha-nodes (compile-alpha-nodes alpha-graph)] + + `(let [beta-network# ~beta-network + alpha-nodes# ~alpha-nodes + productions# '~productions + options# ~options] + (clara.rules/assemble-session beta-network# alpha-nodes# productions# options#)))) + +(defn sources-and-options->session-assembly-form + [sources-and-options] + (let [sources (take-while #(not (keyword? %)) sources-and-options) + options (apply hash-map (drop-while #(not (keyword? %)) sources-and-options)) + ;; Eval to unquote ns symbols, and to eval exprs to look up + ;; explicit rule sources + sources (eval (vec sources)) + productions (vec (for [source sources + production (get-productions source)] + production))] + (productions->session-assembly-form productions options))) (defmacro defsession "Creates a sesson given a list of sources and keyword-style options, which are typically ClojureScript namespaces. @@ -220,36 +246,16 @@ Typical usage would be like this, with a session defined as a var: -(defsession my-session 'example.namespace) + (defsession my-session 'example.namespace) -That var contains an immutable session that then can be used as a starting point to create sessions with -caller-provided data. Since the session itself is immutable, it can be safely used from multiple threads -and will not be modified by callers. So a user might grab it, insert facts, and otherwise -use it as follows: + That var contains an immutable session that then can be used as a starting point to create sessions with + caller-provided data. Since the session itself is immutable, it can be safely used from multiple threads + and will not be modified by callers. So a user might grab it, insert facts, and otherwise + use it as follows: (-> my-session (insert (->Temperature 23)) (fire-rules)) -" + " [name & sources-and-options] - (let [sources (take-while #(not (keyword? %)) sources-and-options) - options (apply hash-map (drop-while #(not (keyword? %)) sources-and-options)) - ;; Eval to unquote ns symbols, and to eval exprs to look up - ;; explicit rule sources - sources (eval (vec sources)) - productions (vec (for [source sources - production (get-productions source)] - production)) - - beta-graph (com/to-beta-graph productions) - ;; Compile the children of the logical root condition. - beta-network (gen-beta-network (get-in beta-graph [:forward-edges 0]) beta-graph #{}) - - alpha-graph (com/to-alpha-graph beta-graph) - alpha-nodes (compile-alpha-nodes alpha-graph)] - - `(let [beta-network# ~beta-network - alpha-nodes# ~alpha-nodes - productions# '~productions - options# ~options] - (def ~name (clara.rules/assemble-session beta-network# alpha-nodes# productions# options#))))) + `(def ~name ~(sources-and-options->session-assembly-form sources-and-options))) diff --git a/src/main/clojure/clara/rules/dsl.clj b/src/main/clojure/clara/rules/dsl.clj index 6bd71b23..eefdd7be 100644 --- a/src/main/clojure/clara/rules/dsl.clj +++ b/src/main/clojure/clara/rules/dsl.clj @@ -47,7 +47,12 @@ "Creates a condition with the given optional result binding when parsing a rule." [condition result-binding expr-meta] (let [type (if (symbol? (first condition)) - (if-let [resolved (resolve (first condition))] + (if-let [resolved (and + ;; If we are compiling ClojureScript rules we don't want + ;; to resolve the symbol in the ClojureScript compiler's + ;; Clojure environment. See issue 300. + (not (com/compiling-cljs?)) + (resolve (first condition)))] ;; If the type resolves to a var, grab its contents for the match. (if (var? resolved) diff --git a/src/main/clojure/clara/tools/testing_utils.clj b/src/main/clojure/clara/tools/testing_utils.clj new file mode 100644 index 00000000..e1e379db --- /dev/null +++ b/src/main/clojure/clara/tools/testing_utils.clj @@ -0,0 +1,66 @@ +(ns clara.tools.testing-utils + "Internal utilities for testing clara-rules and derivative projects. These should be considered experimental + right now from the perspective of consumers of clara-rules, although it is possible that this namespace + will be made part of the public API once its functionality has proven robust and reliable. The focus, however, + is functionality needed to test the rules engine itself." + (:require [clara.macros :as m] + [clara.rules.dsl :as dsl] + [clara.rules.compiler :as com])) + +(defmacro def-rules-test + "This macro allows creation of rules, queries, and sessions from arbitrary combinations of rules + and queries in a setup map without the necessity of creating a namespace or defining a session + using defsession in both Clojure and ClojureScript. The first argument is the name of the test, + and the second argument is a map with entries :rules, :queries, and :sessions. For example usage see + clara.test-testing-utils. Note that sessions currently can only contain rules and queries defined + in the setup map; supporting other rule sources such as namespaces and defrule/defquery may be possible + in the future. + + Namespaces consuming this macro are expected to require clara.rules and either clojure.test or cljs.test. + Unfortunately, at this time we can't add inline requires for these namespace with the macroexpanded code in + ClojureScript; see https://anmonteiro.com/2016/10/clojurescript-require-outside-ns/ for some discussion on the + subject. However, the test namespaces consuming this will in all likelihood have these dependencies anyway + so this probably isn't a significant shortcoming of this macro." + [name params & forms] + (let [sym->rule (->> params + :rules + (partition 2) + (into {} + (map (fn [[rule-name [lhs rhs props]]] + [rule-name (dsl/parse-rule* lhs rhs props {})])))) + + sym->query (->> params + :queries + (partition 2) + (into {} + (map (fn [[query-name [params lhs]]] + [query-name (dsl/parse-query* params lhs {})])))) + + production-syms->productions (fn [p-syms] + (map (fn [s] + (or (get sym->rule s) + (get sym->query s))) + p-syms)) + + session-syms->session-forms (->> params + :sessions + (partition 3) + (into [] + (comp (map (fn [[session-name production-syms session-opts]] + [session-name (production-syms->productions production-syms) session-opts])) + (map (fn [[session-name productions session-opts]] + [session-name (if (com/compiling-cljs?) + (m/productions->session-assembly-form (map eval productions) session-opts) + `(com/mk-session ~(into [(vec productions)] + cat + session-opts)))])) + cat))) + + test-form `(~(if (com/compiling-cljs?) + 'cljs.test/deftest + 'clojure.test/deftest) + ~name + (let [~@session-syms->session-forms + ~@(sequence cat sym->query)] + ~@forms))] + test-form)) diff --git a/src/test/clojurescript/clara/test.cljs b/src/test/clojurescript/clara/test.cljs index 1ed610d8..580a99d7 100644 --- a/src/test/clojurescript/clara/test.cljs +++ b/src/test/clojurescript/clara/test.cljs @@ -4,7 +4,8 @@ [cljs.test] [clara.test-salience] [clara.test-complex-negation] - [clara.test-common])) + [clara.test-common] + [clara.test-testing-utils])) (enable-console-print!) @@ -17,4 +18,5 @@ (test/run-tests 'clara.test-rules 'clara.test-common 'clara.test-salience + 'clara.test-testing-utils 'clara.test-complex-negation)) diff --git a/src/test/clojurescript/clara/test_rules.cljs b/src/test/clojurescript/clara/test_rules.cljs index 9e73b974..f647a29c 100644 --- a/src/test/clojurescript/clara/test_rules.cljs +++ b/src/test/clojurescript/clara/test_rules.cljs @@ -38,7 +38,6 @@ [] [?t <- lowest-temp :from [Temperature]]) - (defrule is-cold-and-windy "Rule to determine whether it is indeed cold and windy." @@ -82,10 +81,27 @@ [WindSpeed (== ?w windspeed) (== ?loc location)] [Temperature (== ?t temperature) (== ?loc location)]) +;; The idea here is that Number will resolve to java.lang.Number in a Clojure environment, +;; so this validates that we correctly handle symbols in a ClojureScript rule that happen +;; to resolve to something in a Clojure environment. Since ClojureScript's compiler +;; is in Clojure failing to handle this correctly can cause us to attempt to embed +;; Java objects in ClojureScript code, which won't work. See issue 300. +(defrecord Number [value]) + +(defquery num-query + [] + [?n <- Number]) + (defsession my-session 'clara.test-rules) (defsession my-session-map 'clara.test-rules :fact-type-fn :type) (defsession my-session-data (clara.test-rules-data/weather-rules)) +(deftest test-number-query + (is (= (-> my-session + (insert (->Number 5)) + fire-rules + (query num-query)) + [{:?n (->Number 5)}]))) (deftest test-simple-defrule (let [session (insert my-session (->Temperature 10 "MCI"))] diff --git a/src/test/common/clara/test_testing_utils.cljc b/src/test/common/clara/test_testing_utils.cljc new file mode 100644 index 00000000..c787320c --- /dev/null +++ b/src/test/common/clara/test_testing_utils.cljc @@ -0,0 +1,56 @@ +#?(:clj + (ns clara.test-testing-utils + (:require [clara.tools.testing-utils :refer [def-rules-test]] + [clara.rules :as r] + + [clara.rules.testfacts :refer [->Temperature ->Cold]] + [clojure.test :refer [is deftest run-tests] :as t]) + (:import [clara.rules.testfacts + Temperature + Cold])) + + :cljs + (ns clara.test-testing-utils + (:require [clara.rules :as r] + [clara.rules.testfacts :refer [->Temperature Temperature + ->Cold Cold]] + [cljs.test :as t]) + (:require-macros [clara.tools.testing-utils :refer [def-rules-test]] + [cljs.test :refer (is deftest run-tests)]))) + +(def test-ran-atom (atom false)) + +;; This test fixture validates that def-rules-test actually executed the test bodies it +;; is provided. If the test bodies were not executed test-ran-atom would have a value of false +;; after test execution. +(t/use-fixtures :once (fn [t] + (reset! test-ran-atom false) + (t) + (is (true? @test-ran-atom)))) + +(def-rules-test basic-tests + {:rules [rule1 [[[?t <- Temperature (< temperature 0)]] + (r/insert! (->Cold (:temperature ?t)))]] + + :queries [query1 [[] + [[Cold (= ?t temperature)]]]] + + :sessions [session1 [rule1 query1] {} + session2 [rule1 query1] {:fact-type-fn (fn [fact] :bogus)}]} + + (reset! test-ran-atom true) + (is (= [{:?t -50}] + (-> session1 + (r/insert (->Temperature -50 "MCI")) + r/fire-rules + (r/query query1)))) + + ;; Since we validate later (outside the scope of this test) that the state + ;; change occurred put it in the middle so that it would fail if we took either + ;; the first or last test form, rather than all test forms. + (reset! test-ran-atom true) + + (is (empty? (-> session2 + (r/insert (->Temperature -50 "MCI")) + r/fire-rules + (r/query query1)))))