Skip to content

Repository files navigation

restructure

Rewrite nested Clojure data with a declared shape.

over compiles a selector and rewrite body into code in the style of update/mapv/update-vals, but keeps the shape in one place. It visits only what you select.

API

(over selector body) ; => rewritten value
(over-> value selector body) ; => rewritten value (thread-first helper)
(over->> selector body value); => rewritten value (thread-last helper)

Examples

1) Update nested numbers without a full walk

(defdata
{:left [{:p1:q2}
{:r3}
{:s4}]
:right [{:t5}]})
(over [{_ [{_ n}]} data]
{n (cond-> n (even? n) inc)})
;; => {:left [{:p 1 :q 3} {:r 3} {:s 5}];; :right [{:t 5}]}

Sequential example:

(over [[n] [123]]
{n? (even? n)})
;; => [2]

Threading helpers:

(-> data
(over-> [{_ [{_ n}]}]
{n (cond-> n (even? n) inc)}))

Map + vector traversal with one binding.

Plain Clojure:

(update-vals data
#(mapv (fn [m]
(update-vals m (fn [n] (cond-> n (even? n) inc))))
%))

2) Filter map entries + normalize a field

(require '[clojure.string :as str])
(defusers
{:alice {:activetrue:email"ALICE@EXAMPLE.COM"}
:bob {:activefalse:email"bob@example.com"}
:cara {:activetrue:email"CARA@EXAMPLE.COM"}})
(over [{_ {:keys [active email] :as u}} users]
{u? active
email (str/lower-case email)})
;; => {:alice {:active true :email "alice@example.com"};; :cara {:active true :email "cara@example.com"}}

Map-entry traversal with a guard (u?) to drop entries.

Plain Clojure:

(->> users
(reduce-kv (fn [m id u]
(if (:active u)
(assoc m id (update u :email str/lower-case))
m))
{}))

3) Vendor-specific SKU rewrite (multiple levels)

(defdb
{:orders [{:id1:lines [{:sku"A":qty2:vendor"x"}
{:sku"B":qty1:vendor"y"}]}
{:id2:lines [{:sku"C":qty1:vendor"y"}]}]
:users {"tim" {:cart [{:sku"B":qty3:vendor"y"}]}}})
(over [{:keys [orders users]} db
[{:keys [lines]}] orders
[{line-sku :sku, vendor :vendor}] lines
{id {:keys [cart]}} users
[{cart-sku :sku cart-vendor :vendor}] cart]
{line-sku (if (= vendor "y") (str line-sku "-y") line-sku)
cart-sku (if (= cart-vendor "y") (str cart-sku "-y") cart-sku)})
;; => {:orders [{:id 1, :lines [{:sku "A", :qty 2, :vendor "x"};; {:sku "B-y", :qty 1, :vendor "y"}]};; {:id 2, :lines [{:sku "C-y", :qty 1, :vendor "y"}]}];; :users {"tim" {:cart [{:sku "B-y", :qty 3, :vendor "y"}]}}}

Plain Clojure:

(let [suffix-sku (fn [{:keys [sku vendor] :as line}]
(if (= vendor "y")
(assoc line :sku (str sku "-y"))
line))]
(-> db
(update:orders
(fn [orders]
(mapv (fn [order]
(update order :lines #(mapv suffix-sku %)))
orders)))
(update:users
(fn [users]
(update-vals users #(update % :cart (fnil (partial mapv suffix-sku) [])))))))

Selector semantics (query, not destructuring)

A selector is a vector of alternating pattern and source:

(over [pattern1 source1
pattern2 source2
...]
body-map)
  • The first source is the input expression.
  • Later sources must be previously bound symbols.
  • The selector looks like destructuring, but it is a query: it binds and traverses; it does not introduce locals the way Clojure destructuring does.

Supported patterns

  • Map-entry traversal:{kpat vpat}
    • Requires a map value; iterates entries.
    • kpat binds the key (no traversal). vpat may traverse.
    • Key traversal is intentionally not supported to keep map semantics and identity guarantees predictable.
  • Sequential traversal:[pat]
    • Requires a sequential or set value; iterates elements.
  • Sequential destructure traversal:(seq [k v & more])
    • Iterates elements and destructures each element as a vector, supporting & and :as.
  • Plain symbol:sym
    • Binds the current value.
  • Map destructuring form:{:keys [...], :strs [...], :as sym, :or {...}}
    • No traversal; binds keys and/or :as.
    • Nested destructuring inside :keys/:strs is rejected.
    • Explicit key bindings are allowed, Clojure-style: {user-id :id, org-id "org_id"}
  • Records are treated as maps and may come back as plain maps after rewrite.

Unsupported or ambiguous patterns are rejected with ex-info including :phase, :path, and :pattern.

Rewrite/body semantics

The body is a map from target symbols to expressions.

  • A key x rewrites the binding x.
  • A key x? is a guard. When false, the corresponding structural position is elided.

Elision rules:

  • Map-entry traversal: false guard removes the entry.
  • Sequential traversal: false guard removes the element.
  • Map destructure key: false guard removes that key.
  • :as binding: false guard removes the whole element at that level.

Traversal is post-order: children are processed before parents. Guards see post-child values.

Tradeoffs

Plain Clojure is already a win when:

  • A couple of update calls are clearer than any DSL.
  • update-vals + mapv is easier to scan for simple shapes.

clojure.walk/postwalk is better when:

  • You need full-tree traversal.
  • You don’t know the shape ahead of time.

over fits when:

  • You have a known shape and want to touch a few nested spots.
  • You want the traversal shape and the rewrite adjacent.
  • You want compiled code that avoids full walks and keeps structural sharing.

Non-goals

  • Not a general tree rewriting engine.
  • Not a replacement for all data transformation.
  • Not arbitrary destructuring: selectors are queries, not local binding forms.
  • No :let or computed traversal paths.

Notes

  • Identity: if no effective change occurs (value and metadata equality at all rewritten points), over returns a result that is identical? to the input.
  • See CLOS.md for the CLOS-style generic function system in this repo.

Check (format + test + lint)

clj -M:check

Release (Clojars)

Dependency coordinate:

com.github.tailrecursion/restructure {:mvn/version"1.0.0"}

Deploy:

clj -T:build deploy

License

MIT

About

Rewrite nested Clojure data with a declared shape.

Resources

Stars

21 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages