2
votes

I have three similar functions, they filter a collection of maps matching a key (column) against its value, case-insensitively.

Here is the original code I want to DRY up:

;; gets a collection filtered by a column, exact match
(defn get-collection-by-equals [collection-name column-name column-value]
 (filter #(= (string/lower-case (column-name %)) (string/lower-case column-value)) (cached-get-collection collection-name)))

;; gets a collection filtered by a column, with partial match, case-insensitive
(defn get-collection-by-like [collection-name column-name column-value]
 (filter #(string/includes? (string/lower-case (column-name %)) (string/lower-case column-value)) (cached-get-collection collection-name)))

;; gets a collection filtered by a column, which starts with given value, case-insensitive
(defn get-collection-by-starts-with [collection-name column-name column-value]
 (filter #(string/starts-with? (string/lower-case (column-name %)) (string/lower-case column-value)) (cached-get-collection collection-name)))

You can see how similar the code is, I'm just using a different matching strategy in each case, =, includes? and starts-with?.

My first attempt was as follows:

;; returns a function which does a case-insensitive match between given column and value for the given map
(defn matching-fn [match-fn column-name column-value]
  (fn [map] (match-fn (string/lower-case (column-name map)) (string/lower-case column-value))))

;; gets a collection filtered by a column, exact match
(defn get-collection-by-equals [collection-name column-name column-value]
 (filter #((matching-fn = column-name column-value) %) (cached-get-collection collection-name)))

;; gets a collection filtered by a column, with partial match, case-insensitive
(defn get-collection-by-like [collection-name column-name column-value]
 (filter #((matching-fn string/includes? column-name column-value) %) (cached-get-collection collection-name)))

;; gets a collection filtered by a column, which starts with given value, case-insensitive
(defn get-collection-by-starts-with [collection-name column-name column-value]
 (filter #((matching-fn string/starts-with? column-name column-value) %) (cached-get-collection collection-name)))

I didn't like the readability of this solution and it occurred to me that I could just pass the matching function, instead of having a function which returns a function, I came up with this:

;; gets a collection filtered by a column, using the given function, case-insensitive
(defn get-collection-by-filter [collection-name filter-fn column-name column-value]
 (filter #(filter-fn (string/lower-case (column-name %)) (string/lower-case column-value)) (cached-get-collection collection-name)))

;; gets a collection filtered by a column, exact match, case-insensitive
(defn get-collection-by-equals [collection-name column-name column-value]
  (get-collection-by collection-name = column-name column-value))

;; gets a collection filtered by a column, with partial match, case-insensitive
(defn get-collection-by-like [collection-name column-name column-value]
 (get-collection-by collection-name string/includes? column-name column-value))

;; gets a collection filtered by a column, which starts with given value, case-insensitive
(defn get-collection-by-starts-with [collection-name column-name column-value]
 (get-collection-by collection-name string/starts-with? column-name column-value))

Is this idiomatic Clojure, are there other (better) solutions?

Using a macro seems overkill.

2
in your first attempt why would you wrap the function into one more anonymous function call? #((matching-fn string/includes? column-name column-value) %) ? you could just leave (matching-fn string/includes? column-name column-value) making it more readableleetwinski
@leetwinski indeed an oversight on my part, matching-fn returns a one arity function suitable for filter anyway, no need to wrap it in another anonymous function.Kris

2 Answers

9
votes

Like a number of OO patterns, in a functional language the entire pattern boils down to "use a function with an argument". Extract everything but the small part that changes into a new function, with the part that changes as an argument to that function.

(defn collection-comparator [cmp]
  (fn [collection-name column-name column-value]
    (let [lower-value (string/lower-case column-value)]
      (filter #(cmp (string/lower-case (column-name %)) 
                    lower-value)
              (cached-get-collection collection-name)))))

(def get-collection-by-equals (collection-comparator =))
(def get-collection-by-like (collection-comparator string/includes?))
(def get-collection-by-starts-with (collection-comparator string/starts-with?))
3
votes

I'd just use your get-collection-by-filter and not wrap it any more—why create a new function for every value the parameter might take?

Aside: just make the description a documentation string, and format it a bit:

(defn get-collection-by-filter
  "gets a collection filtered by a column, using the given function,
   case-insensitive"
  [collection-name filter-fn column-name column-value]
  (filter #(filter-fn (string/lower-case (column-name %))
                      (string/lower-case column-value))
          (cached-get-collection collection-name)))