diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml
new file mode 100644
index 0000000..9a8552c
--- /dev/null
+++ b/.github/workflows/build_deploy.yml
@@ -0,0 +1,47 @@
+name: Build & Deploy
+
+on:
+ workflow_dispatch
+
+jobs:
+ build-and-deploy:
+ name: Build and Deploy
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Prepare java
+ uses: actions/setup-java@v1.4.3
+ with:
+ java-version: 21
+
+ - name: Install clojure tools
+ uses: DeLaGuardo/setup-clojure@master
+ with:
+ cli: latest
+
+ - name: Cache clojure dependencies
+ uses: actions/cache@v4.2.4
+ with:
+ path: |
+ ~/.m2/repository
+ ~/.gitlibs
+ ~/.deps.clj
+ .cpcache
+ key: cljdeps-${{ hashFiles('deps.edn') }}
+ restore-keys: cljdeps-
+
+ - name: Run CLJ tests
+ run: clojure -M:clj-test
+
+ - name: Run CLJS tests
+ run: clojure -M:cljs-test
+
+ - name: Build JAR
+ run: clojure -M:build jar
+
+ - name: Deploy to Clojars
+ run: clojure -X:deploy
+ env:
+ CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }}
+ CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }}
diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml
index 68e9530..0147a2a 100644
--- a/.github/workflows/unit_test.yml
+++ b/.github/workflows/unit_test.yml
@@ -8,7 +8,7 @@ on:
jobs:
tests:
- name: Clojure
+ name: Run Tests
runs-on: ubuntu-latest
steps:
@@ -22,6 +22,7 @@ jobs:
uses: DeLaGuardo/setup-clojure@master
with:
cli: latest
+
- name: Cache clojure dependencies
uses: actions/cache@v4.2.4
with:
@@ -33,8 +34,10 @@ jobs:
# List all files containing dependencies:
key: cljdeps-${{ hashFiles('deps.edn') }}
restore-keys: cljdeps-
- - name: Start clojure tests
+
+ - name: Run CLJ tests
run: clojure -M:clj-test
- - name: Start clojurescript tests
+
+ - name: Run CLJS tests
run: clojure -M:cljs-test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 670370d..c7e490d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,13 @@
-# 1.0.5
+# 1.0.6
+
+ADDED `print-trace` in `commando.impl.utils` — replaces `print-deep-stats` with an improved flamegraph that also shows per-node instruction keys and optional title. Add `:__title` or `"__title"` to any instruction's top level to annotate that node in the output. `print-deep-stats` is kept as a deprecated alias.
+
+ADDED named anchor navigation for `:commando/from` paths. Declare an anchor with `"__anchor"` or `:__anchor` key in any instruction map, then reference it with `"@name"` as a path segment. The resolver walks up the tree and resolves to the nearest ancestor with that anchor name — independent of nesting depth. Anchors can be combined with existing `"../"` relative navigation in a single path.
+
+UPDATED `resolve-relative-path` in `commando.impl.dependency` to accept an optional leading `instruction` argument and handle `"@anchor"` segments.
+
+UPDATED `point-target-path` in `commando.impl.dependency` to pass the instruction into `resolve-relative-path`, enabling anchor resolution.
+
ADDED new keys to `commando.impl.utils/*execute-config*`. Added hooks keys
- `:hook-execute-start` if not nil, call procedure at the start of `commando.core/execute` function.
- `:hook-execute-end` if not nil, call procedure at the end of `commando.core/execute` function.
diff --git a/README.md b/README.md
index 4cbdb69..a4d2f08 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://clojars.org/org.clojars.funkcjonariusze/commando)
[](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml)
-[](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.0.5)
+[](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.0.6)
**Commando** is a flexible Clojure library for managing, extracting, and transforming data inside nested map structures aimed to build your own Data DSL.
@@ -34,10 +34,10 @@
```clojure
;; deps.edn with git
-{org.clojars.funkcjonariusze/commando {:mvn/version "1.0.5"}}
+{org.clojars.funkcjonariusze/commando {:mvn/version "1.0.6"}}
;; leiningen
-[org.clojars.funkcjonariusze/commando "1.0.5"]
+[org.clojars.funkcjonariusze/commando "1.0.6"]
```
## Quick Start
@@ -128,24 +128,44 @@ The basic commands is found in namespace `commando.commands.builtin`. It describ
#### command-from-spec
-Allows retrieving data from the instruction by referencing it via the `:commando/from` key. An optional function can be applied via the `:=` key.
+Retrieves a value from the instruction by path. An optional `":="` key applies a function to the result.
-The `:commando/from` command supports relative paths like `"../"`, `"./"` for accessing data. The example below shows how values "1", "2", and "3" can be incremented and decremented in separate map namespaces:
+**Absolute path** — list of keys from the instruction root:
```clojure
(commando/execute
[commands-builtin/command-from-spec]
- {"incrementing 1"
- {"1" 1
- "2" {:commando/from ["../" "1"] := inc}
- "3" {:commando/from ["../" "2"] := inc}}
- "decrementing 1"
- {"1" 1
- "2" {:commando/from ["../" "1"] := dec}
- "3" {:commando/from ["../" "2"] := dec}}})
-;; =>
-;; {"incrementing 1" {"1" 1, "2" 2, "3" 3},
-;; "decrementing 1" {"1" 1, "2" 0, "3" -1}}
+ {:catalog {:price 99}
+ :ref {:commando/from [:catalog :price]}})
+;; => {:catalog {:price 99}, :ref 99}
+```
+
+**Relative path** — `"../"` goes up one level from the command's position, `"./"` stays at the current level:
+
+```clojure
+(commando/execute
+ [commands-builtin/command-from-spec]
+ {"section-a" {"price" 10 "ref" {"commando-from" ["../" "price"]}}
+ "section-b" {"price" 20 "ref" {"commando-from" ["../" "price"]}}})
+;; => {"section-a" {"price" 10, "ref" 10}
+;; "section-b" {"price" 20, "ref" 20}}
+```
+
+**Named anchors** — mark any map with `"__anchor"` or `:__anchor`, then jump to it with `"@name"` regardless of nesting depth. The resolver finds the nearest ancestor with that name, so duplicate anchor names are safe — each command resolves to its own closest one:
+
+```clojure
+(commando/execute
+ [commands-builtin/command-from-spec]
+ {:items [{:__anchor "item" :price 10 :total {:commando/from ["@item" :price]}}
+ {:__anchor "item" :price 20 :total {:commando/from ["@item" :price]}}]})
+;; => {:items [{:__anchor "item", :price 10, :total 10}
+;; {:__anchor "item", :price 20, :total 20}]}
+```
+
+Anchors and `"../"` can be combined in one path — after jumping to the anchor, navigation continues from there:
+
+```clojure
+{:commando/from ["@section" "../" :base-price]}
```
#### command-fn-spec
diff --git a/pom.xml b/pom.xml
index 307c683..d9765b1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
jar
org.clojars.funkcjonariusze
commando
- 1.0.5
+ 1.0.6
commando
@@ -42,6 +42,6 @@
https://github.com/funkcjonariusze/commando
scm:git:git://github.com/funkcjonariusze/commando.git
scm:git:ssh://git@github.com:funkcjonariusze/commando.git
- 1.0.5
+ 1.0.6
diff --git a/src/commando/impl/dependency.cljc b/src/commando/impl/dependency.cljc
index d281634..38bd503 100644
--- a/src/commando/impl/dependency.cljc
+++ b/src/commando/impl/dependency.cljc
@@ -1,5 +1,6 @@
(ns commando.impl.dependency
(:require
+ [clojure.string :as str]
[commando.impl.command-map :as cm]
[commando.impl.utils :as utils]))
@@ -60,23 +61,59 @@
(remove #(= % command-path-obj))
set)))
+(defn- find-anchor-path
+ "Walks UP from current-path looking for the nearest ancestor map
+ that has key \"__anchor\" or :__anchor equal to anchor-name.
+ Returns the path vector to that ancestor, or nil if not found."
+ [instruction current-path anchor-name]
+ (loop [path (vec current-path)]
+ (let [node (get-in instruction path)]
+ (if (and (map? node)
+ (= anchor-name (or (get node "__anchor")
+ (get node :__anchor))))
+ path
+ (when (seq path)
+ (recur (pop path)))))))
+
(defn resolve-relative-path
- "Resolves path segments with relative navigation (../ and ./) against a base path."
- [base-path segments]
- (let [{:keys [relative path]} (reduce (fn [acc segment]
- (let [{:keys [relative path]} acc]
- (cond
- (= segment "../") {:relative
- (if relative (butlast relative) (butlast base-path))
- :path path}
- (= segment "./") {:relative (if relative relative base-path)
- :path path}
- :else {:relative relative
- :path (conj path segment)})))
- {:relative nil
- :path []}
- segments)]
- (if relative (concat relative path) path)))
+ "Resolves path segments with relative navigation against a base path.
+ Returns nil if an @anchor segment cannot be resolved.
+
+ Supported segment types:
+ \"../\" - go up one level from current position
+ \"./\" - stay at current level (noop for relative base)
+ \"@anchor\" - jump to nearest ancestor with matching __anchor name
+ (requires instruction to be passed as first argument)
+ any other - descend into that key"
+ [instruction base-path segments]
+ (let [result
+ (reduce
+ (fn [acc segment]
+ (let [{:keys [relative path]} acc
+ current-base (or relative base-path)]
+ (cond
+ (= segment "../")
+ {:relative (vec (butlast current-base)) :path path}
+
+ (= segment "./")
+ {:relative (vec current-base) :path path}
+
+ (and instruction
+ (string? segment)
+ (str/starts-with? segment "@"))
+ (let [anchor-name (subs segment 1)
+ anchor-path (find-anchor-path instruction (butlast current-base) anchor-name)]
+ (if anchor-path
+ {:relative anchor-path :path path}
+ (reduced nil)))
+
+ :else
+ {:relative relative :path (conj path segment)})))
+ {:relative nil :path []}
+ segments)]
+ (when result
+ (let [{:keys [relative path]} result]
+ (if relative (vec (concat relative path)) (vec path))))))
(defn path-exists-in-instruction?
"Checks if a path exists in the instruction map."
@@ -113,9 +150,8 @@
(reduced pointed-path)))
nil
point-key-seq)]
- (->> pointed-path
- (resolve-relative-path command-path)
- vec)))
+ (or (resolve-relative-path instruction command-path pointed-path)
+ (throw-point-error command-path-obj pointed-path instruction))))
(defmethod find-command-dependencies :point
[command-path-obj instruction path-trie _type]
@@ -159,3 +195,4 @@
(find-command-dependencies command-path-obj instruction path-trie dependency-mode))))
{}
cm-list)))
+
diff --git a/src/commando/impl/utils.cljc b/src/commando/impl/utils.cljc
index f85eade..4664bf6 100644
--- a/src/commando/impl/utils.cljc
+++ b/src/commando/impl/utils.cljc
@@ -301,7 +301,7 @@ See
;; Stats Tools
;; -----------
-;; print stats of execution
+;; -- print stats --
(defn print-stats
"Prints a formatted summary of the execution stats from a status-map.
@@ -351,87 +351,126 @@ See
" " (if (= "execute" key-str) "=" (str (inc index)) )
" " key-str " " padding formatted))))))))
-
-;; print stats for all internal executions
+;; -- print-trace --
(defn ^:private flame-print-stats [stats indent]
(let [max-key-len (apply max 0 (map (comp count name first) stats))]
(doseq [[stat-key _ formatted] stats]
(let [key-str (name stat-key)
padding (str/join "" (repeat (- max-key-len (count key-str)) " "))]
- (println (str indent
- "" key-str " " padding formatted))))))
+ (println (str indent key-str " " padding formatted))))))
+
+(defn ^:private flame-print-title [title indent]
+ (println (str indent "title: " title)))
+
+(defn ^:private flame-print-keys [instruction-keys indent]
+ (let [max-keys-per-line 5
+ ks (keep (fn [k]
+ (when-not (contains? #{"__title" :__title} k) (str k)))
+ instruction-keys)
+ [first-line & rest-lines] (partition-all max-keys-per-line ks)]
+ (when first-line
+ (println (str indent "keys: " (str/join ", " first-line)))
+ (doseq [line rest-lines]
+ (println (str indent " " (str/join ", " line)))))))
(defn ^:private flame-print [data & [indent]]
(let [indent (or indent "")]
(doseq [[k v] data]
(println (str indent "———" k))
+ (when (:instruction-title v)
+ (flame-print-title (:instruction-title v) (str indent " |")))
+ (when (:instruction-keys v)
+ (flame-print-keys (:instruction-keys v) (str indent " |")))
(when (:stats v)
(flame-print-stats (:stats v) (str indent " |")))
- (doseq [[child-k child-v] v
+ (doseq [[_child-k child-v] v
:when (map? child-v)]
- (when (not= child-k :stats)
- (flame-print {child-k child-v} (str indent " :")))))))
+ (flame-print {_child-k child-v} (str indent " :"))))))
(defn ^:private flamegraph [data]
(println "Printing Flamegraph for executes:")
(flame-print data))
-(defn print-deep-stats
- "Function print the flamegraph of internals execution.
+(defn print-trace
+ "Wraps an execution function and prints a flamegraph of all nested
+ `commando/execute` calls with timing stats, instruction keys, and
+ optional titles.
- Example
- (defmethod commando.commands.builtin/command-mutation :rand-n
- [_macro-type {:keys [v]}]
- (:instruction
- (commando.core/execute
- [commando.commands.builtin/command-apply-spec]
- {:commando/apply v
- := (fn [n] (rand-int n))})))
-
- (defmethod commando.commands.builtin/command-macro :sum-n
- [_macro-type {:keys [v]}]
- {:commando/fn (fn [& v-coll] (apply + v-coll))
- :args [v
- {:commando/mutation :rand-n
- :v 200}]})
-
- (print-deep-stats
- #(commando.core/execute
- [commando.commands.builtin/command-fn-spec
- commando.commands.builtin/command-from-spec
- commando.commands.builtin/command-macro-spec
- commando.commands.builtin/command-mutation-spec]
- {:value {:commando/mutation :rand-n :v 200}
- :result {:commando/macro :sum-n
- :v {:commando/from [:value]}}}))
-
- OUT=>
- Printing Flamegraph for executes:
- ———59f2f084-28f6-44fd-bf52-1e561187a2e5
- |execute-commands! 1.123606ms
- |execute 1.92817ms
- :———e4e245ca-194a-43c6-9d7e-9225e0424c46
- : |execute-commands! 66.344µs
- : |execute 287.669µs
- :———77de8840-c9d3-4baa-b0d6-8a9806ede29d
- : |execute-commands! 372.566µs
- : |execute 721.636µs
- : :———0aefeb8e-04b2-4e77-b526-6969c08f9bb5
- : : |execute-commands! 39.221µs
- : : |execute 264.591µs
+ Add `:__title` or `\"__title\"` to the top level of any instruction
+ to annotate that node in the flamegraph output.
+
+ Takes a zero-argument function that calls `commando/execute` and
+ returns its result unchanged.
+
+ Example
+ (defmethod commando.commands.builtin/command-mutation :rand-n
+ [_macro-type {:keys [v]}]
+ (:instruction
+ (commando.core/execute
+ [commando.commands.builtin/command-apply-spec]
+ {:commando/apply v
+ := (fn [n] (rand-int n))})))
+
+ (defmethod commando.commands.builtin/command-macro :sum-n
+ [_macro-type {:keys [v]}]
+ {:__title \"sum random\"
+ :commando/fn (fn [& v-coll] (apply + v-coll))
+ :args [v
+ {:commando/mutation :rand-n
+ :v 200}]})
+
+ (print-trace
+ #(commando.core/execute
+ [commando.commands.builtin/command-fn-spec
+ commando.commands.builtin/command-from-spec
+ commando.commands.builtin/command-macro-spec
+ commando.commands.builtin/command-mutation-spec]
+ {:value {:commando/mutation :rand-n :v 200}
+ :result {:commando/macro :sum-n
+ :v {:commando/from [:value]}}}))
+
+ OUT=>
+ Printing Flamegraph for executes:
+ ———59f2f084-28f6-44fd-bf52-1e561187a2e5
+ |keys: :value, :result
+ |execute-commands! 1.123606ms
+ |execute 1.92817ms
+ :———e4e245ca-194a-43c6-9d7e-9225e0424c46
+ : |execute-commands! 66.344µs
+ : |execute 287.669µs
+ :———77de8840-c9d3-4baa-b0d6-8a9806ede29d
+ : |title: sum random
+ : |keys: :__title, :commando/fn, :args
+ : |execute-commands! 372.566µs
+ : |execute 721.636µs
+ : :———0aefeb8e-04b2-4e77-b526-6969c08f9bb5
+ : : |execute-commands! 39.221µs
+ : : |execute 264.591µs
"
[execution-fn]
(let [stats-state (atom {})
result
(binding [*execute-config*
- {;; :debug-result true
+ {:error-data-string false
+ :hook-execute-start
+ (fn [e]
+ (swap! stats-state
+ (fn [s]
+ (update-in s (:stack *execute-internals*)
+ #(merge % {:instruction-title
+ (when (map? (:instruction e))
+ (or (get (:instruction e) "__title")
+ (get (:instruction e) :__title)))})))))
:hook-execute-end
(fn [e]
(swap! stats-state
(fn [s]
(update-in s (:stack *execute-internals*)
- #(merge % {:stats (:stats e)})))))}]
+ #(merge % {:stats (:stats e)
+ :instruction-keys (when (map? (:instruction e))
+ (vec (keys (:instruction e))))})))))}]
(execution-fn))]
(flamegraph @stats-state)
result))
+
diff --git a/test/perf/commando/core_perf_test.clj b/test/perf/commando/core_perf_test.clj
index c66c46e..f451f5c 100644
--- a/test/perf/commando/core_perf_test.clj
+++ b/test/perf/commando/core_perf_test.clj
@@ -221,7 +221,8 @@
:instruction-A (commando.commands.query-dsl/resolve-instruction
"error"
- {:commando/fn (fn [& [y]]
+ {:__title "Resolve instruction-A"
+ :commando/fn (fn [& [y]]
{:a
{:b {:c y}
:d {:c (inc y)
@@ -231,11 +232,13 @@
:query-A (commando.commands.query-dsl/resolve-instruction-qe
"error"
- {:commando/resolve :query-A
+ {:__title "Resolve query-A"
+ :commando/resolve :query-A
:x 1})
:query-B (commando.commands.query-dsl/resolve-instruction-qe
"error"
- {:commando/resolve :query-B
+ {:__title "Resolve query-B"
+ :commando/resolve :query-B
:x 1})}
(commando.commands.query-dsl/->query-run QueryExpression))))
@@ -243,12 +246,13 @@
(println "\n===================Benchmark=====================")
(println "Run commando/execute in depth with using queryDSL")
(println "=================================================")
- (commando-utils/print-deep-stats
+ (commando-utils/print-trace
#(commando.core/execute
[commando.commands.query-dsl/command-resolve-spec
commando.commands.builtin/command-from-spec
commando.commands.builtin/command-fn-spec]
- {:commando/resolve :query-A
+ {:__title "TOPLEVEL"
+ :commando/resolve :query-A
:x 1
:QueryExpression
[{:map
diff --git a/test/unit/commando/commands/builtin_test.cljc b/test/unit/commando/commands/builtin_test.cljc
index 28d5bce..f69e283 100644
--- a/test/unit/commando/commands/builtin_test.cljc
+++ b/test/unit/commando/commands/builtin_test.cljc
@@ -186,7 +186,67 @@
)
"Uncorrect commando/from ':=' applicator. CLJS Supports: fn/keyword")))
;; -------------------
+ (testing "Anchor navigation"
+ (is (= {:section {:__anchor "root" :price 10 :ref 10}}
+ (:instruction
+ (commando/execute [command-builtin/command-from-spec]
+ {:section {:__anchor "root"
+ :price 10
+ :ref {:commando/from ["@root" :price]}}})))
+ "Basic anchor: command resolves to nearest ancestor with __anchor = 'root'")
+ (is (= {:items [{:__anchor "item" :price 10 :ref 10}
+ {:__anchor "item" :price 20 :ref 20}]}
+ (:instruction
+ (commando/execute [command-builtin/command-from-spec]
+ {:items [{:__anchor "item"
+ :price 10
+ :ref {:commando/from ["@item" :price]}}
+ {:__anchor "item"
+ :price 20
+ :ref {:commando/from ["@item" :price]}}]})))
+ "Duplicate anchors: each command finds its own nearest ancestor")
+ (is (= {:catalog {:__anchor "root"
+ :base-price 5
+ :section {:__anchor "section" :price 10 :sibling-price 5}}}
+ (:instruction
+ (commando/execute [command-builtin/command-from-spec]
+ {:catalog {:__anchor "root"
+ :base-price 5
+ :section {:__anchor "section"
+ :price 10
+ :sibling-price {:commando/from ["@section" "../" :base-price]}}}})))
+ "Anchor combined with ../: jump to anchor then go up one level")
+ (is (= {:root-1
+ {:__anchor "root-1"
+ :price 5
+ :root-2
+ {:__anchor "root-2"
+ :price 10
+ :root-3
+ {:price-1 5
+ :price-2 10}}}}
+ (:instruction
+ (commando/execute [command-builtin/command-from-spec]
+ {:root-1
+ {:__anchor "root-1"
+ :price 5
+ :root-2
+ {:__anchor "root-2"
+ :price 10
+ :root-3
+ {:price-1 {:commando/from ["@root-1" :price]}
+ :price-2 {:commando/from ["@root-2" :price]}}}}})))
+ "Different level anchor combined in one level"))
+ ;; -------------------
(testing "Failure test cases"
+ (is
+ (helpers/status-map-contains-error?
+ (commando/execute [command-builtin/command-from-spec]
+ {:ref {:commando/from ["@nonexistent" :value]}})
+ {:message "Commando. Point dependency failed: key ':commando/from' references non-existent path [\"@nonexistent\" :value]",
+ :path [:ref],
+ :command {:commando/from ["@nonexistent" :value]}})
+ "Anchor not found: should produce error with :anchor key in data")
(is
(helpers/status-map-contains-error?
(commando/execute [command-builtin/command-from-spec]