From d5708a04fe994ed0e88da93696d9c76ca88b41b9 Mon Sep 17 00:00:00 2001 From: SerhiiRI Date: Sun, 8 Mar 2026 01:40:33 +0200 Subject: [PATCH 1/2] adding new command command-context-spec what allow to access values outside of the instruction in elegant way --- CHANGELOG.md | 2 + README.md | 29 +++++ src/commando/commands/builtin.cljc | 102 ++++++++++++++++++ test/unit/commando/commands/builtin_test.cljc | 72 ++++++++++++- 4 files changed, 202 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff9132..5db5fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 1.0.6 +ADDED `command-context-spec` in `commando.commands.builtin`. A new command type `:commando/context` (string form: `"commando-context"`) that injects external reference data into instructions via closure. Call `(command-context-spec ctx-map)` to create a CommandMapSpec. Resolves with `{:mode :none}` — before all other commands, so `:commando/from` and `:commando/fn` can depend on context results. Supports `:=` transform, `:default` fallback for missing paths, and returns `nil` when path is not found without `:default`. Full malli validation for both keyword and string key forms. + REDESIGNED Registry. Registry is now a map-based structure (`{:type spec, ...}`) instead of a plain vector. `registry-create` accepts both formats: ```clojure ;; vector — order = scan priority diff --git a/README.md b/README.md index 6152ef1..7afd840 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [command-apply-spec](#command-apply-spec) - [command-mutation-spec](#command-mutation-spec) - [command-macro-spec](#command-macro-spec) + - [command-context-spec](#command-context-spec) - [Adding New Commands](#adding-new-commands) - [Status-Map and Internals](#status-map-and-internals) - [Configuring Execution Behavior](#configuring-execution-behavior) @@ -305,6 +306,34 @@ The defmethod should return a Instruction. Commando will then treat that returne Use these macro handlers to hide repeated command structure and keep your instructions shorter and easier to read. +#### command-context-spec + +Injects external reference data (dictionaries, config, feature flags) into instructions without duplicating it. Unlike other commands, `command-context-spec` is a **function** — call it with your context map to get a CommandMapSpec. The data is captured via closure and resolves before other commands (`{:mode :none}`), so `:commando/from` and `:commando/fn` can reference context results through the standard dependency mechanism. + +```clojure +(def game-config + {:heroes {"warrior" {:hp 120 :damage 15} + "mage" {:hp 80 :damage 25}} + :buffs {:fire-sword 1.5 :shield 2.0} + :settings {:difficulty "hard" :max-level 60}}) + +(commando/execute + [(commands-builtin/command-context-spec game-config) + commands-builtin/command-from-spec + commands-builtin/command-fn-spec] + {:warrior {:commando/context [:heroes "warrior"]} + :fire-bonus {:commando/context [:buffs :fire-sword]} + :hit-damage {:commando/fn * :args [{:commando/from [:warrior] := :damage} + {:commando/from [:fire-bonus]}]} + :arena-name {:commando/context [:arenas :default] :default "Colosseum"}}) +;; => {:warrior {:hp 120 :damage 15} +;; :fire-bonus 1.5 +;; :hit-damage 22.5 +;; :arena-name "Colosseum"} +``` + +Missing path returns `nil`; use `:default` for an explicit fallback. The `:=` key applies a transform to the resolved value, same as in `:commando/from`. String-key form (`"commando-context"`, `"default"`, `"="`) is available for JSON compatibility. + ### Adding new commands As you start using commando, you will start writing your own command specs to match your data needs. diff --git a/src/commando/commands/builtin.cljc b/src/commando/commands/builtin.cljc index e1d3d25..ddce756 100644 --- a/src/commando/commands/builtin.cljc +++ b/src/commando/commands/builtin.cljc @@ -200,6 +200,108 @@ :point-key [:commando/from "commando-from"]}}) +;; ====================== +;; Context +;; ====================== + +(defn command-context-spec + "Creates a CommandMapSpec that resolves references to external context data + captured via closure. Context is immutable per registry and resolves before + other commands (dependency mode :none). + + ctx — a map with arbitrary structure to look up values from. + + Instruction usage (keyword keys): + {:commando/context [:path :to :data]} + {:commando/context [:path :to :data] := :key} + {:commando/context [:path :to :data] :default 0} + + Instruction usage (string keys, JSON-compatible): + {\"commando-context\" [\"path\" \"to\" \"data\"]} + {\"commando-context\" [\"path\" \"to\" \"data\"] \"=\" \"key\"} + {\"commando-context\" [\"path\" \"to\" \"data\"] \"default\" 0} + + Parameters: + :commando/context — sequential path for get-in on ctx + := — optional transform function/keyword applied to the resolved value + :default — optional fallback value when path is not found in ctx. + Without :default, missing path throws an error. + + Example: + (def my-ctx {:rates {:vat 0.20} :codes {\"01\" \"Kyiv\"}}) + + (:instruction + (commando/execute + [(command-context-spec my-ctx) + command-from-spec command-fn-spec] + {:vat {:commando/context [:rates :vat]} + :city {:commando/context [:codes \"01\"]} + :missing {:commando/context [:nonexistent] :default \"N/A\"} + :total {:commando/fn * :args [{:commando/from [:vat]} 1000]}})) + ;; => {:vat 0.20 :city \"Kyiv\" :missing \"N/A\" :total 200.0} + + See Also + `commando.core/execute` + `commando.commands.builtin/command-from-spec`" + [ctx] + {:pre [(map? ctx)]} + (let [kw-key :commando/context + str-key "commando-context"] + {:type kw-key + :recognize-fn #(and (map? %) + (or (contains? % kw-key) + (contains? % str-key))) + :validate-params-fn + (fn [m] + (let [m-explain + (cond + (and (contains? m kw-key) (contains? m str-key)) + "The keyword :commando/context and the string \"commando-context\" cannot be used simultaneously in one command." + (contains? m kw-key) + (malli-error/humanize + (malli/explain + [:map + [kw-key [:sequential {:error/message "commando/context should be a sequential path: [:some :key]"} + [:or :string :keyword :int]]] + [:= {:optional true} [:or utils/ResolvableFn :string]] + [:default {:optional true} :any]] + m)) + (contains? m str-key) + (malli-error/humanize + (malli/explain + [:map + [str-key [:sequential {:error/message "commando-context should be a sequential path: [\"some\" \"key\"]"} + [:or :string :keyword :int]]] + ["=" {:optional true} [:string {:min 1}]] + ["default" {:optional true} :any]] + m)))] + (if m-explain m-explain true))) + :apply + (fn [_instruction _command-path-obj command-map] + (let [[path m-= m-default has-default?] + (cond + (contains? command-map kw-key) + [(get command-map kw-key) + (:= command-map) + (:default command-map) + (contains? command-map :default)] + (contains? command-map str-key) + [(get command-map str-key) + (get command-map "=") + (get command-map "default") + (or (contains? command-map "default") + (contains? command-map :default))]) + result (get-in ctx path ::not-found)] + (if (= result ::not-found) + (if has-default? m-default nil) + (if m-= + (if (string? m-=) + (get result m-=) + (let [resolved (utils/resolve-fn m-=)] + (resolved result))) + result)))) + :dependencies {:mode :none}})) + ;; ====================== ;; Mutation ;; ====================== diff --git a/test/unit/commando/commands/builtin_test.cljc b/test/unit/commando/commands/builtin_test.cljc index 7d3afb2..8ab2c8e 100644 --- a/test/unit/commando/commands/builtin_test.cljc +++ b/test/unit/commando/commands/builtin_test.cljc @@ -105,9 +105,6 @@ ;; FROM-SPEC ;; =========================== - - - (deftest command-from-spec ;; ------------------- (testing "Successfull test cases" @@ -315,6 +312,75 @@ :path [:a], :value {:commando/from [:v], := ["BROKEN"]}}))) "Waiting on error, ':validate-params-fn' for commando/from. Wrong type for optional ':=' applicator"))) +;; =========================== +;; CONTEXT-SPEC +;; =========================== + +(def test-ctx + {:colors {:red "#FF0000" :blue "#0000FF"} + :numbers [10 20 30] + :nested {:a {:b {:c 42}}}}) + +(deftest command-context-spec-success + (let [ctx-spec (command-builtin/command-context-spec test-ctx)] + (testing "Successfull test cases" + (is (= {:color "#FF0000" :number 10 :deep 42} + (:instruction + (commando/execute {:commando/context ctx-spec} + {:color {:commando/context [:colors :red]} + :number {:commando/context [:numbers 0]} + :deep {:commando/context [:nested :a :b :c]}}))) + "Should resolve keyword path in context with varios deepnest") + + (is (= {:val "#0000FF" :count 2} + (:instruction + (commando/execute {:commando/context ctx-spec} + {:count {:commando/context [:colors] := count} + :val {:commando/context [:colors] := :blue}}))) + "Should apply := fn to resolved value") + + (is (= {:val-default "fallback" :val-nil nil} + (:instruction + (commando/execute {:commando/context ctx-spec} + {:val-default {:commando/context [:nonexistent] :default "fallback"} + :val-nil {:commando/context [:nonexistent] :default nil}}))) + "Should return :default value when path not found, in other way 'nil' value without exception ") + + (let [str-ctx {"lang" {"ua" "Ukrainian" "en" "English"}} + str-spec (command-builtin/command-context-spec str-ctx)] + (is (= {"val" "Ukrainian" "val-default" "none" "val-=" "English"} + (:instruction + (commando/execute {:commando/context str-spec} + {"val" {"commando-context" ["lang" "ua"]} + "val-=" {"commando-context" ["lang"] "=" "en"} + "val-default" {"commando-context" ["missing"] "default" "none"}}))) + "String keys test"))) + (testing "Failure test cases" + (is (helpers/status-map-contains-error? + (binding [commando-utils/*execute-config* + {:debug-result false :error-data-string false}] + (commando/execute {:commando/context ctx-spec} + {:val {:commando/context "NOT-A-PATH"}})) + (fn [error] + (= (-> error :error :data) + {:command-type :commando/context + :reason {:commando/context ["commando/context should be a sequential path: [:some :key]"]} + :path [:val] + :value {:commando/context "NOT-A-PATH"}}))) + "Should fail validation when path is not sequential") + (is (helpers/status-map-contains-error? + (binding [commando-utils/*execute-config* + {:debug-result false :error-data-string false}] + (commando/execute {:commando/context ctx-spec} + {:val {:commando/context [:colors] "commando-context" ["colors"]}})) + (fn [error] + (= (-> error :error :data) + {:command-type :commando/context + :reason "The keyword :commando/context and the string \"commando-context\" cannot be used simultaneously in one command." + :path [:val] + :value {:commando/context [:colors] "commando-context" ["colors"]}}))) + "Should not allow both keyword and string form")))) + ;; =========================== ;; MUTATION-SPEC From ca7d56acb3690dbfc53d8fe79826c2eac6028bd5 Mon Sep 17 00:00:00 2001 From: SerhiiRI Date: Sun, 8 Mar 2026 10:32:56 +0200 Subject: [PATCH 2/2] Refactoring core steps. Created function core-step-safe which do measure of step execution and handle warning text about the skipping procedure --- src/commando/core.cljc | 194 ++++++++++++------------------ src/commando/impl/status_map.cljc | 21 ++++ 2 files changed, 97 insertions(+), 118 deletions(-) diff --git a/src/commando/core.cljc b/src/commando/core.cljc index b51fc8e..6c66794 100644 --- a/src/commando/core.cljc +++ b/src/commando/core.cljc @@ -86,144 +86,102 @@ [built-registry command-map-spec-type] (registry/registry-dissoc built-registry command-map-spec-type)) -;; -- Execute Flow -- +;; -- Core Steps -- (defn ^:private use-registry [status-map registry] - (let [start-time (utils/now) - result (case (:status status-map) - :failed (-> status-map - (smap/status-map-handle-warning {:message "Skip step with registry check"})) - :ok (try (-> status-map - (assoc :registry - (-> - (registry-create registry) - (registry/enreach-runtime-registry)))) - (catch #?(:clj Exception - :cljs :default) - e - (-> status-map - (smap/status-map-handle-error {:message "Invalid registry specification" - :error (utils/serialize-exception e)})))))] - (smap/status-map-add-measurement result "use-registry" start-time (utils/now)))) + (smap/core-step-safe status-map "use-registry" + (fn [sm] + (assoc sm :registry + (-> (registry-create registry) + (registry/enreach-runtime-registry)))))) (defn ^:private find-commands - "Searches the instruction map for commands defined in the registry. - Returns input map with assoced :internal/cm-list with vector of CommandMapPath objects - - Commands are recognized by evaluating each registry command's :recognize-fn - - Skips processing if status is :failed. - Returns :failed status if params validation of the command fail - - Commands are validated using their :validate-params-fn if present" - [{:keys [instruction registry] - :as status-map}] - (let [start-time (utils/now) - result (case (:status status-map) - :failed (-> status-map - (smap/status-map-handle-warning {:message "Skipping search for commands due to :failed status"})) - :ok (try (-> status-map - (assoc :internal/cm-list (finding-commands/find-commands instruction registry)) - (smap/status-map-handle-success {:message "Commands were successfully collected"})) - (catch #?(:clj Exception - :cljs :default) - e - (-> status-map - (smap/status-map-handle-error {:message - "Failed during commands search in instruction. See error for details." - :error (utils/serialize-exception e)})))) - (smap/status-map-undefined-status status-map))] - (smap/status-map-add-measurement result "find-commands" start-time (utils/now)))) + [{:keys [instruction registry] :as status-map}] + (smap/core-step-safe status-map "find-commands" + (fn [sm] + (-> sm + (assoc :internal/cm-list (finding-commands/find-commands instruction registry)) + (smap/status-map-handle-success {:message "Commands were successfully collected"}))))) (defn ^:private build-deps-tree - "Builds a dependency tree by resolving ':commando/from' references in commands. - Returns status map with :internal/cm-dependency containing mapping from commands to their dependencies." - [{:keys [instruction] - :internal/keys [cm-list] - :as status-map}] - (let [start-time (utils/now) - result (case (:status status-map) - :failed (-> status-map - (smap/status-map-handle-warning {:message "Skipping dependency resolution due to :failed status"})) - :ok (try (-> status-map - (assoc :internal/cm-dependency (deps/build-dependency-graph instruction cm-list)) - (smap/status-map-handle-success {:message "Dependency map was successfully built"})) - (catch #?(:clj clojure.lang.ExceptionInfo - :cljs :default) - e - (-> status-map - (smap/status-map-handle-error (ex-data e))))) - (smap/status-map-undefined-status status-map))] - (smap/status-map-add-measurement result "build-deps-tree" start-time (utils/now)))) + [{:keys [instruction] :internal/keys [cm-list] :as status-map}] + (smap/core-step-safe status-map "build-deps-tree" + (fn [sm] + (try + (-> sm + (assoc :internal/cm-dependency (deps/build-dependency-graph instruction cm-list)) + (smap/status-map-handle-success {:message "Dependency map was successfully built"})) + (catch #?(:clj clojure.lang.ExceptionInfo :cljs :default) e + (smap/status-map-handle-error sm (ex-data e))))))) (defn ^:private sort-commands-by-deps [status-map] - (let [start-time (utils/now) - result (case (:status status-map) - :failed (-> status-map - (smap/status-map-handle-warning {:message (str utils/exception-message-header - "sort-entities-by-deps. Skipping mandatory step")})) - :ok (let [sort-result (graph/topological-sort (:internal/cm-dependency status-map)) - status-map (assoc status-map :internal/cm-running-order (vec (:sorted sort-result)))] - (if (not-empty (:cyclic sort-result)) - (smap/status-map-handle-error status-map - {:message (str utils/exception-message-header - "sort-entities-by-deps. Detected cyclic dependency") - :cyclic (:cyclic sort-result)}) - (smap/status-map-handle-success - status-map - {:message (str utils/exception-message-header - "sort-entities-by-deps. Entities was sorted and prepare for evaluating")}))) - (smap/status-map-undefined-status status-map))] - (smap/status-map-add-measurement result "sort-commands-by-deps" start-time (utils/now)))) + (smap/core-step-safe status-map "sort-commands-by-deps" + (fn [sm] + (let [{:keys [sorted cyclic]} (graph/topological-sort (:internal/cm-dependency sm)) + sm (assoc sm :internal/cm-running-order (vec sorted))] + (if (not-empty cyclic) + (smap/status-map-handle-error sm + {:message (str utils/exception-message-header + "sort-entities-by-deps. Detected cyclic dependency") + :cyclic cyclic}) + (smap/status-map-handle-success sm + {:message (str utils/exception-message-header + "sort-entities-by-deps. Entities was sorted and prepare for evaluating")})))))) (defn ^:private execute-commands! - "Execute commands based on `:internal/cm-running-order`, transforming the instruction map. - - This function orchestrates command execution by delegating to individual command specs." [{:keys [instruction registry] :internal/keys [cm-running-order] :as status-map}] - (let [start-time (utils/now) - result (binding [utils/*command-map-spec-registry* registry] - (cond - (smap/failed? status-map) - (smap/status-map-handle-warning status-map {:message "Skipping command evaluation due to failed status"}) - (empty? cm-running-order) (smap/status-map-handle-success status-map {:message "No commands to execute"}) - :else (let [[updated-instruction error-info] (executing/execute-commands instruction cm-running-order)] - (if error-info - (-> status-map - (assoc :instruction updated-instruction) - (smap/status-map-handle-error {:message "Command execution failed during evaluation" - :error (utils/serialize-exception (:original-error error-info)) - :command-path (:command-path error-info) - :command-type (:command-type error-info)})) - (-> status-map - (assoc :instruction updated-instruction) - (smap/status-map-handle-success {:message "All commands executed successfully"}))))))] - (smap/status-map-add-measurement result "execute-commands!" start-time (utils/now)))) + (smap/core-step-safe status-map "execute-commands!" + (fn [sm] + (binding [utils/*command-map-spec-registry* registry] + (if (empty? cm-running-order) + (smap/status-map-handle-success sm {:message "No commands to execute"}) + (let [[updated-instruction error-info] (executing/execute-commands instruction cm-running-order)] + (if error-info + (-> sm + (assoc :instruction updated-instruction) + (smap/status-map-handle-error {:message "Command execution failed during evaluation" + :error (utils/serialize-exception (:original-error error-info)) + :command-path (:command-path error-info) + :command-type (:command-type error-info)})) + (-> sm + (assoc :instruction updated-instruction) + (smap/status-map-handle-success {:message "All commands executed successfully"}))))))))) + +(defn ^:private prepare-execution-status-map [status-map] + (if (smap/failed? status-map) + status-map + (-> status-map + (update :internal/cm-running-order registry/remove-runtime-registry-commands-from-command-list) + (update :registry registry/reset-runtime-registry)))) + +(defn ^:private crop-final-status-map [status-map] + (let [debug? (:debug-result (utils/execute-config))] + (cond-> status-map + (false? debug?) (dissoc :internal/cm-running-order) + (false? debug?) (dissoc :registry)))) + +;; -- Execute -- (defn execute [registry instruction] {:pre [(or (map? registry) (vector? registry))]} (binding [utils/*execute-internals* (utils/-execute-internals-push (str (random-uuid)))] - (let [start-time (utils/now) - status-map (-> (smap/status-map-pure {:instruction instruction}) - (utils/hook-process (:hook-execute-start (utils/execute-config))) - (use-registry registry) - (find-commands) - (build-deps-tree) - (sort-commands-by-deps))] - (let [status-map-ready - (case (:status status-map) - :failed status-map - :ok (-> status-map - (update :internal/cm-running-order registry/remove-runtime-registry-commands-from-command-list) - (update :registry registry/reset-runtime-registry)))] - (cond-> (execute-commands! (assoc status-map-ready :instruction instruction)) - (false? (:debug-result (utils/execute-config))) (dissoc :internal/cm-running-order) - (false? (:debug-result (utils/execute-config))) (dissoc :registry) - :always (smap/status-map-add-measurement "execute" start-time (utils/now)) - :always (utils/hook-process (:hook-execute-end (utils/execute-config)))))))) + (let [start-time (utils/now)] + (-> (smap/status-map-pure {:instruction instruction}) + (utils/hook-process (:hook-execute-start (utils/execute-config))) + (use-registry registry) + (find-commands) + (build-deps-tree) + (sort-commands-by-deps) + (prepare-execution-status-map) + (execute-commands!) + (smap/status-map-add-measurement "execute" start-time (utils/now)) + (utils/hook-process (:hook-execute-end (utils/execute-config))))))) (defn failed? [status-map] (smap/failed? status-map)) (defn ok? [status-map] (smap/ok? status-map)) + diff --git a/src/commando/impl/status_map.cljc b/src/commando/impl/status_map.cljc index 9270366..7bcc77c 100644 --- a/src/commando/impl/status_map.cljc +++ b/src/commando/impl/status_map.cljc @@ -54,4 +54,25 @@ (defn ok? [status-map] (= (:status status-map) :ok)) +;; -------------------- +;; Core Pipeline Helper +;; -------------------- + +(defn core-step-safe + "Executes a pipeline step with skip-on-failure, error safety net, and timing. + step-fn receives the status-map and must return an updated status-map. + If status is :failed, the step is skipped with a warning. + Uncaught exceptions are converted to :failed status." + [status-map step-name step-fn] + (let [start (utils/now) + result (if (failed? status-map) + (status-map-handle-warning status-map + {:message (str "Skipping " step-name)}) + (try + (step-fn status-map) + (catch #?(:clj Exception :cljs :default) e + (status-map-handle-error status-map + {:message (str "Failed at " step-name ". See error for details.") + :error (utils/serialize-exception e)}))))] + (status-map-add-measurement result step-name start (utils/now))))