diff --git a/CHANGELOG.md b/CHANGELOG.md
index e36d8a8c..31e417d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
This is a history of changes to k13labs/clara-rules.
+# 1.5.2
+* implement sorting-by and sorted-grouping-by accumulators
+
# 1.5.1
* do not break inspection if no accumulated facts are found
diff --git a/pom.xml b/pom.xml
index 4fecccf5..64691a5c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,9 +5,9 @@
com.github.k13labs
clara-rules
clara-rules
- 1.5.1
+ 1.5.2
- 1.5.1
+ 1.5.2
https://github.com/k13labs/clara-rules
scm:git:git://github.com/k13labs/clara-rules.git
scm:git:ssh://git@github.com/k13labs/clara-rules.git
diff --git a/src/main/clojure/clara/rules/accumulators.clj b/src/main/clojure/clara/rules/accumulators.clj
index f71b1663..d1e3fc3e 100644
--- a/src/main/clojure/clara/rules/accumulators.clj
+++ b/src/main/clojure/clara/rules/accumulators.clj
@@ -211,3 +211,39 @@
{:initial-value []
:reduce-fn (fn [items value] (conj items (field value)))
:retract-fn (fn [items retracted] (drop-one-of items (field retracted)))})))
+
+(defn sorting-by
+ "Return a generic sorting accumulator. Behaves like clojure.core/sort-by.
+ * `field` - required - The field of a fact to sort by.
+ * `comparator` - optional - The comparator for sort by, defaults to `clojure.core/compare`.
+ * `convert-return-fn` - optional - Converts the resulting sorted data. Defaults to clojure.core/identity."
+ [field & {:keys [comparator
+ convert-return-fn]
+ :or {comparator compare
+ convert-return-fn identity}}]
+ {:pre [(ifn? convert-return-fn)]}
+ (assoc (all) :convert-return-fn
+ (comp convert-return-fn (fn do-sort
+ [return-items]
+ (sort-by field comparator return-items)))))
+
+(defn sorted-grouping-by
+ "Return a generic sorted grouping accumulator. Behaves like clojure.core/group-by into a map
+ as if created by `clojure.core/sorted-map-by`, and each group of values is sorted as if by `clojure.core/sort-by`.
+ * `group-field` - required - The field of a fact to group facts by.
+ * `sort-field` - required - The field of a fact to sort facts by.
+ * `group-comparator` - optional - The comparator to compare sorted groups, defaults to `clojure.core/compare`.
+ * `sort-comparator` - optional - The comparator to compare sorted values, defaults to `clojure.core/compare`.
+ * `convert-return-fn` - optional - Converts the resulting grouped data. Defaults to clojure.core/identity."
+ [group-field sort-field & {:keys [group-comparator
+ sort-comparator
+ convert-return-fn]
+ :or {group-comparator compare
+ sort-comparator compare
+ convert-return-fn identity}}]
+ {:pre [(ifn? convert-return-fn)]}
+ (update (grouping-by group-field convert-return-fn)
+ :convert-return-fn comp (fn do-sort [m]
+ (into (sorted-map-by group-comparator)
+ (for [[k vs] m]
+ [k (sort-by sort-field sort-comparator vs)])))))
diff --git a/src/test/clojure/clara/test_accumulators.clj b/src/test/clojure/clara/test_accumulators.clj
index e6feac7d..6ac06051 100644
--- a/src/test/clojure/clara/test_accumulators.clj
+++ b/src/test/clojure/clara/test_accumulators.clj
@@ -536,3 +536,208 @@
(is (and (= (count (query session wind-query)) 1)
(= [(->WindSpeed 75 "KCI")] wind-facts)
(seq? wind-facts))))))
+
+(def-rules-test test-accum-sorting-by
+ {:queries [sorted-all [[] [[?t <- (acc/sorting-by :temperature) from [Temperature]]]]
+ sorted-all-desc [[] [[?t <- (acc/sorting-by :temperature :comparator >) :from [Temperature]]]]
+ sorted-all-conv [[] [[?t <- (acc/sorting-by :temperature :convert-return-fn #(map :temperature %)) :from [Temperature]]]]]
+
+ :sessions [empty-session [sorted-all sorted-all-desc sorted-all-conv] {}]}
+
+ (let [shuffled-facts (shuffle
+ (for [temp (range 80 85)]
+ (->Temperature temp "MCI")))
+ session (-> empty-session
+ (insert-all shuffled-facts)
+ fire-rules)
+
+ retracted (-> session
+ (retract (->Temperature 81 "MCI")
+ (->Temperature 82 "MCI"))
+ fire-rules)
+
+ all-retracted (-> (apply retract session shuffled-facts)
+ (fire-rules))]
+
+ ;; Ensure expected items are there. Ordering is guaranteed.
+ (is (= [{:?t (sort-by :temperature shuffled-facts)}]
+ (query session sorted-all)))
+
+ (is (= [{:?t (sort-by :temperature > shuffled-facts)}]
+ (query session sorted-all-desc)))
+
+ (is (= [{:?t (->> (sort-by :temperature shuffled-facts)
+ (map :temperature))}]
+ (query session sorted-all-conv)))
+
+ (is (= [{:?t (->> [(->Temperature 81 "MCI")
+ (->Temperature 82 "MCI")]
+ (apply disj (set shuffled-facts))
+ (sort-by :temperature))}]
+ (query retracted sorted-all)))
+
+ (is (= [{:?t (->> [(->Temperature 81 "MCI")
+ (->Temperature 82 "MCI")]
+ (apply disj (set shuffled-facts))
+ (sort-by :temperature >))}]
+ (query retracted sorted-all-desc)))
+
+ (is (= [{:?t (->> [(->Temperature 81 "MCI")
+ (->Temperature 82 "MCI")]
+ (apply disj (set shuffled-facts))
+ (sort-by :temperature)
+ (map :temperature))}]
+ (query retracted sorted-all-conv)))
+
+ (is (= [{:?t []}]
+ (query all-retracted sorted-all)
+ (query all-retracted sorted-all-desc)
+ (query all-retracted sorted-all-conv))
+ "Retracting all values should cause a return to the initial value of
+ an empty sequence.")))
+
+(def-rules-test test-accum-sorted-grouping-by
+ {:queries [sorted-grouping [[] [[?t <- (acc/sorted-grouping-by :location :temperature)
+ :from [Temperature]]]]
+
+ sorted-grouping-desc [[] [[?t <- (acc/sorted-grouping-by :location :temperature
+ :group-comparator #(compare %2 %1)
+ :sort-comparator >)
+ :from [Temperature]]]]
+ sorted-grouping-conv [[] [[?t <- (acc/sorted-grouping-by :location :temperature
+ :convert-return-fn vec)
+ :from [Temperature]]]]]
+
+ :sessions [empty-session [sorted-grouping sorted-grouping-desc sorted-grouping-conv] {}]}
+
+ (let [shuffled-facts (shuffle
+ (concat
+ (for [temp (range 80 85)]
+ (->Temperature temp "MCI"))
+ (for [temp (range 85 90)]
+ (->Temperature temp "ORD"))
+ (for [temp (range 90 95)]
+ (->Temperature temp "TPA"))))
+ session (-> empty-session
+ (insert-all shuffled-facts)
+ fire-rules)
+
+ retracted-session (-> session
+ (retract (->Temperature 80 "MCI")
+ (->Temperature 85 "ORD")
+ (->Temperature 90 "TPA"))
+ fire-rules)
+ retracted-all-session (-> (apply retract session shuffled-facts)
+ fire-rules)]
+
+ (testing "sorted grouping-accum"
+ (testing "all facts"
+ (is (= [{:?t [["MCI" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "MCI")]
+ fact)
+ (sort-by :temperature))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "ORD")]
+ fact)
+ (sort-by :temperature))]
+ ["TPA" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "TPA")]
+ fact)
+ (sort-by :temperature))]]}]
+ (for [result (query session sorted-grouping)]
+ (update result :?t vec)))))
+ (testing "retracted some facts"
+ (is (= [{:?t [["MCI" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "MCI")
+ (> (:temperature fact) 80))]
+ fact)
+ (sort-by :temperature))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "ORD")
+ (> (:temperature fact) 85))]
+ fact)
+ (sort-by :temperature))]
+ ["TPA" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "TPA")
+ (> (:temperature fact) 90))]
+ fact)
+ (sort-by :temperature))]]}]
+ (for [result (query retracted-session sorted-grouping)]
+ (update result :?t vec)))))
+ (testing "retracted all facts"
+ (is (= [{:?t {}}]
+ (query retracted-all-session sorted-grouping)))))
+
+ (testing "sorted grouping-accum desc"
+ (testing "all facts"
+ (is (= [{:?t [["TPA" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "TPA")]
+ fact)
+ (sort-by :temperature >))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "ORD")]
+ fact)
+ (sort-by :temperature >))]
+ ["MCI" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "MCI")]
+ fact)
+ (sort-by :temperature >))]]}]
+ (for [result (query session sorted-grouping-desc)]
+ (update result :?t vec)))))
+ (testing "retracted some facts"
+ (is (= [{:?t [["TPA" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "TPA")
+ (> (:temperature fact) 90))]
+ fact)
+ (sort-by :temperature >))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "ORD")
+ (> (:temperature fact) 85))]
+ fact)
+ (sort-by :temperature >))]
+ ["MCI" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "MCI")
+ (> (:temperature fact) 80))]
+ fact)
+ (sort-by :temperature >))]]}]
+ (for [result (query retracted-session sorted-grouping-desc)]
+ (update result :?t vec)))))
+ (testing "retracted all facts"
+ (is (= [{:?t {}}]
+ (query retracted-all-session sorted-grouping-desc)))))
+
+ (testing "sorted grouping-accum with custom return-convert-fn (vec)"
+ (testing "all facts"
+ (is (= [{:?t [["MCI" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "MCI")]
+ fact)
+ (sort-by :temperature))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "ORD")]
+ fact)
+ (sort-by :temperature))]
+ ["TPA" (->> (for [fact shuffled-facts
+ :when (= (:location fact) "TPA")]
+ fact)
+ (sort-by :temperature))]]}]
+ (query session sorted-grouping-conv))))
+ (testing "retracted some facts"
+ (is (= [{:?t [["MCI" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "MCI")
+ (> (:temperature fact) 80))]
+ fact)
+ (sort-by :temperature))]
+ ["ORD" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "ORD")
+ (> (:temperature fact) 85))]
+ fact)
+ (sort-by :temperature))]
+ ["TPA" (->> (for [fact shuffled-facts
+ :when (and (= (:location fact) "TPA")
+ (> (:temperature fact) 90))]
+ fact)
+ (sort-by :temperature))]]}]
+ (query retracted-session sorted-grouping-conv))))
+ (testing "retracted all facts"
+ (is (= [{:?t []}]
+ (query retracted-all-session sorted-grouping-conv)))))))