diff --git a/README.md b/README.md index d7e0b74..a6d6be5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A Clojure library designed to manipulate sparse *arrays* - multi-dimensional spa Arbitrary numbers of dimensions are supported, up to limits imposed by the JVM stack. +[](https://clojars.org/sparse-array) + ## Conventions: ### Sparse arrays @@ -32,7 +34,26 @@ Thus an array with a single value 'hello' at coordinates x = 3, y = 4, z = 5 wou } ``` -At the present stage of development, where the expectations of an operation are violated, `nil` is returned and no exception is thrown. However, it's probable that later there will be at least the option of thowing specific exceptions, as otherwise debugging could be tricky. +### Errors and error-reporting + +A dynamic variable, `*safe-sparse-operations*`, is provided to handle behaviour in error conditions. If this is `false`, bad data will generally not cause an exception to be thrown, and corrupt structures may be returned, thus: + +```clojure +(put (make-sparse-array :x :y :z) "hello" 3) ;; insufficient coordinates specified + +=> {:dimensions 3, :coord :x, :content (:y :z), 3 {:dimensions 2, :coord :y, :content (:z), nil {:dimensions 1, :coord :z, :content :data, nil nil}}} +``` + +However, if `*safe-sparse-operations*` is bound to `true`, exceptions will be thrown instead: + +```clojure +(binding [*safe-sparse-operations* true] + (put (make-sparse-array :x :y :z) "hello" 3)) + +ExceptionInfo Expected 3 coordinates; found 1 clojure.core/ex-info (core.clj:4617) +``` + +Sanity checking data is potentially expensive; for this reason `*safe-sparse-operations*` defaults to `false`, but you make wish to bind it to `true` especially while debugging. ### Dense arrays diff --git a/project.clj b/project.clj index d9c47b7..10fba9b 100644 --- a/project.clj +++ b/project.clj @@ -1,6 +1,27 @@ -(defproject sparse-array "0.1.0-SNAPSHOT" +(defproject sparse-array "0.1.0" :description "A Clojure library designed to manipulate sparse *arrays* - multi-dimensional spaces accessed by indices, but containing arbitrary values rather than just numbers. For sparse spaces which contain numbers only, you're better to use a *sparse matrix* library, for example [clojure.core.matrix](https://mikera.github.io/core.matrix/)." :url "http://example.com/FIXME" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} - :dependencies [[org.clojure/clojure "1.8.0"]]) + :dependencies [[org.clojure/clojure "1.8.0"]] + + :plugins [[lein-codox "0.10.4"] + [lein-release "1.0.5"]] + + + ;; `lein release` doesn't play nice with `git flow release`. Run `lein release` in the + ;; `develop` branch, then merge the release tag into the `master` branch. + + :release-tasks [["vcs" "assert-committed"] + ["clean"] + ["test"] + ["codox"] + ["change" "version" "leiningen.release/bump-version" "release"] + ["vcs" "commit"] + ;; ["vcs" "tag"] -- not working, problems with secret key + ["uberjar"] + ["install"] + ["deploy" "clojars"] + ["change" "version" "leiningen.release/bump-version"] + ["vcs" "commit"]]) + diff --git a/src/sparse_array/core.clj b/src/sparse_array/core.clj index b169df8..c984580 100644 --- a/src/sparse_array/core.clj +++ b/src/sparse_array/core.clj @@ -1,5 +1,18 @@ (ns sparse-array.core) +(declare put get) + +(def ^:dynamic *safe-sparse-operations* + "Whether spase array operations should be conducted safely, with careful + checking of data conventions and exceptions thrown if expectations are not + met. Normally `false`." + false) + +(defn- unsafe-sparse-operations? + "returns `true` if `*safe-sparse-operations*` is `false`, and vice versa." + [] + (not (true? *safe-sparse-operations*))) + (defn make-sparse-array "Make a sparse array with these `dimensions`. Every member of `dimensions` must be a keyword; otherwise, `nil` will be returned." @@ -14,26 +27,63 @@ :data (rest dimensions))})) +(defn- safe-test-or-throw + "If `v` is truthy or `*safe-sparse-operations*` is false, return `v`; + otherwise, throw an `ExceptionInfo` with this `message` and the map `m`." + [v message m] + (if-not + v + (if + *safe-sparse-operations* + (throw (ex-info message m)) + v) + v)) + (defn sparse-array? "`true` if `x` is a sparse array conforming to the conventions established by this library, else `false`." ([x] - (and - (map? x) - (pos? (:dimensions x)) - (keyword? (:coord x)) - (if - (coll? (:content x)) - (every? - sparse-array? - (map #(x %) (filter integer? (keys x)))) - (= (:content x) :data))))) + (apply + sparse-array? + (cons + x + (cons + (:coord x) + (if + (coll? (:content x)) + (:content x)))))) + ([x & axes] + (and + (safe-test-or-throw + (map? x) + "Array must be a map" {:array x}) + (safe-test-or-throw + (and (integer? (:dimensions x)) (pos? (:dimensions x))) + (str "The value of `:dimensions` must be a positive integer, not " (:dimensions x)) + {:array x}) + (safe-test-or-throw + (keyword? (:coord x)) + (str "The value of `:coord` must be a keyword, not " (:coord x)) + {:array x}) + (safe-test-or-throw + (= (:coord x) (first axes)) + (str "The value of `:coord` must be " (first axes) ", not " (:coord x)) + {:array x}) + (if + (empty? (rest axes)) + (safe-test-or-throw + (= (:content x) :data) + "If there are no further axes the value of `:content` must be `:data`" + {:array x}) + (and + (= (:content x) (rest axes)) + (every? + sparse-array? + (map #(x %) (filter integer? (keys x))))))))) -(defn put - "Return a sparse array like this `array` but with this `value` at these - `coordinates`. Returns `nil` if any coordinate is invalid." - [array value & coordinates] - (if +(defn- unsafe-put + [array value coordinates] + (cond (every? #(and (integer? %) (or (zero? %) (pos? %))) coordinates) @@ -51,14 +101,75 @@ (apply make-sparse-array (:content array))) (cons value (rest coordinates)))))))) +(defn put + "Return a sparse array like this `array` but with this `value` at these + `coordinates`. Returns `nil` if any coordinate is invalid." + [array value & coordinates] + (cond + (nil? value) + nil + (unsafe-sparse-operations?) + (unsafe-put array value coordinates) + (not (sparse-array? array)) + (throw (ex-info "Sparse array expected" {:array array})) + (not= (:dimensions array) (count coordinates)) + (throw + (ex-info + (str "Expected " (:dimensions array) " coordinates; found " (count coordinates)) + {:array array + :coordinates coordinates})) + (not + (every? + #(and (integer? %) (or (zero? %) (pos? %))) + coordinates)) + (throw + (ex-info + "Coordinates must be zero or positive integers" + {:array array + :coordinates coordinates + :invalid (remove #(and (pos? %) (integer? %)) coordinates)})) + :else + (unsafe-put array value coordinates) + value + *safe-sparse-operations*)) + +(defn- unsafe-get + ;; TODO: I am CERTAIN there is a more elegant solution to this. + [array coordinates] + (let [v (array (first coordinates))] + (cond + (= :data (:content array)) + v + (nil? v) + nil + :else + (apply get (cons v (rest coordinates)))))) + (defn get "Return the value in this sparse `array` at these `coordinates`." - ;; TODO: I am CERTAIN there is a more elegant solution to this. [array & coordinates] - (if - (= :data (:content array)) - (array (first coordinates)) - (apply get (cons (array (first coordinates)) (rest coordinates))))) + (cond + (unsafe-sparse-operations?) + (unsafe-get array coordinates) + (not (sparse-array? array)) + (throw (ex-info "Sparse array expected" {:array array})) + (not (every? + #(and (integer? %) (or (zero? %) (pos? %))) + coordinates)) + (throw + (ex-info + "Coordinates must be zero or positive integers" + {:array array + :coordinates coordinates + :invalid (remove #(and (pos? %) (integer? %)) coordinates)})) + (not (= (:dimensions array) (count coordinates))) + (throw + (ex-info + (str "Expected " (:dimensions array) " coordinates; found " (count coordinates)) + {:array array + :coordinates coordinates})) + :else + (unsafe-get array coordinates))) (defn merge-sparse-arrays "Return a sparse array taking values from sparse arrays `a1` and `a2`, @@ -74,7 +185,7 @@ nil (= :data (:content a1)) (merge a1 a2) - :else + (or (unsafe-sparse-operations?) (and (sparse-array? a1) (sparse-array? a2))) (reduce merge a2 diff --git a/test/sparse_array/core_test.clj b/test/sparse_array/core_test.clj index 815618f..b06f2ad 100644 --- a/test/sparse_array/core_test.clj +++ b/test/sparse_array/core_test.clj @@ -4,7 +4,94 @@ (deftest creation-and-testing (testing "Creation and testing." - (is (sparse-array? (make-sparse-array :x :y :z))))) + (is (sparse-array? (make-sparse-array :x :y :z))) + (is (sparse-array? {:dimensions 2, + :coord :x, + :content '(:y), + 3 {:dimensions 1, + :coord :y, + :content :data, + 4 "hello"}, + 4 {:dimensions 1, + :coord :y, + :content :data, + 3 "goodbye"}})) + (is (= (sparse-array? []) false)) + (is (= (sparse-array? "hello") false)) + (is (= + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :dimensions)) + false) + "All mandatory keywords must be present: dimensions") + (is (= + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :coord)) + false) + "All mandatory keywords must be present: coord") + (is (= + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :content)) + false) + "All mandatory keywords must be present: content") + (is (= + (sparse-array? {:dimensions 2, + :coord :x, + :content '(:y), + 3 {:dimensions 1, + :coord :y, + :content :data, + 4 "hello"}, + 4 {:dimensions 1, + :coord :y, + :content :data, + 3 "goodbye"} + 5 :foo}) + false) + "Can't have data in a non-data layer") + )) + +(deftest testing-safe + (testing "Checking that correct exceptions are thrown when `*safe-sparse-operations*` is true" + (binding [*safe-sparse-operations* true] + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Array must be a map" + (sparse-array? []))) + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"The value of `:dimensions` must be a positive integer, not .*" + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :dimensions))) + "All mandatory keywords must be present: dimensions") + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"The value of `:coord` must be a keyword, not .*" + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :coord)))) + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"If there are no further axes the value of `:content` must be `:data`" + (sparse-array? + (dissoc (make-sparse-array :x :y :z) :content))) + "All mandatory keywords must be present: content") + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Array must be a map" + (sparse-array? {:dimensions 2, + :coord :x, + :content '(:y), + 3 {:dimensions 1, + :coord :y, + :content :data, + 4 "hello"}, + 4 {:dimensions 1, + :coord :y, + :content :data, + 3 "goodbye"} + 5 :foo})) + "Can't have data in a non-data layer")))) (deftest put-and-get (testing "get" @@ -14,6 +101,13 @@ :content '(:y) 3 {:dimensions 1 :coord :y :content :data 4 "hello"}} actual (get array 3 4)] + (is (= actual expected))) + (let [expected nil + array {:dimensions 2, + :coord :x, + :content '(:y) + 3 {:dimensions 1 :coord :y :content :data 4 "hello"}} + actual (get array 4 3)] (is (= actual expected)))) (testing "put" (let [expected "hello" @@ -26,12 +120,24 @@ (let [expected "hello" actual (get (put (make-sparse-array :x) expected 3) 3)] - (is (= actual expected)))) + (is (= actual expected))) + (binding [*safe-sparse-operations* true] + ;; enabling error handling shouldn't make any difference + (let + [expected "hello" + actual (get (put (make-sparse-array :x) expected 3) 3)] + (is (= actual expected))))) (testing "round trip, two dimensions" (let [expected "hello" actual (get (put (make-sparse-array :x :y) expected 3 4) 3 4)] - (is (= actual expected)))) + (is (= actual expected))) + (binding [*safe-sparse-operations* true] + ;; enabling error handling shouldn't make any difference + (let + [expected "hello" + actual (get (put (make-sparse-array :x :y) expected 3 4) 3 4)] + (is (= actual expected))))) (testing "round trip, three dimensions" (let [expected "hello" @@ -41,7 +147,29 @@ (let [expected "hello" actual (get (put (make-sparse-array :p :q :r :s) expected 3 4 5 6) 3 4 5 6)] - (is (= actual expected))))) + (is (= actual expected)))) + (testing "Error handling, number of dimensions" + (binding [*safe-sparse-operations* true] + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Expected 3 coordinates; found 2" + (put (make-sparse-array :x :y :z) "hello" 3 4))) + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Expected 3 coordinates; found 4" + (put (make-sparse-array :x :y :z) "hello" 3 4 5 6))) + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Expected 3 coordinates; found 2" + (get (make-sparse-array :x :y :z) 3 4))) + (is + (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Expected 3 coordinates; found 4" + (get (make-sparse-array :x :y :z) 3 4 5 6)))))) (deftest merge-test (testing "merge, one dimension" @@ -84,9 +212,17 @@ [nil nil nil nil nil] [nil nil nil nil "hello"] [nil nil nil "goodbye" nil]] - actual (sparse-to-dense (put - (put - (make-sparse-array :x :y) - "hello" 3 4) - "goodbye" 4 3))] + actual (sparse-to-dense {:dimensions 2, + :coord :x, + :content '(:y), + 3 {:dimensions 1, + :coord :y, + :content :data, + 4 "hello"}, + 4 {:dimensions 1, + :coord :y, + :content :data, + 3 "goodbye"}})] (is (= actual expected))))) + +