diff --git a/CHANGELOG.md b/CHANGELOG.md index 675e771..bd431fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,86 @@ +# 1.2.0 + +## Breaking Changes + +**BREAKING** `execute` accepts an optional third argument — a config map that replaces the old `binding`-based `*execute-config*` dynamic var for passing options. Users no longer need `(binding [utils/*execute-config* {...}] (execute ...))`. +- `(execute registry instruction)` — unchanged +- `(execute registry instruction {:error-data-string false})` — new opts map +- Config keys: `:error-data-string`, `:hook-execute-start`, `:hook-execute-end` +- Config is inherited by nested execute calls; inner calls can override specific keys. + +**BREAKING** `execute-trace` signature changed from `(execute-trace exec-fn)` to `(execute-trace registry instruction)` / `(execute-trace registry instruction opts)`. No longer requires wrapping in a zero-arg function. + +**BREAKING** Removed `:debug-result` configuration option. Internal structures (`:internal/cm-list`, `:internal/cm-dependency`, `:internal/cm-running-order`, `:internal/path-trie`, `:internal/cm-results`, `:internal/original-instruction`, `:registry`) are now **always** retained in the status-map. If you relied on stripped status-maps, dissoc internal keys yourself. + +**BREAKING** Removed dependency modes `:all-inside-recur` and `:point-and-all-inside-recur`. Supported modes: `:point`, `:all-inside`, `:none`. + +**BREAKING** Removed `print-stats`, `print-trace` (`print-deep-stats`) from `commando.impl.utils`. Replaced by `commando.debug` namespace. + +**BREAKING** `registry_test.clj` renamed to `registry_test.cljc` (cross-platform). + +## Added + +ADDED `commando.debug` namespace — dedicated module for debug visualization: +- `execute-debug` — execute and visualize in one of six display modes: `:tree`, `:table`, `:graph`, `:stats`, `:instr-before` / `:instr-after`. Supports combining multiple modes via vector. +- `execute-trace` — trace all nested `commando/execute` calls with timing and structure. + +ADDED `commando.impl.pathtrie` module — trie data structure for O(depth) command lookup by path. Built during the same traversal pass as command discovery, eliminating extra passes over the instruction tree. + +ADDED new status-map keys always present after execution: +- `:internal/cm-results` — map `{CommandMapPath -> resolved-value}` with result of each command's `:apply` function. +- `:internal/path-trie` — nested trie for efficient command lookup by path. +- `:internal/original-instruction` — the original instruction before command evaluation. + +ADDED `structural-command-type?` and `structural-command-types` in `commando.impl.registry` for detecting internal structural commands (`:instruction/_value`, `:instruction/_map`, `:instruction/_vec`). + +## Performance + +OPTIMIZED `find-commands` BFS traversal in `commando.impl.finding_commands`: +- Replaced vector-based queue with transient index-based queue (O(N) vs O(N^2) from subvec+into). +- Transient set for found-commands accumulation. +- Direct `enqueue-coll-children!` / `enqueue-command-children!` instead of intermediate mapv vectors. +- Path-trie built in the same pass — no separate traversal needed. + +OPTIMIZED `execute-commands` in `commando.impl.executing`: +- Transient results map avoids N persistent map copies during execution loop. +- Index-based loop with `nth` instead of `rest`/`first` on remaining commands. + +OPTIMIZED `build-dependency-graph` in `commando.impl.dependency`: +- Accepts pre-built path-trie from `find-commands` instead of rebuilding it. +- Transient accumulation for forward dependency map. +- `:all-inside` dependency resolution uses `reduce-kv` on trie subtree instead of dissoc+vals+keep+set chain. + +OPTIMIZED `topological-sort` in `commando.impl.graph`: +- Transient maps during in-degree computation. +- Transient queue for sorted result accumulation. + +OPTIMIZED `CommandMapPath` in `commando.impl.command_map`: +- Hash computed once at construction time and cached. +- `coll-starts-with?` uses indexed loop instead of lazy seq/take. + +OPTIMIZED Malli validation in `commando.commands.builtin`: +- Pre-computed validators and explainers for each command spec. +- Cached coercer for status-map messages. Avoids re-creating schemas on every call. + +## Fixed + +FIXED `execute-single-command` in `commando.impl.executing` — guard for non-map `command-data` before calling `dissoc` on driver keys (`:=>`, `"=>`). + +FIXED point dependency errors in `commando.impl.dependency` now include `:command-path`, `:path`, and `:command` in error data. + +## Updated + +UPDATED `resolve-relative-path` in `commando.impl.dependency` — refactored from reduce to recursive loop for clarity and correct early termination. + +UPDATED `find-anchor-path` in `commando.impl.dependency` — refactored from reduce to loop. + +UPDATED documentation — restructured `README.md` with comprehensive status-map documentation, improved navigation, "Managing the Registry" and "Debugging" sections. Moved doc files to `examples/` with runnable code examples. + +UPDATED performance test alias from `:performance` to `:clj-test-perf-execute` in `deps.edn`. + +UPDATED tests — split monolithic `core_test.cljc` into focused namespaces: `dependency_test.cljc`, `finding_commands_test.cljc`, `graph_test.cljc`, `pathtrie_test.cljc`. Added `debug_test.cljc`. Converted `registry_test` to `.cljc` for cross-platform support. + + # 1.1.0 **BREAKING** REDESIGNED `:=` / `"="` with the new **Driver system** `:=>` / `"=>"`. The old keys are removed. Migration: diff --git a/README.md b/README.md index b2474b3..90d2ac5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Clojars Project](https://img.shields.io/clojars/v/org.clojars.funkcjonariusze/commando.svg)](https://clojars.org/org.clojars.funkcjonariusze/commando) [![Run tests](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml/badge.svg)](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml) -[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.1.0) +[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.2.0) -**Commando** is a flexible Clojure library for managing, extracting, and transforming data inside nested map structures aimed to build your own Data DSL. +**Commando** is a flexible Clojure/ClojureScript library for building data-driven DSLs. ## Content @@ -26,12 +26,13 @@ - [Pipeline](#pipeline) - [Custom Drivers](#custom-drivers) - [Adding New Commands](#adding-new-commands) + - [Managing the Registry](#managing-the-registry) + - [Debugging](#debugging) - [Status-Map and Internals](#status-map-and-internals) - [Configuring Execution Behavior](#configuring-execution-behavior) - - [`:debug-result`](#debug-result) - - [`:error-data-string`](#error-data-string) - [Performance](#performance) -- [Integrations](#integrations) +- [Examples & Guides](#examples--guides) +- [See Also](#see-also) - [Versioning](#versioning) - [License](#license) @@ -39,10 +40,10 @@ ```clojure ;; deps.edn with git -{org.clojars.funkcjonariusze/commando {:mvn/version "1.1.0"}} +{org.clojars.funkcjonariusze/commando {:mvn/version "1.2.0"}} ;; leiningen -[org.clojars.funkcjonariusze/commando "1.1.0"] +[org.clojars.funkcjonariusze/commando "1.2.0"] ``` ## Quick Start @@ -55,7 +56,7 @@ [commands-builtin/command-from-spec] {"1" 1 "2" {:commando/from ["1"]} - "3" {:commando/from ["2"]}})) + "3" {:commando/from ["2"]}}) ;; RETURN => ;; {:instruction {"1" 1, "2" 1, "3" 1} @@ -64,44 +65,106 @@ ;; :warnings [] ;; :successes [{:message "All commands executed successfully"}]} ``` + +> **Hands-on intro** — open [`examples/walkthrough.clj`](./examples/walkthrough.clj) in your REPL and evaluate forms one by one to get a feel for the library. + ## Concept -The main idea of Commando is to create your own flexible, data-driven DSL. Commando enables you to describe complex data transformation and integration pipelines declaratively, tying together data sources, migrations, DTOs, and more. +Commando turns a plain Clojure map into a small program: + +1. You write an **instruction** — a map where some values are **commands** +2. Commando scans it, builds a dependency graph, and executes commands in the right order +3. You get back the same map, but with commands replaced by their results + +``` + ① Instruction (your data + commands) + + {:price 100 + :tax 10.2 + :total {:commando/fn + ◄── "call (+ price tax-amount)" + :args [{:commando/from [:price]} + {:commando/from [:tax]}]}} + + ② Dependency graph (built automatically) + + :price ──┐ + ├──► :total + :tax ──┘ + + ③ Result (commands replaced with values) + + {:price 100 + :tax 10.2 + :total 110.2} +``` + +The commands themselves are pluggable. Commando ships with a handful of built-in ones (`:commando/from`, `:commando/fn`, `:commando/apply`, ...), but the real power is that you define your own — any predicate that recognizes a pattern in data can become a command. + +This makes Commando a toolkit for building **domain-specific languages out of ordinary maps**: migration scripts, integration pipelines, configuration layers, form builders — anything where you want a declarative data structure that *does something*. + +### Real-World Examples + +The examples below use **custom commands** (see [Adding New Commands](#adding-new-commands)). Nothing here is built into Commando — it shows what a domain DSL can look like once you define your own command specs. + +**Database migration** — roles are created before users, because Commando resolves the dependency graph automatically: ```clojure -{"user-from-oracle-db" {:oracle/db :query-user :where [:= :session-id "SESSION-FSD123F1N1ASJ12UIVC"]} - "inserting-info-about-user-in-mysql" - {:mysql/db :add-some-user-action - :insert [{:action "open-app" :user {:commando/from ["user-from-oracle-db"] :=> [:get :login]}} - {:action "query-insurense-data" :user {:commando/from ["user-from-oracle-db"] :=> [:get :login]}} - ...]}} +(commando/execute + my-db-registry + {:roles + {:admin {:sql/insert :permissions :name "admin"} + :viewer {:sql/insert :permissions :name "viewer"}} + + :users + [{:sql/insert :users + :record {:name "Alice" + :role {:commando/from [:roles :admin] :=> [:get :id]}}} + {:sql/insert :users + :record {:name "Bob" + :role {:commando/from [:roles :viewer] :=> [:get :id]}}}]}) ``` -In the above example, Commando combines queries to two different databases, enabling you to compose effective scripts, migrations, DTO structures, etc. +**Chained queries** — a tree of database queries where each depends on a previous result: ```clojure -{"roles" - {"admin-role" - {:sql> "INSERT INTO permission-table(role,description) VALUES ((\"admin\", \"...\"))" - :sql< "SELECT id FROM permission-table WHERE role = \"admin\" "} - "service-role" - {:sql> "INSERT INTO permission-table(role,description) VALUES ((\"service\", \"...\"))" - :sql< "SELECT id FROM permission-table WHERE role = \"service\" "} - "user-role" - {:sql> "INSERT INTO permission-table(role,description) VALUES ((\"user\", \"...\"))" - :sql< "SELECT id FROM permission-table WHERE role = \"user\" "}} - "users" - [{:sql/insert-into :user-table - :record {:fname "Adam" :lname "West" :role {:commando/from ["roles" "admin-role"]}}} - {:sql/insert-into :user-table - :record {:fname "Bat" :lname "Man" :role {:commando/from ["roles" "admin-role"]}}} - ...]} +(commando/execute + [db-query-spec builtin/command-from-spec] + {:user {:db/query :users :where {:id 42}} + + :posts {:db/query :posts + :where {:author-id {:commando/from [:user] :=> [:get :id]}}} + + :comments {:db/query :comments + :where {:post-ids {:commando/from [:posts] :=> [:fn #(mapv :id %)]}}}}) +;; => {:user {:id 42 :name "Alice" ...} +;; :posts [{:id 1 ...} {:id 2 ...}] +;; :comments [{:post-id 1 ...} {:post-id 2 ...} ...]} ``` -The instruction above clearly explains the processes, and creates the required bindings which, when maintained, will help visualize and support your business logic. +**Design tokens / style map** — build a Reagent-friendly style system where component styles derive from shared tokens: +```clojure +(commando/execute + [builtin/command-from-spec + builtin/command-fn-spec] + {:tokens {:primary "#3b82f6" :danger "#ef4444" + :radius "8px" :space-sm "8px"} -As Commando is simply a graph-based resolver with easy configuration, it is not limited by any architectural constraints or specific framework. + :button {:commando/fn merge + :args [{:background {:commando/from [:tokens :primary]}} + {:border-radius {:commando/from [:tokens :radius]}} + {:padding {:commando/from [:tokens :space-sm]}}]} + + :alert {:commando/fn merge + :args [{:background {:commando/from [:tokens :danger]}} + {:border-radius {:commando/from [:tokens :radius]}} + {:padding {:commando/from [:tokens :space-sm]}}]}}) +;; => {:tokens {...} +;; :button {:background "#3b82f6" :border-radius "8px" :padding "8px"} +;; :alert {:background "#ef4444" :border-radius "8px" :padding "8px"}} +``` + +Commando is a graph-based resolver with easy configuration — it is not limited by any architectural constraints or specific framework. ## Basics @@ -129,7 +192,7 @@ The above function composes "Instructions", "Commands", and a "CommandRegistry". ### Builtin Functionality -The basic commands is found in namespace `commando.commands.builtin`. It describes core commands and their behaviors. Behavior of those commands declared with configuration map called _CommandMapSpecs_. +The basic commands are found in namespace `commando.commands.builtin`. It describes core commands and their behaviors. Behavior of those commands declared with configuration map called _CommandMapSpecs_. #### command-from-spec @@ -190,9 +253,9 @@ A convenient wrapper over `apply`. "v2" 2 "sum=" {:commando/fn + - :args [{:commando/from ["v1"]} - {:commando/from ["v2"]} - 3]}}) + :args [{:commando/from ["v1"]} + {:commando/from ["v2"]} + 3]}}) ;; => {"v1" 1 "v2" 2 "sum=" 6} ``` @@ -204,13 +267,13 @@ Returns the value of `:commando/apply` as-is. Use `:=>` driver to post-process t (commando/execute [commands-builtin/command-apply-spec] {"0" {:commando/apply - {"1" {:commando/apply - {"2" {:commando/apply - {"3" {:commando/apply {"4" {:final "5"}} - :=> [:get "4"]}} - :=> [:get "3"]}} - :=> [:get "2"]}} - :=> [:get "1"]}}) + {"1" {:commando/apply + {"2" {:commando/apply + {"3" {:commando/apply {"4" {:final "5"}} + :=> [:get "4"]}} + :=> [:get "3"]}} + :=> [:get "2"]}} + :=> [:get "1"]}}) ;; => {"0" {:final "5"}} ``` @@ -223,14 +286,14 @@ Imagine the following instruction is your initial database migration, adding use [commands-builtin/command-from-spec commands-builtin/command-mutation-spec] {"add-new-user-01" {:commando/mutation :add-user :name "Bob Anderson" - :permissions [{:commando/from ["perm_send_mail"] :=> [:get :id]} - {:commando/from ["perm_recv_mail"] :=> [:get :id]}]} + :permissions [{:commando/from ["perm_send_mail"] :=> [:get :id]} + {:commando/from ["perm_recv_mail"] :=> [:get :id]}]} "add-new-user-02" {:commando/mutation :add-user :name "Damian Nowak" - :permissions [{:commando/from ["perm_recv_mail"] :=> [:get :id]}]} + :permissions [{:commando/from ["perm_recv_mail"] :=> [:get :id]}]} "perm_recv_mail" {:commando/mutation :add-permission - :name "receive-email-notification"} + :name "receive-email-notification"} "perm_send_mail" {:commando/mutation :add-permission - :name "send-email-notification"}}) + :name "send-email-notification"}}) ``` You can see that you need both :add-permission and :add-user commands. In most cases, such patterns can be abstracted and reused, simplifying your migrations and business logic. @@ -257,7 +320,7 @@ This approach enables you to quickly encapsulate business logic into reusable co Allows describing reusable command templates that are expanded into regular Commando commands at runtime. This is useful when you want to describe a pattern for building a complex command or a set of related commands without duplicating the same structure throughout an instruction -Asume we have a Instruction what calculates mean. +Assume we have an instruction that calculates the mean. ```clojure (commando/execute [commands-builtin/command-from-spec @@ -266,11 +329,11 @@ Asume we have a Instruction what calculates mean. {:=> [:get :result] :commando/apply {:vector-of-numbers [1, 2, 3, 4, 5] - :result - {:fn (fn [& [vector-of-numbers]] - (/ (reduce + 0 vector-of-numbers) - (count vector-of-numbers))) - :args [{:commando/from [:commando/apply :vector-of-numbers]}]}}}) + :result + {:fn (fn [& [vector-of-numbers]] + (/ (reduce + 0 vector-of-numbers) + (count vector-of-numbers))) + :args [{:commando/from [:commando/apply :vector-of-numbers]}]}}}) ;; => 3 ``` @@ -283,11 +346,11 @@ Define a macro {:=> [:get :result] :commando/apply {:vector-of-numbers vector-of-numbers - :result - {:fn (fn [& [vector-of-numbers]] - (/ (reduce + 0 vector-of-numbers) - (count vector-of-numbers))) - :args [{:commando/from [:commando/apply :vector-of-numbers]}]}}}) + :result + {:fn (fn [& [vector-of-numbers]] + (/ (reduce + 0 vector-of-numbers) + (count vector-of-numbers))) + :args [{:commando/from [:commando/apply :vector-of-numbers]}]}}}) (commando/execute @@ -356,7 +419,7 @@ A driver is declared via the `:=>` key (or `"=>"` for string-key/JSON maps) on a {:commando/.. :=> [[:get :address] [:get-in [:location :city]] :uppercase]} ``` -If no `:=>` is specified, the default driver is `:get-in` with no params, which acts as identity (pass-through). +If no `:=>` is specified, the default driver is `:identity` (pass-through). ```clojure (commando/execute @@ -527,7 +590,7 @@ Here's an example of another instruction, utilizing step-by-step extraction of k "a-value" {:commando/from ["1" :values :a] :=> [:fn (partial * 100)]} "b-value" {:commando/from ["1" :values :b] :=> [:fn (partial * -100)]} "args" {:a {:commando/from ["a-value"]} - :b {:commando/from ["b-value"]}} + :b {:commando/from ["b-value"]}} "summ=" {:commando/from ["args"] :=> [:fn (fn [{:keys [a b]}] (+ a b))]}}) ;; => ;; {"1" {:values {:a 1, :b -1}}, @@ -545,11 +608,11 @@ Let's create a new command using a CommandMapSpec configuration map: {:type :CALC= :recognize-fn #(and (map? %) (contains? % :CALC=)) :validate-params-fn (fn [m] - (and - (fn? (:CALC= m)) - (not-empty (:ARGS m)))) + (and + (fn? (:CALC= m)) + (not-empty (:ARGS m)))) :apply (fn [_instruction _command m] - (apply (:CALC= m) (:ARGS m))) + (apply (:CALC= m) (:ARGS m))) :dependencies {:mode :all-inside}} ``` @@ -565,46 +628,30 @@ Let's create a new command using a CommandMapSpec configuration map: Now you can use it for more expressive operations like "summ=" and "multiply=" as shown below: ```clojure -;; Build a registry — vector order defines scan priority -(def command-registry - (commando/registry-create - [commands-builtin/command-from-spec - {:type :CALC= - :recognize-fn #(and (map? %) (contains? % :CALC=)) - :validate-params-fn (fn [m] - (and - (fn? (:CALC= m)) - (not-empty (:ARGS m)))) - :apply (fn [_instruction _command m] - (apply (:CALC= m) (:ARGS m))) - :dependencies {:mode :all-inside}}])) - -;; Modify a built registry -(def extended-registry - (commando/registry-add command-registry - {:type :NEW-CMD - :recognize-fn #(and (map? %) (contains? % :NEW-CMD)) - :apply (fn [_ _ m] (:NEW-CMD m)) - :dependencies {:mode :none}})) - -(def shrunk-registry - (commando/registry-remove extended-registry :NEW-CMD)) - (commando/execute - command-registry + [commands-builtin/command-from-spec + {:type :CALC= + :recognize-fn #(and (map? %) (contains? % :CALC=)) + :validate-params-fn (fn [m] + (and + (fn? (:CALC= m)) + (not-empty (:ARGS m)))) + :apply (fn [_instruction _command m] + (apply (:CALC= m) (:ARGS m))) + :dependencies {:mode :all-inside}}] {"1" {:values {:a 1 :b -1}} "a-value" {:commando/from ["1" :values :a] :=> [:fn (partial * 100)]} "b-value" {:commando/from ["1" :values :b] :=> [:fn (partial * -100)]} "summ=" {:CALC= + - :ARGS [{:commando/from ["a-value"]} - {:commando/from ["b-value"]} - 1 - 11]} + :ARGS [{:commando/from ["a-value"]} + {:commando/from ["b-value"]} + 1 + 11]} "multiply=" {:CALC= * - :ARGS [{:commando/from ["a-value"]} - {:commando/from ["b-value"]} - 2 - 22]}}) + :ARGS [{:commando/from ["a-value"]} + {:commando/from ["b-value"]} + 2 + 22]}}) ;; => ;; {"1" {:values {:a 1, :b -1}}, ;; "a-value" 100, @@ -618,11 +665,11 @@ The concept of a **command** is not limited to map structures it is basically an ```clojure (commando/execute [{:type :custom/json - :recognize-fn #(and (string? %) (clojure.string/starts-with? % "json")) - :apply (fn [_instruction _command-map string-value] - (clojure.data.json/read-str (apply str (drop 4 string-value)) - :key-fn keyword)) - :dependencies {:mode :none}}] + :recognize-fn #(and (string? %) (clojure.string/starts-with? % "json")) + :apply (fn [_instruction _command-map string-value] + (clojure.data.json/read-str (apply str (drop 4 string-value)) + :key-fn keyword)) + :dependencies {:mode :none}}] {:json-command-1 "json{\"some-json-value-1\": 123}" :json-command-2 "json{\"some-json-value-2\": [1, 2, 3]}"}) ;; => @@ -630,14 +677,88 @@ The concept of a **command** is not limited to map structures it is basically an ;; :json-command-2 {:some-json-value-2 [1 2 3]}} ``` +### Managing the Registry + +You can pre-build a registry with `registry-create` and later modify it with `registry-add` / `registry-remove`: + +```clojure +;; Build a registry — vector order defines scan priority +(def command-registry + (commando/registry-create + [commands-builtin/command-from-spec + {:type :CALC= + :recognize-fn #(and (map? %) (contains? % :CALC=)) + :apply (fn [_ _ m] (apply (:CALC= m) (:ARGS m))) + :dependencies {:mode :all-inside}}])) + +;; Add a new command spec to an existing registry +(def extended-registry + (commando/registry-add command-registry + {:type :NEW-CMD + :recognize-fn #(and (map? %) (contains? % :NEW-CMD)) + :apply (fn [_ _ m] (:NEW-CMD m)) + :dependencies {:mode :none}})) + +;; Remove a command spec by type +(def shrunk-registry + (commando/registry-remove extended-registry :NEW-CMD)) +``` + +### Debugging + +The `commando.debug` namespace provides two main functions for inspecting instruction execution: + +**`execute-debug`** + +Executes an instruction with debug enabled and prints a visual representation. Accepts a display mode (default `:table`): + +```clojure +(require '[commando.debug :as debug]) + +;; Default :table mode — tabular execution map with order, type, deps, values +(debug/execute-debug registry instruction) + +;; Other modes: :table, :tree, :graph, :stats, :instr-before, :instr-after +(debug/execute-debug registry instruction :tree) + +;; Combine multiple modes in one printing +(debug/execute-debug registry instruction [:instr-before :table :instr-after :stats]) +``` + +**`execute-trace`** + +Traces all nested `commando/execute` calls (including recursive calls from macros/mutations) with timing: + +```clojure +(debug/execute-trace registry instruction) + +;; With opts +(debug/execute-trace registry instruction {:error-data-string false}) +``` + +Add `:__title` (or `"__title"`) to an instruction to label it in the trace output. + ## Status-Map and Internals -The main function for executing instructions is `commando.core/execute`, which returns a so-called Status-Map. A Status-Map is a data structure that contains the outcome of instruction execution, including results, successes, warnings, errors, and internal execution order.) +`commando.core/execute` returns a **Status-Map** — a data structure that contains the full outcome of instruction execution. All keys are always present in the result, regardless of whether execution succeeded or failed. + +- `:status` — `:ok` or `:failed`. Outcome of the execution. +- `:instruction` — on `:ok`, the fully evaluated instruction with all commands resolved to their values. On `:failed`, the partially or completely unexecuted original instruction. +- `:errors` — vector of error objects accumulated during execution. Each entry is a map with at least `:message`; may also contain `:error` (serialized exception), `:command-path`, `:command-type`. Empty `[]` on success. +- `:warnings` — vector of non-critical issues, e.g. skipped pipeline steps after a failure. Empty `[]` when there are no warnings. +- `:successes` — vector of informational messages about completed pipeline steps. Each entry is a map with `:message`. +- `:stats` — vector of timing measurements for each pipeline step. Each entry is a tuple `[step-name duration-ns formatted-string]`, e.g. `["execute-commands!" 95838 "95.838µs"]`. +- `:uuid` — unique identifier for this execution invocation. +- `:registry` — the built command registry used for this execution. +- `:internal/cm-list` — set of all discovered Command objects (`CommandMapPath`) found during the `find-commands` step. +- `:internal/cm-dependency` — forward dependency graph `{CommandMapPath → #{deps}}`, which commands each command depends on. +- `:internal/cm-running-order` — vector of commands in topologically sorted execution order (Kahn's algorithm). +- `:internal/cm-results` — map `{CommandMapPath → resolved-value}`, the result of each command's `:apply` function. +- `:internal/path-trie` — nested trie structure for O(depth) command lookup by path. +- `:internal/original-instruction` — the original instruction as passed by the user, before any command evaluation. -On successful execution (`:ok`), you get: -- `:instruction` - the resulting evaluated data map. -- `:successes` - information about successful execution steps. +### Example: successful execution ```clojure (require '[commando.core :as commando]) @@ -649,140 +770,85 @@ On successful execution (`:ok`), you get: "2" {:commando/from ["1"]} "3" {:commando/from ["2"]}}) -;; RETURN => -{:status :ok, +;; => +{:status :ok :instruction {"1" 1, "2" 1, "3" 1} - :stats - [["execute-commands!" 95838 "95.838µs"] - ["execute" 1085471 "1.085471ms"]] + :errors [] + :warnings [] :successes - [{:message - "Commando. parse-instruction-map. Entities was successfully collected"} - {:message - "Commando. build-deps-tree. Dependency map was successfully built"} - {:message - "Commando. sort-entities-by-deps. Entities were sorted and prepared for evaluation"} - {:message - "Commando. compress-execution-data. Status map was compressed"} - {:message - "Commando. evaluate-instruction-commands. Data term was processed"}]} -``` - -On unsuccessful execution (`:failed`), you get: -- `:instruction` - the partially or completely unexecuted instruction given by the user -- `:successes` - a list of successful actions completed before the failure -- `:warnings` - a list of non-critical errors or skipped steps -- `:errors` - a list of error objects, sometimes with exception data or additional keys -- `:internal/cm-list` (optional) - a list of Command objects with command meta-information -- `:internal/cm-dependency` (optional) - a map of dependencies -- `:internal/cm-running-order` (optional) - the resulting list of commands to be executed in order + [{:message "Commands were successfully collected"} + {:message "Dependency map was successfully built"} + {:message "..."}] + :stats + [["use-registry" 12500 "12.5µs"] + ["find-commands" 35000 "35µs"] + ["build-deps-tree" 18000 "18µs"] + ["sort-commands-by-deps" 9000 "9µs"] + ["execute-commands!" 95838 "95.838µs"] + ["execute" 1085471 "1.085471ms"]] + :uuid "a1b2c3..." + :registry ... + :internal/cm-list #{...} + :internal/cm-dependency {...} + :internal/cm-running-order [...] + :internal/cm-results {...} + :internal/path-trie {...} + :internal/original-instruction {"1" 1, "2" {:commando/from ["1"]}, "3" {:commando/from ["2"]}}} +``` -```clojure -(require '[commando.core :as commando]) -(require '[commando.commands.builtin :as commands-builtin]) +### Example: failed execution +```clojure (commando/execute [commands-builtin/command-from-spec] {"1" 1 "2" {:commando/from ["1"]} "3" {:commando/from ["WRONG" "PATH"]}}) -;; RETURN => +;; => {:status :failed :instruction {"1" 1 "2" {:commando/from ["1"]} "3" {:commando/from ["WRONG" "PATH"]}} :errors - [{:message "build-deps-tree. Failed to build `:point` dependency. Key `Commando.` with path: `:commando/from`, - referring to non-existing value", - :path ["3"], - :command {:commando/from ["WRONG" "PATH"]}}], + [{:message "Point dependency failed: key ':commando/from' references non-existent path [\"WRONG\" \"PATH\"]" + :path ["3"] + :command {:commando/from ["WRONG" "PATH"]}}] :warnings - [{:message - "Commando. sort-entities-by-deps. Skipping mandatory step"} - {:message - "Commando. compress-execution-data. Skipping mandatory step"} - {:message - "Commando. evaluate-instruction-commands. Skipping mandatory step"}], + [{:message "Skipping sort-commands-by-deps"} + {:message "Skipping execute-commands!"}] :successes - [{:message - "Commando. parse-instruction-map. Entities were successfully collected"}], - :internal/cm-list - [# - # - # - #]} + [{:message "Commands were successfully collected"}] + :stats [...] + :uuid "d4e5f6..." + :internal/cm-list #{...} + ...} ``` -### Configuring Execution Behavior - -The `commando.impl.utils/*execute-config*` dynamic variable allows for fine-grained control over `commando/execute`'s behavior. You can bind this variable to a map containing the following configuration keys: +Helper predicates for checking status: -- `:debug-result` (boolean) -- `:error-data-string` (boolean) - -#### `:debug-result` +```clojure +(commando/ok? result) ;; => true | false +(commando/failed? result) ;; => true | false +``` -When set to `true`, the returned status-map will include additional execution information, such as `:internal/cm-list`, `:internal/cm-dependency`, and `:internal/cm-running-order`. This helps in analyzing the instruction's execution flow. +### Configuring Execution Behavior -Here's an example of how to use `:debug-result`: +`commando/execute` accepts an optional third argument — a configuration map. Config keys control execution behavior and are automatically inherited by nested `execute` calls (e.g. from `:commando/macro` or `:commando/resolve`). Inner calls can override specific keys — non-overridden keys come from the parent. ```clojure -(require '[commando.core :as commando]) -(require '[commando.commands.builtin :as commands-builtin]) -(require '[commando.impl.utils :as commando-utils]) - -(binding [commando-utils/*execute-config* {:debug-result true}] - (commando/execute - [commands-builtin/command-from-spec] - {"1" 1 - "2" {:commando/from ["1"]} - "3" {:commando/from ["2"]}})) - -;; RETURN => -{:status :ok, - :instruction {"1" 1, "2" 1, "3" 1} - :stats - [["use-registry" 111876 "111.876µs"] - ["find-commands" 303062 "303.062µs"] - ["build-deps-tree" 134049 "134.049µs"] - ["sort-commands-by-deps" 292206 "292.206µs"] - ["execute-commands!" 53762 "53.762µs"] - ["execute" 1074110 "1.07411ms"]] - :registry - [{:type :commando/from, - :recognize-fn #function[commando.commands.builtin/fn], - :validate-params-fn #function[commando.commands.builtin/fn], - :apply #function[commando.commands.builtin/fn], - :dependencies {:mode :point, :point-key [:commando/from]}}], - :warnings [], - :errors [], - :successes - [{:message "Commands were successfully collected"} - {:message "Dependency map was successfully built"} - {:message "Commando. sort-entities-by-deps. Entities was sorted and prepare for evaluating"} - {:message "All commands executed successfully"}], - :internal/cm-list - ["root[_map]" - "root,1[_value]" - "root,2[from]" - "root,3[from]"] - :internal/cm-running-order - ["root,2[from]" - "root,3[from]"], - :internal/cm-dependency - {"root[_map]" #{"root,2[from]" "root,1[_value]" "root,3[from]"}, - "root,1[_value]" #{}, - "root,2[from]" #{"root,1[_value]"}, - "root,3[from]" #{"root,2[from]"}}} +(commando/execute registry instruction + {:error-data-string false + :hook-execute-start (fn [status-map] ...) + :hook-execute-end (fn [status-map] ...)}) ``` -`:internal/cm-list` - a list of all recognized commands in an instruction. This list also contains the `_map`, `_value`, and the unmentioned `_vector` commands. Commando includes several internal built-in commands that describe the _instruction's structure_. An _instruction_ is a composition of maps, their values, and vectors that represent its structure and help build a clear dependency graph. These commands are removed from the final output after this step, but included in the compiled registry. - -`:internal/cm-dependency` - describes how parts of an _instruction_ depend on each other. - -`:internal/cm-running-order` - the correct order in which to execute commands. +- `:error-data-string` (boolean, default `true`) +- `:hook-execute-start` (function) — called before execution begins, receives execution context map +- `:hook-execute-end` (function) — called after execution completes, receives execution context map with `:stats` and `:instruction` +Hooks allow you to observe or instrument the execution lifecycle — for example, to collect timing data, log nested executions, or build execution traces. See [Debugging](#debugging) for a practical use via `execute-trace`. #### `:error-data-string` @@ -808,10 +874,10 @@ When `:error-data-string` is `true`, the `:data` key within serialized `Exceptio (def value - (binding [sut/*execute-config* {:error-data-string false}] - (commando/execute [commands-builtin/command-from-spec] - {"a" 10 - "ref" {:commando/from "BROKEN"}}))) + (commando/execute [commands-builtin/command-from-spec] + {"a" 10 + "ref" {:commando/from "BROKEN"}} + {:error-data-string false})) (get-in value [:errors 0 :error]) ;; => ;; {:type "exception-info", @@ -838,7 +904,7 @@ All benchmarks were conducted on an **Intel Core i9-13980HX**. The primary metri The graph below illustrates the total execution time for instructions with a typical number of dependencies, ranging from 1,250 to 80,000. As you can see, the execution time scales linearly and remains in the low millisecond range, demonstrating excellent performance for common use cases.
- +
#### Execution Step Analysis @@ -857,7 +923,7 @@ The following graphs show the performance of each step under both normal and ext Under normal conditions, each execution step completes in just a few milliseconds. The overhead of parsing, dependency resolution, and execution is minimal, ensuring a fast and responsive system.
- +
**Massive Workloads (up to 5,000,000 dependencies)** @@ -865,16 +931,26 @@ Under normal conditions, each execution step completes in just a few millisecond To test the limits of the library, we benchmarked it with instructions containing up to 5 million dependencies. The graph below shows that while the system scales, the `find-commands` (parsing) and `build-deps-tree` (dependency graph construction) phases become the primary bottlenecks. This demonstrates that the core execution remains fast, but performance at extreme scales is dominated by the initial analysis steps.
- +
-# Integrations +# Examples & Guides + +- [Walkthrough](./examples/walkthrough.clj) — REPL-driven intro, evaluate forms one by one +- [Work with JSON](./examples/json.clj) — sending instructions over the wire as JSON, keyword handling, string-keyed commands +- [Frontend + Reagent](./examples/reagent_front.cljs) — using Commando with Reagent on the frontend +- [Commando + Integrant](./examples/integrant.clj) — wiring Commando into an Integrant system +- [Commando + Component](./examples/component.clj) — replacing Component + Aero with a single Commando instruction +- [Commando + Reitit](./examples/reitit.clj) — HTTP handler with Transit and Reitit +- [Commando QueryDSL](./examples/query_dsl.md) — lightweight query mechanism as an alternative to GraphQL/Pathom + +# See Also + +Libraries that explore related ideas — graph-based resolution, declarative data pipelines, or reactive maps: -- [Work with JSON](./doc/json.md) -- [Frontend + Reagent](./doc/reagent.md) -- [Commando + integrant](./doc/integrant.md) -- [Commando QueryDSL](./doc/query_dsl.md) -- [Example Http commando transit handler + Reitit](./doc/example_reitit.clj) +- **[Pathom 3](https://pathom3.wsscode.com)** — attribute-based graph resolver. You declare how attributes derive from other attributes; Pathom plans and executes the minimal resolver graph to fulfill a query. +- **[rmap](https://github.com/aroemers/rmap)** — reactive maps where values reference sibling keys, with dependencies resolved automatically — like a spreadsheet in a Clojure map. +- **[Plumbing Graph](https://github.com/plumatic/plumbing)** — declarative DAG of named computations as `{keyword → fnk}`, compiled into an execution plan with automatic dependency resolution. # Versioning diff --git a/deps.edn b/deps.edn index dd4b66b..a495cc5 100644 --- a/deps.edn +++ b/deps.edn @@ -9,9 +9,9 @@ :main-opts ["-m" "cljs-test-runner.main" "-d" "test/unit"] :patterns [".*-test$"]} ;;For local performance regression tests - :performance + :clj-test-perf-execute {:extra-deps {cljfreechart/cljfreechart {:mvn/version "0.2.0"}} - :main-opts ["-m" "commando.core-perf-test"] + :main-opts ["-m" "commando.execute.execute-test"] :extra-paths ["test/perf"]} ;;Build jar :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.10"}} diff --git a/doc/README.md b/doc/README.md deleted file mode 100644 index 5ea84f3..0000000 --- a/doc/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Table of Contents - -Here is a list of the available documentation files: - -- [Work with JSON](./json.md) -- [Frontend + Reagent](./reagent.md) -- [Commando + integrant](./integrant.md) -- [Commando QueryDSL](./query_dsl.md) -- [Example Http commando transit handler + Reitit](./example_reitit.clj) - - diff --git a/doc/example_reitit.clj b/doc/example_reitit.clj deleted file mode 100644 index 1c426a6..0000000 --- a/doc/example_reitit.clj +++ /dev/null @@ -1,50 +0,0 @@ -(ns example-reitit - (:require - [commando.core :as commando] - [commando.commands.builtin :as command-builtin] - [commando.commands.query-dsl :as command-query-dsl] - [reitit.http :as http] - [reitit.interceptor.sieppari :as reitit-sieppari] - [reitit.ring :as ring] - [ring.middleware.transit :as ring-transit])) - -;; -------------------------- -;; 1. Define Commando Handler -;; -------------------------- - -(defn ^:private commando-handler [request] - (if-let [instruction-to-run (get request [:body :instruction])] - (let [result - (commando/execute - [command-builtin/command-apply-spec - command-builtin/command-fn-spec - command-builtin/command-from-spec - command-builtin/command-mutation-spec - command-query-dsl/command-resolve-spec] - instruction-to-run)] - (if (= :ok (:status result)) - {:status 200 - :headers {"Content-Type" "application/transit+json; charset=UTF-8"} - :body (select-keys result [:status :instruction :successes])} - {:status 500 - :headers {"Content-Type" "application/transit+json; charset=UTF-8"} - :body (select-keys result [:status :errors :warnings :successes])})) - {:status 500 - :headers {"Content-Type" "application/transit+json; charset=UTF-8"} - :body {:status :failed - :errors [{:message "Instruction is empty"}]}})) - -;; ------------------------------------ -;; 2. Registering Endpoint With Transit -;; ------------------------------------ - -(def reitit-handler - (http/ring-handler - (http/router - [["/commando" - {:post {:handler (-> commando-handler - ring-transit/wrap-transit-body - ring-transit/wrap-transit-response)}}]]) - (ring/create-default-handler) - {:executor reitit-sieppari/executor - :interceptors []})) diff --git a/doc/integrant.md b/doc/integrant.md deleted file mode 100644 index ff5fca6..0000000 --- a/doc/integrant.md +++ /dev/null @@ -1,204 +0,0 @@ -# Integrating Integrant and Commando - -[Integrant](https://github.com/weavejester/integrant) is a flexible library for building systems of interconnected components in Clojure. It allows you to declaratively describe the structure of a system, define dependencies between components, and manage their lifecycle. - -## Why use Integrant together with Commando? - -Unlike the classic approach of using only configuration maps, Commando lets you construct and transform your dependency structure as declarative instructions. This makes it easy to describe systems, reuse configuration fragments, inject extra processing logic, and define custom "commands" for specific tasks. Integrant is responsible for actual component initialization, while Commando builds, transforms, and validates configuration structures. - -## Example: HTTP server + database - -Imagine we're building a system with two components: an HTTP server and an SQL database connector. - -```clojure -(require '[integrant.core :as ig]) - -(defmethod ig/init-key :plugin-jetty-server/server-main [...] ...) -(defmethod ig/init-key :plugin-sqlite/datasource [...] ...) - -;; convenient component renaming -(derive :plug/db :plugin-sqlite/datasource) -(derive :plug/http-server :plugin-jetty-server/server) - -(def db-storage-file "../../some-folder") - -(def system:integrant-native - {:plug/db {:dbtype "sqlite" :dbname "storage.db" :dir db-storage-file} - :plug/http-server {:port 2500 :handler #'ring-handler :db-connector #ig/ref :plug/db}}) -``` - -Integrant lets you build a dependency tree, e.g., the server refers to the DB connector via `#ig/ref`. However, if you need extra processing, template organization, or structure transformation, try Commando! - -## System construction via Commando: a declarative approach - -Commando allows you to build Integrant configurations as instructions - nested data structures where dependencies, settings, and transformations are described declaratively. - -```clojure -(defn system:integrant-commando [] - {"settings" {"db-storage-file" "../../some-folder"} - "db" - {"sqlite-connector" - {:integrant/component-alias :plug/db - :integrant/component :plugin-sqlite/datasource - :dbtype "sqlite" - :dbname "storage.db" - :dir {:commando/from ["settings" :project-root]}}} - "http-server" - {:integrant/component-alias :plug/http-server - :integrant/component :plugin-jetty-server/server-main - :port 2500 - :handler #'ring-handler - :db-connector {:integrant/from ["db" "sqlite-connector"]}} - "integrant-config" - {:integrant/system - [{:commando/from ["db" "sqlite-connector"]} - {:commando/from ["http-server"]}]}}) -``` - -Compared to the classic approach, here you can easily plug in shared settings, use aliases, build more complex dependencies, and automatically generate the final config for Integrant. - -### `:integrant/component` - - This command declare what "component" exactly mean. As you see the `:apply` just only do `derive` to apply your custom naming for component - -```clojure -(def command-integrant-component-spec - {:type :integrant/component - :recognize-fn #(and (map? %) (contains? % :integrant/component)) - :validate-params-fn (fn [m] (malli/validate - [:map - [:integrant/component-alias :keyword] - [:integrant/component :keyword]] m)) - :apply (fn [_ _ integrant-component] - (derive - (get integrant-component :integrant/component-alias) - (get integrant-component :integrant/component)) - integrant-component) - :dependencies {:mode :all-inside}}) -``` - -### `:integrant/from` - -This command lets you wire dependencies between components by specifying the path to the relevant config node. - -```clojure -{:integrant/from ["db" "sqlite-connector"]} -``` - -Used to build references between components; it overlays over `#ig/ref` in Integrant. - -Let's define CommandMapSpec for `:integrant/from`: - -```clojure -(def command-integrant-from-spec - {:type :integrant/from - :recognize-fn #(and (map? %) (contains? % :integrant/from)) - :validate-params-fn (fn [m] (malli/validate [:map - [:integrant/from [:sequential [:or :string :keyword :int]]]] m)) - :apply (fn [data-map _ {keyword-vector-to-component :integrant/from :as term-data}] - (let [integrant-component (get-in data-map keyword-vector-to-component)] - (if (and - (contains? integrant-component :integrant/component) - (contains? integrant-component :integrant/component-alias)) - (ig/ref (:integrant/component-alias integrant-component)) - (throw (ex-info "`:integrant/from` Exception. term pointing on something that not a `:integrant/component` term " term-data))))) - :dependencies {:mode :point - :point-key [:integrant/from]}}) -``` - -Just like Commando’s basic commands, you specify how to recognize a component reference, how to validate it, and what to produce on evaluation. - -### `:integrant/system` - -Combines a set of components into a ready configuration map for Integrant initialization. - -```clojure -{:integrant/system - [{:commando/from ["db" "sqlite-connector"]} - {:commando/from ["http-server"]}]} -``` - -And the CommandMapSpec for `:integrant/system`: - -```clojure -(def command-integrant-system-spec - {:type :integrant/system - :recognize-fn #(and (map? %) (contains? % :integrant/system)) - :validate-params-fn (fn [m] (malli/validate - [:map - [:integrant/system [:+ :map]]] m)) - :apply (fn [_ _ {integrant-system :integrant/system}] - (reduce - (fn [acc v] - (assoc acc - (:integrant/component-alias v) - (dissoc v :integrant/component :integrant/component-alias))) - {} - integrant-system)) - :dependencies {:mode :all-inside}}) -``` - -## Build and Launch the system - -Once the instruction structure is built and the above command specs are placed in the registry, you can execute it via Commando to obtain the final Integrant config map: - -```clojure -(def integrant-commando-registry - (commando/build-command-registry - [command-integrant-component - command-integrant-from - command-integrant-system - commando/command-from])) - -(def integrant-config-build - (fn [commando-integrant-configuration-map] - (let [result-status-map - (commando/execute - integrant-commando-registry - commando-integrant-configuration-map)] - (if (commando/failed? result-status-map) - (throw (ex-info "Failed to build integrant configuration" result-status-map)) - (get-in result-status-map [:instruction "integrant-config"]))))) - -(def system - (ig/init - (integrant-config-build) - [:plug/http-server])) -``` - -## Accessing environment variables via custom commands - -To integrate with environment variables, you can define your own command, similarly to how it works in [juxt/aero](https://github.com/juxt/aero): - -```clojure -(def command-get-env-spec - {:type :env/get - :recognize-fn #(and (map? %) (contains? % :env/get)) - :validate-params-fn (fn [m] (malli/validate - [:map - [:env/get [:string {:min 1}]] - [:default {:optional true} :any]] m)) - :apply (fn [_ _ {default-value :default env-get-var :env/get}] - (or (System/getenv env-get-var) default-value)) - :dependencies {:mode :self}}) -``` - -Usage: -``` -(commando/execute - [command-get-env-spec] - {:env/get "HOME" - :default "~/"}) -;; => {:instruction "/home/host-user/"} -``` - -## Benefits of the Commando + Integrant approach - -- **Flexibility**: Easy to modify, reuse, and test configurations. -- **Declarativeness**: All dependency logic is described declaratively, making it more readable and maintainable. -- **Validation**: Commands can include their own validation and error handling logic. -- **Extensibility**: It’s easy to add your own commands for specific use cases (e.g., environment integration, value generation, integration with external systems). - -This approach is especially useful when you need to frequently change or parameterize configs, automate building of complex systems, or integrate business logic directly into configuration structures. - -For more advanced scenarios, you can combine Commando with other libraries, creating your own commands and registries. See the documentation in `README.md` for base command descriptions, registries, and execution interfaces. diff --git a/doc/json.md b/doc/json.md deleted file mode 100644 index acee202..0000000 --- a/doc/json.md +++ /dev/null @@ -1,118 +0,0 @@ -# Working with JSON - -Since Commando is a technology that allows you to create your own DSLs, a critical aspect is the format of the data structures you process. JSON is a common choice for APIs, serialization, and database storage. Therefore, your DSL must be adaptable and work seamlessly outside the Clojure ecosystem. - -## The Challenge: Keywords vs. Strings - -Commando is idiomatic Clojure and heavily relies on namespaced keywords (e.g., `:commando/from`, `:commando/mutation`). JSON, however, does not support keywords; it only uses strings for object keys. This presents a challenge when an instruction needs to be represented in JSON format. - -## The Solution: String-Based Commands - -Commando's built-in commands are designed to work with string-based keys out of the box, allowing for seamless JSON interoperability. When parsing instructions, Commando recognizes both the keyword version (e.g., `:commando/mutation`) and its string counterpart (`"commando-mutation"`). - -This allows you to define instructions in pure JSON, slurp in clojure, parse and have them executed by Commando. - -### Example: Vector Dot Product - -Imagine you want to calculate the scalar (dot) product of two vectors described in a JSON file. - -**`vectors.json`:** -```json -{ - "vector-1": { "x": 1, "y": 2 }, - "vector-2": { "x": 4, "y": 5 }, - "scalar-product-value": { - "commando-mutation": "dot-product", - "v1": { "commando-from": ["vector-1"] }, - "v2": { "commando-from": ["vector-2"] } - } -} -``` - -Notice the use of `"commando-mutation"` and `"commando-from"` as string keys. - -To handle the custom `"dot-product"` mutation, you define a `defmethod` for `commando.commands.builtin/command-mutation` that dispatches on the string `"dot-product"`. When destructuring the parameters map, you must also use `:strs` to correctly access the string-keyed values (`v1`, `v2`). - -```clojure -(require '[commando.commands.builtin :as commands-builtin] - '[commando.core :as commando] - '[clojure.data.json :as json]) - -;; Define the mutation handler for the "dot-product" string identifier -(defmethod commands-builtin/command-mutation "dot-product" [_ {:strs [v1 v2]}] - (->> ["x" "y"] - (map #(* (get v1 %) (get v2 %))) - (reduce + 0))) - -;; Read the JSON file and execute the instruction -(let [json-string (slurp "vectors.json") - instruction (json/read-str json-string)] - (commando/execute - [commands-builtin/command-mutation-spec - commands-builtin/command-from-spec] - instruction)) -``` - -When executed, Commando correctly resolves the dependencies and applies the mutation, producing the final instruction map: - -```clojure -;; => -{:status :ok - :instruction - {"vector-1" {"x" 1, "y" 2}, - "vector-2" {"x" 4, "y" 5}, - "scalar-product-value" 14}} -``` - -By supporting string-based keys for its commands, Commando makes it easy to build powerful, data-driven systems that can be defined and serialized using the ubiquitous JSON format. For more details on creating custom commands, see the [main README](../README.md). - -## Important Note on String-Based Commands - -It's important to understand that only a select few core commands have direct string-based equivalents for JSON interoperability. These are primarily: - -* `commando.commands.builtin/command-macro-spec` (`"commando-macro"`) -* `commando.commands.builtin/command-from-spec` (`"commando-from"`) -* `commando.commands.builtin/command-mutation-spec` (`"commando-mutation"`) -* `commando.commands.query-dsl/command-resolve-spec` (`"commando-resolve"`) - -Other commands, such as `:commando/apply` or `:commando/fn`, are more tightly coupled with Clojure's functional mechanisms and do not have direct string-based aliases. - -### Leveraging `commando-macro-spec` for JSON Instructions - -For scenarios where you need to define complex logic using string keys in JSON, but still want to utilize Clojure-specific commands, `commando-macro-spec` (with its string alias `"commando-macro"`) is your most powerful tool. - -You can define a macro with a string identifier in your Clojure code, and within that macro's `defmethod`, you can use any Clojure-idiomatic commands (e.g., `:commando/apply`, `:commando/from`, or custom Clojure-based commands). - -This allows you to declare high-level logic in your JSON instruction using string keys, while encapsulating the more intricate, Clojure-specific command structures within the macro definition. The macro acts as a bridge, expanding the JSON-friendly instruction into a full Clojure-based Commando instruction at runtime. - -Here is a brief example illustrating the concept. - -**JSON Instruction:** -```json -{ - "calculation-result": { - "commando-macro": "calculate-and-format", - "input-a": 10, - "input-b": 25 - } -} -``` - -**Commando Macro Definition:** -```clojure -(require '[commando.commands.builtin :as commands-builtin]) - -(defmethod commands-builtin/command-macro "calculate-and-format" - [_ {:strs [input-a input-b]}] - ;; Inside the macro, we can use Clojure-native commands with keywords - ;; to define the complex logic that will be expanded at runtime. - {:= :formatted-output - :commando/apply - {:raw-result {:commando/fn (fn [& [a b]] (+ a b)) - :args [input-a input-b]} - :formatted-output {:commando/fn (fn [& args] (apply str args)) - :args ["The result is: " {:commando/from [:commando/apply :raw-result]}]}}}) -;; => "35" -``` - -In this example, the JSON file uses the string-based `"commando-macro"` to invoke `"calculate-and-format"`. The corresponding `defmethod` in Clojure takes the string inputs, then expands into a more complex instruction using keyword-based commands like `:commando/apply`, `:commando/fn`, and `:commando/from` to perform the actual logic. diff --git a/doc/reagent.md b/doc/reagent.md deleted file mode 100644 index 69f8bef..0000000 --- a/doc/reagent.md +++ /dev/null @@ -1,185 +0,0 @@ -# Using Commando with Reagent: Best Practices and Patterns - -This guide demonstrates how to integrate [Commando](../README.md) with [Reagent](https://reagent-project.github.io/), using a practical form component example for managing car data. - -## Classic Reagent Form Example - -Let's start with a typical Reagent form containing two fields for describing a car (e.g., Mazda MX-5). Typically, we store form state in a `reagent/atom`: - -```clojure -(defn form-component [] - (let [state (reagent.core/atom {:car-model "" - :car-specs ""})] - (fn [] - [:div - [:div "Car model" - [:input {:defaultValue (:car-model @state) - :on-change #(swap! state assoc :car-model (.. % -target -value))}]] - [:div "Car Specs" - [:input {:defaultValue (:car-specs @state) - :on-change #(swap! state assoc :car-specs (.. % -target -value))}]] - [:input {:type "button" - :value "submit" - :on-click #(js/console.log @state)}]]))) -``` - -This is a standard Reagent controlled form. The state atom holds both fields. All logic—validation, transformation, etc. It typically implemented as separate functions or inline handlers. - ---- - -## Bringing Commando into the Form - -Usually, the next step is to create functions for validation, normalization, and data processing before submitting. The key idea behind Commando is to "soak up" all this logic into an **Instruction** — a declarative data structure representing dependencies and transformations. - -With Commando, we pass the state atom directly into the instruction. All dependencies and transformations are transparent, and changes in state automatically trigger reactive updates. - -```clojure -(require '[commando.core :as commando]) -(require '[commando.commands.builtin :as commands-builtin]) - -(defn form-component [] - (let [state (reagent.core/atom {:car-model "" - :car-specs ""}) - state-instruction - (reagent.core/track - (fn [] - (commando/execute - [commands-builtin/command-mutation-spec - commands-builtin/command-from-spec - commands-builtin/command-fn-spec] - {:state @state - :validation-car-model - {:commando/mutation :ui/validate - :value {:commando/from [:state :car-model]} - :validators - [(fn [v] - (when (empty? v) - "Enter car model name")) - (fn [v] - (when-not (re-matches #"(suzuki|mazda|honda|hyundai).*" v) - (str "Enter model for suzuki, mazda, honda, hyundai: " v))) - ]} - :validation-car-specs - {:commando/mutation :ui/validate - :value {:commando/from [:state :car-specs]} - :validators [(fn [v] - (when (empty? v) "Enter car specs"))]} - :validated? - {:commando/fn #(not (boolean (not-empty (concat %1 %2)))) - :args [{:commando/from [:validation-car-model]} - {:commando/from [:validation-car-specs]}]} - :on-change-event - {:commando/fn (fn [validated? data] - (when validated? - (js/console.log data))) - :args [{:commando/from [:validated?]} - {:commando/from [:state]}]}}))) - get-instruction-value (fn [kvs] (reagent.core/track (fn [] (get-in @state-instruction (into [:instruction] kvs))))) - set-value (fn [k v] (swap! state assoc k v))] - (fn [] - [:div - [:div - "Car model" - [:input {:defaultValue (:car-model @state) - :on-change #(set-value :car-model (.. % -target -value))}]] - (for [error-string @(get-instruction-value [:validation-car-model])] - [:span {:style {:color "darkred" :margin-left "10px"}} error-string]) - - [:div - "Car Specs" - [:input {:defaultValue (:car-specs @state) - :on-change #(set-value :car-specs (.. % -target -value))}]] - (for [error-string @(get-instruction-value [:validation-car-specs])] - [:span {:style {:color "darkred" :margin-left "10px"}} error-string]) - - [:input {:disabled (not @(get-instruction-value [:validated?])) - :type "button" - :on-click #(js/window.alert (js/JSON.stringify (clj->js @(get-instruction-value [:state])))) - :value "submit"}] - [:pre - (with-out-str - (cljs.pprint/pprint - (:instruction @state-instruction)))] - ]))) - -(defmethod commands-builtin/command-mutation :ui/validate [_ {:keys [value validators]}] - ;; The validator returns a list of error messages or nil. - (not-empty - (reduce - (fn [acc validator] - (if-let [msg (validator value)] - (conj acc msg) - acc)) - [] - validators))) -``` - -**All dependencies** (validation results, field values, etc.) are described as data, making it easy to compose and trace. Command results (like `:validated?`) drive UI state directly (e.g., disabling the submit button). - The instruction is a fully declarative pipeline. Changing business logic or validation rules. Is a matter of editing data, not code. - -## Why Is This Powerful? - -- **Abstraction and Reuse:** Validation and state logic are data-driven and can be reused for any number of fields. -- **UI Control:** The result of validation (`:validated?`) directly controls UI state. -- **Transparency:** All dependencies are visible in the instruction — no hidden wiring. - -## Front-End Optimizations - -In the pattern above, every state change triggers a full re-execution of the instruction, including parsing, dependency analysis, and command evaluation. This is fine for small forms, but for larger instructions or frequent updates, it can be inefficient. - -**Commando provides a compilation mechanism:** If your instruction structure is stable, you can "compile" it once, then re-execute only the final evaluation step as state changes. - -```clojure -(def form-instruction-compiler - (commando/build-compiler - [commands-builtin/command-mutation-spec - commands-builtin/command-from-spec - commands-builtin/command-fn-spec] - {:state {:car-model nil :car-specs nil} - :validation-car-model - {:commando/mutation :ui/validate - :value {:commando/from [:state :car-model]} - :validators ...} - ...})) -``` - -You must provide default values for all fields referenced by `:commando/from`. Otherwise, execution will fail if a key command pointing is missing(i.e. `:commando/from [:state :car-model]`need to be founded even if the value is nil) - -Now, in your component, you only need to update the state and re-execute the compiled instruction: - -```clojure -(defn form-component [] - (let [state (reagent.core/atom {:car-model "" - :car-specs ""}) - state-instruction - (reagent.core/track - (fn [] - (commando/execute - form-instruction-compiler - {:state @state - :validation-car-model - {:commando/mutation :ui/validate - :value {:commando/from [:state :car-model]} - :validators ...} - ...})))] - (fn [] - [:div ...]))) -``` - -**Result:** Evaluation time is now much faster(because it practicaly linear) for large instruction graphs. - -## Important Notes - -- The status-map returned by `commando/execute` can have `:status :failed`. This indicates a *problem with the instruction structure*, not a user or business error. UI code should not directly react to these errors, just as you wouldn't use exceptions for routine validation. - -Comment: Use validation results inside the instruction to drive UI, not the error status of Commando itself. - -## Summary - -**Commando** allows you to manage all form logic, validation, and state transformation declaratively, making your UI code simpler, more maintainable, and easier to reason about. - -- Describe all logic as data (instructions), not as imperative code. -- Use compiled instructions for performance when structure is stable. -- Reactivity, validation, and business logic are all unified in a single pipeline. -- If you have complex forms, multi-step wizards, or need to synchronize state with APIs, Commando's data-driven approach can help you scale up with minimal boilerplate. - diff --git a/examples/component.clj b/examples/component.clj new file mode 100644 index 0000000..8362da7 --- /dev/null +++ b/examples/component.clj @@ -0,0 +1,429 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando + Stuart Sierra's Component │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Component builds running systems from records and +;; dependency declarations. Aero reads tagged-literal +;; EDN configs with `#env`, `#profile`, `#ref`, etc. +;; +;; Together they solve a real problem: config values, +;; component constructors, and dependency wiring all +;; live in separate places. Commando unifies them — +;; config, deps, and assembly in one data structure. +;; +;; This walkthrough shows: +;; 1. Stub components you can run in the REPL +;; 2. The vanilla Component + Aero approach +;; 3. The same system built with Commando +;; 4. A full start/stop cycle +;; +;; Evaluate each form in your REPL to follow along. +;; Everything here is self-contained — no external deps +;; beyond Commando and Component. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. SETUP │ +;; └─────────────────────────────────────────────────────┘ + +(require '[commando.core :as commando]) +(require '[commando.commands.builtin :as builtin]) +(require '[com.stuartsierra.component :as component]) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. STUB COMPONENTS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; In a real app these would open connections and bind +;; ports. Here they just print and track their state. + +;; ── Database ────────────────────────────────────────── + +(defrecord Database [host port dbname + ;; managed state + connection] + component/Lifecycle + (start [this] + (println (str ";; [start] Database " host ":" port "/" dbname)) + (assoc this :connection {:status :connected + :uri (str "jdbc:sqlite://" host ":" port "/" dbname)})) + (stop [this] + (println ";; [stop] Database") + (assoc this :connection nil))) + +(defn new-database [config] + (map->Database config)) + +;; ── HTTP Server ─────────────────────────────────────── + +(defn stub-handler [request] + {:status 200 + :headers {"Content-Type" "text/plain"} + :body "OK"}) + +(defrecord HttpServer [port handler + ;; injected dependency + database + ;; managed state + server] + component/Lifecycle + (start [this] + (println (str ";; [start] HttpServer on port " port)) + (assoc this :server {:status :running + :port port + :handler handler})) + (stop [this] + (println (str ";; [stop] HttpServer on port " port)) + (assoc this :server nil))) + +(defn new-http-server [config] + (map->HttpServer config)) + +;; ── Scheduler ───────────────────────────────────────── +;; A third component to show how dependencies scale. + +(defrecord Scheduler [interval-ms + ;; injected + database + ;; managed + running?] + component/Lifecycle + (start [this] + (println (str ";; [start] Scheduler every " interval-ms "ms")) + (assoc this :running? true)) + (stop [this] + (println ";; [stop] Scheduler") + (assoc this :running? false))) + +(defn new-scheduler [config] + (map->Scheduler config)) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. THE VANILLA COMPONENT + AERO WAY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A typical Component system wires things up in code. +;; Config comes from Aero (or a plain map), constructors +;; are called by hand, and `using` declares deps. +;; +;; Imagine this Aero config.edn: +;; +;; {:db {:host #env DB_HOST +;; :port #long #or [#env DB_PORT "5432"] +;; :dbname #profile {:dev "dev.db" :prod "prod.db"}} +;; :http {:port #long #or [#env PORT "3000"]} +;; :scheduler {:interval-ms 5000}} +;; +;; Then in your code: + +(defn create-system-vanilla + "Build the system by hand — three separate concerns + interleaved: config, construction, and wiring." + [config] + (component/system-map + ;; 1) Config values scattered into constructors + :database (new-database (:db config)) + :http (component/using + (new-http-server (assoc (:http config) + :handler stub-handler)) + ;; 2) Dependency wiring — separate from config + {:database :database}) + :scheduler (component/using + (new-scheduler (:scheduler config)) + ;; 3) More wiring — easy to forget when adding + ;; a new component + {:database :database}))) + +;; Try it with hardcoded values (in real code, Aero +;; would read these from config.edn + env vars): + +(def config-vanilla + {:db {:host "localhost" :port 5432 :dbname "dev.db"} + :http {:port 3000} + :scheduler {:interval-ms 5000}}) + +(def sys-vanilla (component/start (create-system-vanilla config-vanilla))) +;; => ;; [start] Database localhost:5432/dev.db +;; => ;; [start] HttpServer on port 3000 +;; => ;; [start] Scheduler every 5000ms + +(component/stop sys-vanilla) +;; => ;; [stop] Scheduler +;; => ;; [stop] HttpServer on port 3000 +;; => ;; [stop] Database + +;; This works, but notice: +;; +;; 1. CONFIG + CONSTRUCTION + WIRING are three separate +;; things. Adding a component means editing all three. +;; +;; 2. Aero's `#ref` can cross-reference config values, +;; but it cannot reference *components*. Wiring is +;; always in code. +;; +;; 3. Shared values (like the host) must be duplicated +;; or extracted into a let-binding — there's no +;; declarative "use the same value" mechanism. +;; +;; 4. The config file (EDN) and the system constructor +;; (Clojure) must be kept in sync manually. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. CUSTOM COMMANDO COMMANDS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Three CommandMapSpecs that teach Commando how to +;; speak Component. Each is a plain map. + +;; ───────────────────────────────────────────────────── +;; :component/def +;; ───────────────────────────────────────────────────── +;; +;; Declares a component: its constructor, config, +;; and dependencies — all in one place. +;; +;; Example: +;; {:component/def {:constructor new-database +;; :using {:database :database}} +;; :host "localhost" +;; :port 5432} +;; +;; The `:apply` function: +;; 1. Calls the constructor with the config (sans meta keys) +;; 2. Attaches `component/using` if `:using` is present + +(def command-component-def-spec + {:type :component/def + :recognize-fn #(and (map? %) (contains? % :component/def)) + :apply (fn [_ _ component] + (let [{:keys [constructor using]} (:component/def component) + config (dissoc component :component/def) + instance (constructor config)] + (if using + (component/using instance using) + instance))) + :dependencies {:mode :all-inside}}) + +;; ───────────────────────────────────────────────────── +;; :component/system +;; ───────────────────────────────────────────────────── +;; +;; Collects resolved components into a +;; `component/system-map`. Each entry is a +;; `[keyword component-ref]` pair. +;; +;; Example: +;; {:component/system +;; [[:database {:commando/from ["db"]}] +;; [:http {:commando/from ["http-server"]}]]} + +(def command-component-system-spec + {:type :component/system + :recognize-fn #(and (map? %) (contains? % :component/system)) + :apply (fn [_ _ {entries :component/system}] + (apply component/system-map + (mapcat (fn [[k v]] [k v]) entries))) + :dependencies {:mode :all-inside}}) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. THE COMMANDO WAY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; The same system — config, construction, wiring, and +;; assembly — all in one data structure. Compare this +;; with section 3 above. + +(def registry + (commando/registry-create + [builtin/command-from-spec + command-component-def-spec + command-component-system-spec])) + +(def instruction + {;; ── Shared settings ──────────────────────────────── + ;; Referenced by any component via :commando/from. + ;; Change once — every component sees the update. + "settings" + {:host "localhost" + :port 5432 + :dbname "dev.db"} + + ;; ── Database ─────────────────────────────────────── + ;; Config + constructor + deps: one self-contained map. + "db" + {:component/def {:constructor new-database} + :host {:commando/from ["settings" :host]} + :port {:commando/from ["settings" :port]} + :dbname {:commando/from ["settings" :dbname]}} + + ;; ── HTTP Server ──────────────────────────────────── + ;; `:using` declares that this component needs :database + ;; injected from the system — same as `component/using`. + "http-server" + {:component/def {:constructor new-http-server + :using {:database :database}} + :port 3000 + :handler stub-handler} + + ;; ── Scheduler ────────────────────────────────────── + "scheduler" + {:component/def {:constructor new-scheduler + :using {:database :database}} + :interval-ms 5000} + + ;; ── Assemble the system ──────────────────────────── + ;; Produces a `component/system-map` ready to start. + "system" + {:component/system + [[:database {:commando/from ["db"]}] + [:http {:commando/from ["http-server"]}] + [:scheduler {:commando/from ["scheduler"]}]]}}) + + +;; Build it: + +(def result (commando/execute registry instruction)) + +(commando/ok? result) +;; => true + +(def sys (get-in result [:instruction "system"])) + +sys +;; => # + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. START & STOP │ +;; └─────────────────────────────────────────────────────┘ + +(def running (component/start sys)) +;; => ;; [start] Database localhost:5432/dev.db +;; => ;; [start] HttpServer on port 3000 +;; => ;; [start] Scheduler every 5000ms + +;; Inspect the running system: +(get-in running [:database]) +;; => #{:host ...} +(get-in running [:database :connection]) +;; => {:status :connected, :uri "jdbc:sqlite://localhost:5432/dev.db"} + +(get-in running [:http]) +;; => #{:port ...} +(get-in running [:http :server]) +;; => {:status :running, :port 3000, :handler #function} + +(component/stop running) +;; => ;; [stop] Scheduler +;; => ;; [stop] HttpServer on port 3000 +;; => ;; [stop] Database + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 7. SIDE-BY-SIDE COMPARISON │ +;; └─────────────────────────────────────────────────────┘ +;; +;; VANILLA (Component + Aero) │ COMMANDO +;; ─────────────────────────────│────────────────── +;; config.edn (Aero) │ instruction map +;; + read-config │ (plain data) +;; + #env, #profile, #ref │ +;; ─────────────────────────────│────────────────── +;; create-system fn │ :component/system +;; (new-database (:db cfg)) │ command +;; (component/using ...) │ +;; ─────────────────────────────│────────────────── +;; 3 files / places to edit │ 1 instruction +;; when adding a component │ to add a component +;; ─────────────────────────────│────────────────── +;; #ref only crosses config, │ :commando/from +;; not into wiring or code │ crosses everything +;; ─────────────────────────────│────────────────── +;; Aero tags are reader macros —│ Commands are data — +;; extensible but EDN-only │ work in EDN, JSON, +;; │ Transit, or code + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 8. BONUS: REPLACING AERO'S #env AND #profile │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Aero's most-used tags are `#env` and `#profile`. +;; Commando can replicate them as commands, making +;; your config fully portable (JSON, Transit, etc.) +;; without reader macro dependencies. + +;; ── :env/get — read an environment variable ─────────── + +(def command-env-spec + {:type :env/get + :recognize-fn #(and (map? %) (contains? % :env/get)) + :apply (fn [_ _ {:keys [env/get default]}] + (or (System/getenv get) default)) + :dependencies {:mode :none}}) + +;; ── :config/profile — switch on active profile ──────── + +(def command-profile-spec + {:type :config/profile + :recognize-fn #(and (map? %) (contains? % :config/profile)) + :apply (fn [instruction _ {:keys [config/profile]}] + (let [active (get-in instruction ["settings" :profile])] + (or (get profile active) + (get profile :default)))) + :dependencies {:mode :none}}) + +;; Now your instruction can read env vars and switch +;; on profiles — just like Aero, but as plain data: + +(def env-registry + (commando/registry-create + [builtin/command-from-spec + command-env-spec + command-profile-spec])) + +(:instruction + (commando/execute + env-registry + {"settings" {:profile :dev} + "config" {:db-host {:env/get "DB_HOST" :default "localhost"} + :db-port {:config/profile {:dev 5432 + :prod 5433 + :default 5000}}}})) +;; => {"settings" {:profile :dev} +;; "config" {:db-host "localhost" +;; :db-port 5432}} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 9. PUTTING IT ALL TOGETHER │ +;; └─────────────────────────────────────────────────────┘ +;; +;; In a real application: + +(defn build-system + "Takes a Commando instruction, returns a Component system + ready to start." + [instruction] + (let [result (commando/execute registry instruction)] + (if (commando/failed? result) + (throw (ex-info "Failed to build system" result)) + (get-in result [:instruction "system"])))) + +;; Usage: +;; +;; (def system (component/start (build-system instruction))) +;; ;; => ;; [start] Database localhost:5432/dev.db +;; ;; => ;; [start] HttpServer on port 3000 +;; ;; => ;; [start] Scheduler every 5000ms +;; +;; (component/stop system) +;; +;; One instruction. One place to edit. Commando resolves +;; the config. Component runs the system. diff --git a/examples/integrant.clj b/examples/integrant.clj new file mode 100644 index 0000000..dfd42bd --- /dev/null +++ b/examples/integrant.clj @@ -0,0 +1,320 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando + Integrant │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Integrant builds systems from a config map. Commando +;; builds config maps from instructions. Together they let +;; you describe infrastructure declaratively, with shared +;; settings, cross-references, and validation — things +;; that a plain Integrant map cannot do on its own. +;; +;; This walkthrough shows: +;; 1. Stub components you can run in the REPL +;; 2. The problem with vanilla Integrant configs +;; 3. Three custom commands that solve it +;; 4. A full end-to-end example with `ig/init` & `ig/halt!` +;; +;; Evaluate each form in your REPL to follow along. +;; Everything here is self-contained — no external deps +;; beyond Commando and Integrant. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. SETUP │ +;; └─────────────────────────────────────────────────────┘ + +(require '[commando.core :as commando]) +(require '[commando.commands.builtin :as builtin]) +(require '[integrant.core :as ig]) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. STUB COMPONENTS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; In a real app, these would be actual database pools +;; and HTTP servers. Here we define stub implementations +;; so the entire walkthrough is executable. + +;; A trivial Ring handler — returns a fixed response. +(defn stub-handler [request] + {:status 200 + :headers {"Content-Type" "text/plain"} + :body "OK"}) + +;; :component/sqlite — pretends to open a SQLite connection. +(defmethod ig/init-key :component/sqlite [_ config] + (println ";; [init] :component/sqlite" (select-keys config [:dbtype :dbname])) + (assoc config :status :running)) + +(defmethod ig/halt-key! :component/sqlite [_ component] + (println ";; [halt] :component/sqlite") + nil) + +;; :component/jetty — pretends to start an HTTP server. +(defmethod ig/init-key :component/jetty [_ {:keys [port] :as config}] + (println (str ";; [init] :component/jetty on port " port)) + (assoc config :status :running)) + +(defmethod ig/halt-key! :component/jetty [_ {:keys [port]}] + (println (str ";; [halt] :component/jetty on port " port)) + nil) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. THE VANILLA INTEGRANT WAY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A typical Integrant config for an HTTP server backed +;; by a SQLite database looks like this: + +(derive :app/db :component/sqlite) +(derive :app/http :component/jetty) + +(def config:integrant-native + {:app/db {:dbtype "sqlite" + :dbname "app.db" + :dir "./data"} + :app/http {:port 2500 + :handler stub-handler + :db (ig/ref :app/db)}}) + +;; Try it: + +(def sys-vanilla (ig/init config:integrant-native)) +;; => ;; [init] :component/sqlite {:dbtype "sqlite", :dbname "app.db"} +;; => ;; [init] :component/jetty on port 2500 + +(ig/halt! sys-vanilla) +;; => ;; [halt] :component/jetty on port 2500 +;; => ;; [halt] :component/sqlite + +;; This works, but notice three things: +;; +;; 1. `derive` calls are side effects scattered outside +;; the config — easy to forget when adding components. +;; +;; 2. The `:dir` value is duplicated if multiple components +;; need the same project root. +;; +;; 3. `ig/ref` is Integrant-specific. If you want to +;; validate, transform, or generate configs +;; programmatically, you're working against opaque +;; objects instead of plain data. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. THREE CUSTOM COMMANDS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; We define three CommandMapSpecs that teach Commando +;; how to speak Integrant. Each one is a plain map — +;; no macros, no special syntax. + +;; ───────────────────────────────────────────────────── +;; :integrant/component +;; ───────────────────────────────────────────────────── +;; +;; Declares a component and its alias. The `:apply` +;; function calls `derive` automatically, so you never +;; need a separate side-effecting step. + +(def command-integrant-component-spec + {:type :integrant/component + :recognize-fn #(and (map? %) (contains? % :integrant/component)) + :apply (fn [_ _ component] + (derive + (:integrant/component-alias component) + (:integrant/component component)) + component) + :dependencies {:mode :all-inside}}) + +;; ───────────────────────────────────────────────────── +;; :integrant/from +;; ───────────────────────────────────────────────────── +;; +;; A typed reference to another component. Works like +;; `:commando/from`, but produces an `ig/ref` instead +;; of a raw value. It also validates that the target +;; is actually an `:integrant/component`. + +(def command-integrant-from-spec + {:type :integrant/from + :recognize-fn #(and (map? %) (contains? % :integrant/from)) + :apply (fn [instruction _ {path :integrant/from :as cmd}] + (let [target (get-in instruction path)] + (if (contains? target :integrant/component-alias) + (ig/ref (:integrant/component-alias target)) + (throw (ex-info + ":integrant/from must point to an :integrant/component" + cmd))))) + :dependencies {:mode :point + :point-key [:integrant/from]}}) + +;; ───────────────────────────────────────────────────── +;; :integrant/system +;; ───────────────────────────────────────────────────── +;; +;; Collects resolved components into the final Integrant +;; config map — keyed by alias, with internal keys +;; stripped out. + +(def command-integrant-system-spec + {:type :integrant/system + :recognize-fn #(and (map? %) (contains? % :integrant/system)) + :apply (fn [_ _ {components :integrant/system}] + (reduce + (fn [acc c] + (assoc acc + (:integrant/component-alias c) + (dissoc c :integrant/component + :integrant/component-alias))) + {} + components)) + :dependencies {:mode :all-inside}}) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. THE COMMANDO WAY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Now the same system, described as a Commando instruction. +;; Shared settings live in one place. Components declare +;; themselves. References are validated at build time. + +(def registry + (commando/registry-create + [builtin/command-from-spec + command-integrant-component-spec + command-integrant-from-spec + command-integrant-system-spec])) + +(def instruction + {;; Shared settings — referenced by any component + "settings" {:project-root "./data"} + + ;; Database component + "db" + {"sqlite" + {:integrant/component :component/sqlite + :integrant/component-alias :app/db + :dbtype "sqlite" + :dbname "app.db" + :dir {:commando/from ["settings" :project-root]}}} + + ;; HTTP server component + "http-server" + {:integrant/component :component/jetty + :integrant/component-alias :app/http + :port 2500 + :handler stub-handler + :db {:integrant/from ["db" "sqlite"]}} + + ;; Assemble the final Integrant config + "integrant-config" + {:integrant/system + [{:commando/from ["db" "sqlite"]} + {:commando/from ["http-server"]}]}}) + +;; Build it: + +(def result (commando/execute registry instruction)) + +(commando/ok? result) +;; => true + +(def ig-config (get-in result [:instruction "integrant-config"])) + +ig-config +;; => {:app/db {:dbtype "sqlite", :dbname "app.db", :dir "./data"} +;; :app/http {:port 2500, :handler #function, :db #ig/ref :app/db}} + +;; That's a valid Integrant config. Start the system: + +(def system (ig/init ig-config)) +;; => ;; [init] :component/sqlite {:dbtype "sqlite", :dbname "app.db"} +;; => ;; [init] :component/jetty on port 2500 + +(ig/halt! system) +;; => ;; [halt] :component/jetty on port 2500 +;; => ;; [halt] :component/sqlite + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. WHY BOTHER? │ +;; └─────────────────────────────────────────────────────┘ +;; +;; For two components, the vanilla approach is fine. The +;; Commando approach pays off when your system grows: +;; +;; a) SHARED SETTINGS +;; `:commando/from` pulls values from a single source. +;; Change `:project-root` once — every component sees +;; the new value. No grep-and-replace. +;; +;; b) SELF-CONTAINED COMPONENTS +;; Each component carries its own `:integrant/component` +;; and alias. Adding a component means adding one map — +;; no separate `derive` call, no risk of forgetting it. +;; +;; c) VALIDATED REFERENCES +;; `:integrant/from` checks that the target is actually +;; a component. A typo in the path fails at build time, +;; not at runtime when `ig/init` tries to resolve a +;; missing ref. +;; +;; d) COMPOSABLE +;; Instructions are data. You can merge them, generate +;; them, or store them in EDN/JSON files. Try doing +;; that with `ig/ref` objects and `derive` side effects. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 7. BONUS: ENVIRONMENT VARIABLES │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Need to read env vars in your config? Define a command +;; for it — similar to how juxt/aero handles `#env`. + +(def command-env-spec + {:type :env/get + :recognize-fn #(and (map? %) (contains? % :env/get)) + :apply (fn [_ _ {:keys [env/get default]}] + (or (System/getenv get) default)) + :dependencies {:mode :none}}) + +;; Now use it anywhere in the instruction: + +(:instruction + (commando/execute + [command-env-spec] + {:home {:env/get "HOME" :default "~/"}})) +;; => {:home "/home/alice"} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 8. PUTTING IT ALL TOGETHER │ +;; └─────────────────────────────────────────────────────┘ +;; +;; In a real application, the pattern looks like this: + +(defn build-integrant-config + "Takes a Commando instruction, returns a ready Integrant config." + [instruction] + (let [result (commando/execute registry instruction)] + (if (commando/failed? result) + (throw (ex-info "Failed to build Integrant config" result)) + (get-in result [:instruction "integrant-config"])))) + +;; Usage: +;; +;; (def system (ig/init (build-integrant-config instruction))) +;; ;; => ;; [init] :component/sqlite {:dbtype "sqlite", :dbname "app.db"} +;; ;; => ;; [init] :component/jetty on port 2500 +;; +;; (ig/halt! system) +;; ;; => ;; [halt] :component/jetty on port 2500 +;; ;; => ;; [halt] :component/sqlite +;; +;; Commando builds the config. Integrant runs the system. +;; Each library does what it does best. diff --git a/examples/json.clj b/examples/json.clj new file mode 100644 index 0000000..b814646 --- /dev/null +++ b/examples/json.clj @@ -0,0 +1,252 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando + JSON │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Commando instructions are Clojure maps — but they don't +;; have to be written in Clojure. Because built-in commands +;; recognize both keyword keys (:commando/from) and string +;; keys ("commando-from"), you can author instructions in +;; plain JSON, parse them, and execute as-is. +;; +;; This walkthrough shows: +;; 1. Keywords vs. strings — what maps to what +;; 2. A JSON instruction executed from a file +;; 3. Using macros as a bridge to Clojure-only features +;; +;; Evaluate each form in your REPL to follow along. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. SETUP │ +;; └─────────────────────────────────────────────────────┘ + +(require '[commando.core :as commando]) +(require '[commando.commands.builtin :as builtin]) +(require '[clojure.data.json :as json]) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. KEYWORDS VS. STRINGS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; In Clojure you write: +;; +;; {:commando/from [:greeting]} +;; +;; In JSON there are no keywords, so the equivalent is: +;; +;; {"commando-from": ["greeting"]} +;; +;; Commando's built-in commands handle both forms. +;; The mapping: +;; +;; Clojure keyword JSON string +;; ────────────────── ────────────────── +;; :commando/from "commando-from" +;; :commando/mutation "commando-mutation" +;; :commando/macro "commando-macro" +;; :=> "=>" +;; +;; Let's verify — same instruction, string keys: + +(:instruction + (commando/execute + [builtin/command-from-spec] + {"greeting" "hello" + "copy" {"commando-from" ["greeting"]}})) +;; => {"greeting" "hello", "copy" "hello"} + +;; Drivers work too: + +(:instruction + (commando/execute + [builtin/command-from-spec] + {"user" {"name" "Bob" "age" 30} + "name" {"commando-from" ["user"] "=>" ["get" "name"]}})) +;; => {"user" {"name" "Bob", "age" 30}, "name" "Bob"} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. EXECUTING A JSON FILE │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Suppose you have a file `vectors.json`: +;; +;; { +;; "vector-1": { "x": 1, "y": 2 }, +;; "vector-2": { "x": 4, "y": 5 }, +;; "scalar-product": { +;; "commando-mutation": "dot-product", +;; "v1": { "commando-from": ["vector-1"] }, +;; "v2": { "commando-from": ["vector-2"] } +;; } +;; } +;; +;; First, define the mutation. Note the string dispatch +;; value "dot-product" and `:strs` destructuring — because +;; the keys coming from JSON are strings, not keywords. + +(defmethod builtin/command-mutation "dot-product" + [_ {:strs [v1 v2]}] + (reduce + (map #(* (get v1 %) (get v2 %)) ["x" "y"]))) + +;; Now parse and execute: + +(def json-instruction + (json/read-str + "{ + \"vector-1\": { \"x\": 1, \"y\": 2 }, + \"vector-2\": { \"x\": 4, \"y\": 5 }, + \"scalar-product\": { + \"commando-mutation\": \"dot-product\", + \"v1\": { \"commando-from\": [\"vector-1\"] }, + \"v2\": { \"commando-from\": [\"vector-2\"] } + } + }")) + +(:instruction + (commando/execute + [builtin/command-mutation-spec + builtin/command-from-spec] + json-instruction)) +;; => {"vector-1" {"x" 1, "y" 2}, +;; "vector-2" {"x" 4, "y" 5}, +;; "scalar-product" 14} +;; +;; 1*4 + 2*5 = 14. Correct. + +;; In a real project it's just: +;; +;; (-> (slurp "vectors.json") +;; (json/read-str) +;; (->> (commando/execute registry))) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. WHICH COMMANDS HAVE STRING ALIASES? │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Not every built-in command has a string counterpart. +;; The JSON-friendly ones: +;; +;; "commando-from" — reference another value +;; "commando-mutation" — side-effecting operation +;; "commando-macro" — expand a template +;; "commando-context" — injects external context +;; +;; These do NOT have string aliases (they need Clojure +;; features like functions or raw data): +;; +;; :commando/fn — calls a function +;; :commando/apply — returns data as-is +;; +;; This is intentional. JSON is data — it shouldn't +;; contain executable code. If you need computation +;; inside a JSON instruction, use a macro. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. MACROS AS A BRIDGE │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A macro is the escape hatch from JSON into Clojure. +;; The JSON side declares *what* to do (string keys). +;; The macro expands into *how* to do it (keywords, +;; functions, anything Clojure can express). +;; +;; JSON instruction: +;; +;; { +;; "result": { +;; "commando-macro": "add-and-format", +;; "a": 10, +;; "b": 25 +;; } +;; } +;; +;; Clojure macro definition: + +(defmethod builtin/command-macro "add-and-format" + [_ {:strs [a b]}] + ;; Inside the macro we have full Clojure — keywords, + ;; functions, nested commands, anything. + {:commando/fn str + :args [(+ a b)]}) + +(:instruction + (commando/execute + [builtin/command-macro-spec + builtin/command-fn-spec] + {"result" {"commando-macro" "add-and-format" + "a" 10 + "b" 25}})) +;; => {"result" "35"} + +;; The JSON author only needs to know the macro's name +;; and parameters. All Clojure-specific logic lives in +;; the defmethod — invisible to the JSON side. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. A LARGER EXAMPLE │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A reporting pipeline defined in JSON. Three mutations +;; chained via dependencies — fetch data, aggregate it, +;; format the output. + +(defmethod builtin/command-mutation "fetch-sales" + [_ {:strs [region]}] + ;; Imagine a database query here + {"region" region "units" 150 "revenue" 45000}) + +(defmethod builtin/command-mutation "aggregate" + [_ {:strs [data]}] + {"avg-price" (/ (get data "revenue") (get data "units")) + "region" (get data "region")}) + +(defmethod builtin/command-mutation "format-report" + [_ {:strs [summary]}] + (str "Region: " (get summary "region") + " | Avg: $" (get summary "avg-price") + " | Status: OK")) + +(def report-json + (json/read-str + "{ + \"raw\": { \"commando-mutation\": \"fetch-sales\", + \"region\": \"EU-West\" }, + \"stats\": { \"commando-mutation\": \"aggregate\", + \"data\": { \"commando-from\": [\"raw\"] } }, + \"report\": { \"commando-mutation\": \"format-report\", + \"summary\": { \"commando-from\": [\"stats\"] } } + }")) + +(:instruction + (commando/execute + [builtin/command-mutation-spec + builtin/command-from-spec] + report-json)) +;; => {"raw" {"region" "EU-West", "units" 150, "revenue" 45000}, +;; "stats" {"avg-price" 300, "region" "EU-West"}, +;; "report" "Region: EU-West | Avg: $300 | Status: OK"} +;; +;; Commando resolved the dependency chain automatically: +;; "report" → "stats" → "raw" + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ SUMMARY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; • Built-in commands work with string keys — JSON +;; instructions execute without any conversion. +;; +;; • Use "commando-from", "commando-mutation", and +;; "commando-macro" as string-key equivalents. +;; +;; • Macros bridge JSON and Clojure: declare intent in +;; JSON, implement logic in a defmethod. +;; +;; • Dependencies, drivers ("=>"), and validation all +;; work identically with string keys. diff --git a/doc/query_dsl.md b/examples/query_dsl.md similarity index 100% rename from doc/query_dsl.md rename to examples/query_dsl.md diff --git a/examples/reagent_front.cljs b/examples/reagent_front.cljs new file mode 100644 index 0000000..f32319c --- /dev/null +++ b/examples/reagent_front.cljs @@ -0,0 +1,320 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando + Reagent — Frontend Patterns │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Commando instructions are plain data — maps with embedded +;; commands. On the frontend this gives you two powers: +;; +;; 1. LOCAL execution — run instructions in the browser +;; for validation, derived state, and reactive UI. +;; +;; 2. REMOTE execution — send instructions to the server +;; via Transit POST and merge the result into state. +;; +;; Both paths share the same data format. +;; This file shows how to use each one with Reagent. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. SETUP │ +;; └─────────────────────────────────────────────────────┘ + +(ns examples.reagent-front + (:require + [commando.core :as commando] + [commando.commands.builtin :as builtin] + [reagent.core :as r])) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. THE IDEA │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A Reagent atom holds mutable state. +;; A Commando instruction describes what to compute from +;; that state — validation, derived values, predicates. +;; +;; When the atom changes, the instruction re-executes and +;; the UI reacts. All logic is data, not imperative code. +;; +;; ┌───────────┐ ┌─────────────────┐ ┌────────┐ +;; │ r/atom │───►│ instruction │───►│ UI │ +;; │ {:name _} │ │ {:valid? ...} │ │ reacts │ +;; └───────────┘ └─────────────────┘ └────────┘ +;; +;; The instruction sits between raw state and the UI. +;; It replaces scattered `let` bindings, inline validators, +;; and ad-hoc derived-state functions with a single +;; declarative pipeline. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. LOCAL EXECUTION — reactive form │ +;; └─────────────────────────────────────────────────────┘ +;; +;; `commando/execute` runs entirely in the browser. +;; Wrap it in `r/track` so Reagent re-evaluates it +;; whenever the atom changes. + +(defn form-component [] + (let [state (r/atom {:car-model "" :car-specs ""}) + + ;; The instruction. Every key is either plain data + ;; or a command that Commando recognizes and evaluates. + result + (r/track + (fn [] + (commando/execute + [builtin/command-mutation-spec + builtin/command-from-spec + builtin/command-fn-spec] + {;; Embed current state as a plain-data key. + :state @state + + ;; Validate :car-model — mutation returns error list. + :errors-model + {:commando/mutation :ui/validate + :value {:commando/from [:state :car-model]} + :validators + [(fn [v] (when (empty? v) "Enter car model")) + (fn [v] (when-not (re-matches #"(mazda|honda).*" v) + "Only mazda or honda"))]} + + ;; Validate :car-specs. + :errors-specs + {:commando/mutation :ui/validate + :value {:commando/from [:state :car-specs]} + :validators + [(fn [v] (when (empty? v) "Enter specs"))]} + + ;; Derive a boolean from both validations. + :valid? + {:commando/fn + (fn [e1 e2] (and (empty? e1) (empty? e2))) + :args [{:commando/from [:errors-model]} + {:commando/from [:errors-specs]}]}}))) + + ;; Helper — read a key from the evaluated instruction. + get-val (fn [k] (get-in @result [:instruction k]))] + + ;; Render function — re-runs when `result` changes. + (fn [] + [:div + [:div "Car model" + [:input {:on-change #(swap! state assoc :car-model + (.. % -target -value))}]] + (for [msg (get-val :errors-model)] + [:span {:style {:color "red"}} msg]) + + [:div "Car specs" + [:input {:on-change #(swap! state assoc :car-specs + (.. % -target -value))}]] + (for [msg (get-val :errors-specs)] + [:span {:style {:color "red"}} msg]) + + [:button {:disabled (not (get-val :valid?))} + "Submit"]]))) + +;; ───────────────────────────────────────────────────── +;; The :ui/validate mutation +;; ───────────────────────────────────────────────────── +;; +;; Mutations are defined with `defmethod`. This one runs +;; a list of validator functions and collects error strings. + +(defmethod builtin/command-mutation :ui/validate + [_ {:keys [value validators]}] + (not-empty + (into [] + (keep (fn [validator] (validator value))) + validators))) + +;; NOTE: The `:status` returned by `commando/execute` can +;; be `:failed` — but that signals a structural problem +;; (missing dependency, broken command), not a user error. +;; For user-facing validation, read instruction keys like +;; `:errors-model` above. Don't branch on `:status` in UI. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. REMOTE EXECUTION — server as the engine │ +;; └─────────────────────────────────────────────────────┘ +;; +;; The same instruction format works over HTTP. The client +;; builds an instruction, POSTs it as Transit to a single +;; `/commando` endpoint, and the server evaluates it +;; against its registry (resolvers, mutations, macros). +;; +;; The server returns a status-map: +;; +;; {:status :ok, :instruction {...evaluated...}} +;; +;; The client takes `:instruction` from the response +;; and merges it into its local state. +;; +;; ───────────────────────────────────────────────────── +;; Sending an instruction +;; ───────────────────────────────────────────────────── +;; +;; A minimal Transit POST looks like this: + +(comment + ;; Using cljs-ajax: + (ajax/POST "/commando" + {:format (ajax/transit-request-format {}) + :response-format (ajax/transit-response-format {}) + :params {:instruction + {:companies + {:commando/resolve :Company + :where [:= :active true] + :QueryExpression [:id :name :tax-number]}}} + :handler (fn [result] + (when (commando/ok? result) + ;; (:instruction result) is the evaluated map + (reset! app-state (:instruction result))))})) + +;; The instruction can mix queries, mutations, and +;; dependencies — just like locally. The server resolves +;; the graph and returns everything at once. +;; +;; {:instruction +;; {:user {:commando/mutation :save-user +;; :name "Alice" :email "a@b.com"} +;; :send-mail {:commando/mutation :send-greeting-mail +;; :user {:commando/from [:user]}} +;; :status {:commando/from [:user] :=> [:get :status]}}} +;; +;; One POST, one response, no REST choreography. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. STATE MANAGEMENT PATTERN │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A practical pattern for Reagent components that talk +;; to the server: +;; +;; atom + instruction → POST /commando → merge result +;; +;; The idea: component owns a `r/atom`. A helper function +;; sends an instruction to the server and deep-merges the +;; response back into the atom. +;; +;; ───────────────────────────────────────────────────── +;; mutate-http — send instruction, merge result +;; ───────────────────────────────────────────────────── + +(defn mutate-http + "POST instruction to /commando. On success, deep-merge + the evaluated instruction into the atom." + [state-atom instruction] + ;; (ajax/POST "/commando" ...) + ;; on-success: (swap! state-atom deep-merge (:instruction result)) + ) + +;; ───────────────────────────────────────────────────── +;; mutate-local — execute locally, merge result +;; ───────────────────────────────────────────────────── + +(defn mutate-local + "Execute instruction in the browser with the given + registry and merge the result into the atom." + [state-atom registry instruction] + (let [result (commando/execute registry instruction)] + (when (commando/ok? result) + (swap! state-atom merge (:instruction result))))) + +;; ───────────────────────────────────────────────────── +;; Usage in a component +;; ───────────────────────────────────────────────────── +;; +;; Both functions share the same contract: +;; (mutate-* atom instruction) → atom is updated +;; +;; The component doesn't care whether the instruction +;; ran locally or on the server — it just reads the atom. + +(defn search-component [] + (let [state (r/atom {:loading false + :results []})] + (fn [] + [:div + [:input + {:placeholder "Search..." + :on-change + (fn [e] + ;; Build an instruction and send it to the server. + ;; The server resolves :Company and returns data. + ;; mutate-http merges {:results [...]} into the atom. + (mutate-local state {:loading true}) + (mutate-http state + {:results + {:commando/mutation :CompanyFindByName + :searching-string (.. e -target -value)} + :loading false}))}] + + (when (:loading @state) + [:span "Loading..."]) + + (for [company (:results @state)] + [:div {:key (:id company)} (:name company)])]))) + +;; This pattern scales: +;; +;; • Add debounce for frequent updates: +;; (def search-debounced (debounce mutate-http 200)) +;; +;; • Add :on-error callback for error handling. +;; +;; • Use mutate-local for client-only logic (validation, +;; formatting) and mutate-http for server queries. +;; +;; • The atom is the single source of truth. The instruction +;; is just a recipe for updating it. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. LOCAL + REMOTE — mixing both │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A common pattern: validate locally, submit remotely. +;; Local execution is instant, server calls are async. + +(comment + ;; On every keystroke — validate locally: + (mutate-local state + [builtin/command-fn-spec] + {:valid? {:commando/fn #(> (count %) 2) + :args [(:search-text @state)]}}) + + ;; On submit — send to server: + (when (:valid? @state) + (mutate-http state + {:result {:commando/mutation :search + :query (:search-text @state)}}))) + +;; The instruction format is the same in both cases. +;; What changes is where it executes and what commands +;; are available (the registry). + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ SUMMARY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; • Instructions are data — build them from component +;; state, merge results back into state. +;; +;; • LOCAL: `commando/execute` in `r/track` for reactive +;; derived state (validation, computed fields). +;; +;; • REMOTE: POST instruction to `/commando`, get back +;; evaluated map, merge into atom. +;; +;; • `mutate-local` and `mutate-http` are the two +;; fundamental operations. Both take an atom and an +;; instruction, both update the atom with the result. +;; +;; • The server registry controls what commands are +;; available — it's the API surface. The client just +;; describes what it needs. diff --git a/examples/reitit.clj b/examples/reitit.clj new file mode 100644 index 0000000..d69c256 --- /dev/null +++ b/examples/reitit.clj @@ -0,0 +1,242 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando + Reitit │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Commando instructions are plain data — maps with +;; embedded commands. That means a frontend (or any HTTP +;; client) can send an instruction as a request body, +;; and the server evaluates it and returns the result. +;; +;; This walkthrough shows how to expose Commando as a +;; single POST endpoint behind Reitit + Ring, using +;; Transit for serialization. +;; +;; The pattern: +;; Client sends → {:instruction {...}} +;; Server runs → (commando/execute registry instruction) +;; Server returns → {:status :ok, :instruction {...}} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. SETUP │ +;; └─────────────────────────────────────────────────────┘ + +(require '[commando.core :as commando]) +(require '[commando.commands.builtin :as builtin]) +(require '[reitit.http :as http]) +(require '[reitit.interceptor.sieppari :as reitit-sieppari]) +(require '[reitit.ring :as ring]) +(require '[ring.middleware.transit :as ring-transit]) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. THE REGISTRY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Define which commands the endpoint accepts. This is +;; your API surface — only commands in the registry can +;; be executed. Nothing else gets through. + +(def api-registry + (commando/registry-create + [builtin/command-from-spec + builtin/command-apply-spec + builtin/command-fn-spec + builtin/command-mutation-spec])) + +;; You can have multiple registries for different +;; endpoints. A production app might look like: +;; +;; /commando → rest-api-registry (queries, mutations) +;; /commando-admin → admin-registry (settings, system specific) +;; /commando-debug → rest-api-registry + debug hooks +;; +;; Each endpoint exposes a different set of capabilities, +;; all through the same execution model. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. THE HANDLER │ +;; └─────────────────────────────────────────────────────┘ +;; +;; The handler extracts the instruction from the request +;; body, executes it, and returns the status-map. +;; +;; Transit middleware (wrap-transit-body / wrap-transit- +;; response) handles serialization — the instruction +;; arrives as a Clojure map, keywords intact. + +(defn- commando-handler [request] + (if-let [instruction (:instruction (:body request))] + (let [result (commando/execute api-registry instruction)] + (if (commando/ok? result) + {:status 200 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body (select-keys result [:status :instruction :successes])} + {:status 500 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body (select-keys result [:status :errors :warnings :successes])})) + {:status 400 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body {:status :failed + :errors [{:message "Instruction is empty"}]}})) + +;; That's the entire backend. No controllers, no route +;; params, no manual validation per endpoint. The +;; instruction IS the request. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. WIRING IT INTO REITIT │ +;; └─────────────────────────────────────────────────────┘ +;; +;; One POST route. Transit middleware wraps the handler +;; so the client sends and receives Transit+JSON. + +(def app + (http/ring-handler + (http/router + [["/commando" + {:post {:handler (-> commando-handler + ring-transit/wrap-transit-body + ring-transit/wrap-transit-response)}}]]) + (ring/create-default-handler) + {:executor reitit-sieppari/executor})) + +;; Start your server (Jetty, http-kit, etc.) with `app` +;; as the handler and you're done. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. CLIENT SIDE │ +;; └─────────────────────────────────────────────────────┘ +;; +;; The client sends a Transit-encoded map with an +;; :instruction key. Here's what the request body +;; looks like (before Transit encoding): +;; +;; {:instruction +;; {:user-name "Alice" +;; :greeting {:commando/from [:user-name]}}} +;; +;; The server returns: +;; +;; {:status :ok +;; :instruction {:user-name "Alice" +;; :greeting "Alice"}} +;; +;; A more realistic example — the client asks the server +;; to save a user and return the result: +;; +;; {:instruction +;; {:user {:commando/mutation :save-user +;; :name "Alice" +;; :email "alice@example.com"} +;; :id {:commando/from [:user] :=> [:get :id]}}} +;; +;; The server resolves the dependency chain, executes +;; the mutation, and returns the evaluated instruction. +;; The client never constructs SQL or calls REST +;; endpoints — it describes what it needs, and Commando +;; figures out the execution order. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. MULTIPLE REGISTRIES, MULTIPLE ENDPOINTS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; A registry defines what a client is allowed to do. +;; Different endpoints can expose different registries — +;; same execution model, different permissions. +;; +;; Example: a public API that only reads data, and an +;; admin API that can also write. + +(def public-registry + (commando/registry-create + [builtin/command-from-spec + builtin/command-apply-spec])) + +(def admin-registry + (commando/registry-create + [builtin/command-from-spec + builtin/command-apply-spec + builtin/command-fn-spec + builtin/command-mutation-spec])) + +;; The handler is the same — only the registry changes. + +(defn- make-commando-handler [registry] + (fn [request] + (if-let [instruction (:instruction (:body request))] + (let [result (commando/execute registry instruction)] + (if (commando/ok? result) + {:status 200 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body (select-keys result [:status :instruction :successes])} + {:status 500 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body (select-keys result [:status :errors :warnings :successes])})) + {:status 400 + :headers {"Content-Type" "application/transit+json; charset=UTF-8"} + :body {:status :failed + :errors [{:message "Instruction is empty"}]}}))) + +(defn- wrap-transit [handler] + (-> handler + ring-transit/wrap-transit-body + ring-transit/wrap-transit-response)) + +(def app-multi + (http/ring-handler + (http/router + [["/api/query" + {:post {:handler (wrap-transit (make-commando-handler public-registry))}}] + ["/api/admin" + {:post {:handler (wrap-transit (make-commando-handler admin-registry))}}]]) + (ring/create-default-handler) + {:executor reitit-sieppari/executor})) + +;; A client hitting /api/query can use :commando/from +;; and :commando/apply — read-only operations. If they +;; try :commando/mutation, Commando won't recognize it +;; because it's not in the public-registry. +;; +;; A client hitting /api/admin gets the full set — +;; mutations, functions, everything. +;; +;; The registry is the access control. No middleware +;; needed to filter operations — unregistered commands +;; are simply invisible. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 7. WHY THIS WORKS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; a) ONE ENDPOINT, INFINITE QUERIES +;; The client doesn't hit /users, /orders, /reports. +;; It sends an instruction to /commando. New mutations +;; and queries are added server-side via `defmethod` — +;; no new routes, no new controllers. +;; +;; b) THE REGISTRY IS YOUR API CONTRACT +;; Only commands in the registry can execute. The +;; client can't call arbitrary code — just the +;; operations you explicitly allow. +;; +;; c) DEPENDENCIES RESOLVE AUTOMATICALLY +;; The client says "I need :user, then :email depends +;; on :user". Commando sorts the execution order. +;; The client doesn't care which runs first. +;; +;; d) TRANSIT PRESERVES TYPES +;; Keywords, UUIDs, dates — Transit keeps them intact. +;; The instruction arrives as idiomatic Clojure data. +;; No manual parsing, no string-to-keyword conversion. +;; +;; e) COMPOSABLE ON THE CLIENT +;; Instructions are data. The frontend can merge +;; partial instructions, build them conditionally, +;; or cache and replay them. Try doing that with +;; a traditional REST API. diff --git a/examples/walkthrough.clj b/examples/walkthrough.clj new file mode 100644 index 0000000..4b2b272 --- /dev/null +++ b/examples/walkthrough.clj @@ -0,0 +1,471 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ Commando Walkthrough │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Evaluate each form in your REPL to see how it works. +;; By the end you will know every core building block. + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 1. GETTING STARTED │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Two namespaces are all you need: +;; • `commando.core` — the execution engine +;; • `commando.commands.builtin` — ready-to-use commands + +(require '[commando.core :as commando]) +(require '[commando.commands.builtin :as builtin]) + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 2. INSTRUCTIONS AND COMMANDS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; An "instruction" is a Clojure map where: +;; • Some values are "commands" — patterns that Commando +;; recognizes and evaluates. +;; • Everything else is plain data, left untouched. +;; +;; The first argument to `execute` is a "registry" — a vector +;; of command specs that tells Commando which patterns to +;; look for. The second argument is the instruction itself. + +(commando/execute + [builtin/command-from-spec] + {:greeting "hello" + :copy {:commando/from [:greeting]}}) +;; => {:status :ok, +;; :instruction {:greeting "hello", :copy "hello"}, ...} +;; +;; NOTE: Here we pass only `command-from-spec`, so only +;; `:commando/from` is recognized as a command. + +;; Commands can depend on other commands. Commando builds a +;; dependency graph and executes them in the right order — +;; you don't have to think about it. + +(commando/execute + [builtin/command-from-spec] + {:a 1 + :b {:commando/from [:a]} + :c {:commando/from [:b]} + :d {:commando/from [:c]}}) +;; => {:instruction {:a 1, :b 1, :c 1, :d 1}, ...} +;; +;; :d → :c → :b → :a — resolved automatically. + +;; ───────────────────────────────────────────────────── +;; Checking the result +;; ───────────────────────────────────────────────────── +;; +;; `execute` returns a status-map. Use `ok?` / `failed?`: + +(let [result (commando/execute + [builtin/command-from-spec] + {:a 1 :b {:commando/from [:a]}})] + (commando/ok? result)) +;; => true +;; +;; To get just the evaluated instruction: + +(let [result (commando/execute + [builtin/command-from-spec] + {:a 1 :b {:commando/from [:a]}})] + (:instruction result)) +;; => {:a 1, :b 1} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 3. BUILTIN COMMANDS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; Commando ships with six commands. Each one is a +;; CommandMapSpec — a config map that describes how to +;; recognize, validate, and execute a command type. + +;; ───────────────────────────────────────────────────── +;; :commando/from +;; ───────────────────────────────────────────────────── +;; +;; Retrieves a value from the instruction by path. + +(:instruction + (commando/execute + [builtin/command-from-spec] + {:catalog {:price 99} + :ref {:commando/from [:catalog :price]}})) +;; => {:catalog {:price 99}, :ref 99} + +;; ───────────────────────────────────────────────────── +;; :commando/from — relative paths +;; ───────────────────────────────────────────────────── +;; +;; `"../"` goes up one level from the command's position. +;; Each command resolves relative to where it sits. + +(:instruction + (commando/execute + [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}} + +;; ───────────────────────────────────────────────────── +;; :commando/from — named anchors +;; ───────────────────────────────────────────────────── +;; +;; Mark any map with `:__anchor`, then jump to it with +;; `"@name"` regardless of nesting depth. Each command +;; resolves to its nearest matching anchor. + +(:instruction + (commando/execute + [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}]} + +;; ───────────────────────────────────────────────────── +;; :commando/fn +;; ───────────────────────────────────────────────────── +;; +;; Calls a function with arguments. Arguments can be plain +;; values or other commands (like `:commando/from`). + +(:instruction + (commando/execute + [builtin/command-fn-spec + builtin/command-from-spec] + {:x 3 + :y 4 + :sum {:commando/fn + :args [{:commando/from [:x]} + {:commando/from [:y]}]}})) +;; => {:x 3, :y 4, :sum 7} + +;; ───────────────────────────────────────────────────── +;; :commando/apply +;; ───────────────────────────────────────────────────── +;; +;; Returns its value as-is — useful as a scoping container. +;; Combine with `:=>` to extract from the result. +;; +;; NOTE: `:=>`/`"=>"` is a driver operator which allows +;; accessing/transforming the result of an executed command. +;; Explained in part 4. + +(:instruction + (commando/execute + [builtin/command-apply-spec] + {:result {:commando/apply {:nested {:value 42}} + :=> [:get-in [:nested :value]]}})) +;; => {:result 42} + +;; ───────────────────────────────────────────────────── +;; :commando/context +;; ───────────────────────────────────────────────────── +;; +;; Injects external data (config, dictionaries) into an +;; instruction without duplicating it. +;; +;; NOTE: `command-context-spec` is a function — call it +;; with your context map to get a CommandMapSpec. + +(def app-config + {:db-host "localhost" + :features {:dark-mode true}}) + +(:instruction + (commando/execute + [(builtin/command-context-spec app-config) + builtin/command-from-spec] + {:host {:commando/context [:db-host]} + :dark? {:commando/context [:features :dark-mode]} + :missing {:commando/context [:nope] :default "fallback"} + :host-copy {:commando/from [:host]}})) +;; => {:host "localhost", :dark? true, +;; :missing "fallback", :host-copy "localhost"} + +;; ───────────────────────────────────────────────────── +;; :commando/mutation +;; ───────────────────────────────────────────────────── +;; +;; For side effects — database inserts, API calls, etc. +;; Define handlers via `defmethod` on `builtin/command-mutation`. + +;; Example 1 — :save-user +;; +;; A mutation that simulates saving a user to a database. +;; The result feeds into a dependent command. + +(defmethod builtin/command-mutation :save-user [_ {:keys [name email]}] + (println "Saving user to database...") + (Thread/sleep 300) + {:id (random-uuid) :name name :email email :status :created}) + +(:instruction + (commando/execute + [builtin/command-mutation-spec + builtin/command-from-spec] + {:user {:commando/mutation :save-user + :name "Alice" :email "alice@example.com"} + :status {:commando/from [:user] :=> [:get :status]}})) +;; REPL output => Saving user to database... +;; => {:user {:id #uuid "...", :name "Alice", :email "alice@example.com", :status :created}, +;; :status :created} + +;; Example 2 — :send-email (chained mutations) +;; +;; A second mutation that depends on the first one's result. +;; Commando resolves the dependency automatically — :send-email +;; waits for :save-user to finish. + +(defmethod builtin/command-mutation :send-email [_ {:keys [to subject]}] + (println (str "Sending email to " to "...")) + (Thread/sleep 500) + {:sent true :to to :subject subject}) + +(:instruction + (commando/execute + [builtin/command-mutation-spec + builtin/command-from-spec] + {:user {:commando/mutation :save-user + :name "Alice" :email "alice@example.com"} + :email {:commando/mutation :send-email + :to {:commando/from [:user] :=> [:get :email]} + :subject "Welcome aboard!"}})) +;; REPL output: +;; Saving user to database... ← runs first (dependency) +;; Sending email to alice@example.com... +;; => {:user {:id #uuid "...", :name "Alice", ...}, +;; :email {:sent true, :to "alice@example.com", :subject "Welcome aboard!"}} + +;; ───────────────────────────────────────────────────── +;; :commando/macro +;; ───────────────────────────────────────────────────── +;; +;; Macros let you define reusable instruction templates. +;; A macro expands into a sub-instruction that Commando +;; evaluates recursively. + +(defmethod builtin/command-macro :double [_ params] + {:commando/apply (* 2 (:value params))}) + +(:instruction + (commando/execute + [builtin/command-macro-spec + builtin/command-apply-spec] + {:a {:commando/macro :double :value 5} + :b {:commando/macro :double :value 21}})) +;; => {:a 10, :b 42} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 4. DRIVERS (POST-PROCESSING) │ +;; └─────────────────────────────────────────────────────┘ +;; +;; After a command produces a result, a "driver" can +;; transform it. Drivers are declared with the `:=>` key +;; on any command. +;; +;; TIP: Without `:=>`, the result passes through unchanged +;; (identity driver). + +;; ───────────────────────────────────────────────────── +;; Built-in drivers +;; ───────────────────────────────────────────────────── +;; +;; • `:identity` — pass-through value (default) +;; • `:get` — extract a single key +;; • `:get-in` — extract a value at a deep path +;; • `:select-keys` — pick a subset of keys +;; • `:fn` — apply an arbitrary function +;; • `:projection` — rename and reshape fields (pure data) + +(:instruction + (commando/execute + [builtin/command-from-spec] + {:user {:name "Alice" :age 30} + :name {:commando/from [:user] :=> [:get :name]}})) +;; => {:user {:name "Alice", :age 30}, :name "Alice"} + +(:instruction + (commando/execute + [builtin/command-from-spec] + {:user {:name "Alice" :age 30 :password "secret"} + :safe {:commando/from [:user] :=> [:select-keys [:name :age]]}})) +;; => {:user {...}, :safe {:name "Alice", :age 30}} + +(:instruction + (commando/execute + [builtin/command-from-spec] + {:price 100 + :with-tax {:commando/from [:price] :=> [:fn #(* % 1.2)]}})) +;; => {:price 100, :with-tax 120.0} + +;; ───────────────────────────────────────────────────── +;; Pipelines +;; ───────────────────────────────────────────────────── +;; +;; When the first element of `:=>` is a vector, it becomes +;; a pipeline. Steps are chained left-to-right — each +;; step's output feeds into the next: + +(:instruction + (commando/execute + [builtin/command-from-spec] + {:data {:profile {:name "alice"}} + :result {:commando/from [:data] + :=> [[:get :profile] [:get :name] [:fn clojure.string/upper-case]]}})) +;; => {:data {...}, :result "ALICE"} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 5. CUSTOM COMMANDS │ +;; └─────────────────────────────────────────────────────┘ +;; +;; You can define entirely new command types with a +;; CommandMapSpec — a map with four keys: +;; • `:type` — unique keyword identifier +;; • `:recognize-fn` — predicate to identify the command +;; • `:apply` — execution function +;; • `:dependencies` — dependency mode + +(:instruction + (commando/execute + [{:type :shout + :recognize-fn #(and (map? %) (contains? % :shout)) + :apply (fn [_instruction _cmd-spec m] + (clojure.string/upper-case (:shout m))) + :dependencies {:mode :none}}] + {:a {:shout "hello world"} + :b {:shout "commando is neat"}})) +;; => {:a "HELLO WORLD", :b "COMMANDO IS NEAT"} + +;; ───────────────────────────────────────────────────── +;; Non-map commands +;; ───────────────────────────────────────────────────── +;; +;; Commands are not limited to maps — anything your +;; `:recognize-fn` can match works. Here's a command +;; that recognizes strings ending with "!": + +(:instruction + (commando/execute + [{:type :bang + :recognize-fn #(and (string? %) (clojure.string/ends-with? % "!")) + :apply (fn [_ _ s] (clojure.string/upper-case s)) + :dependencies {:mode :none}}] + {:calm "hello" :excited "hello!"})) +;; => {:calm "hello", :excited "HELLO!"} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 6. JSON COMPATIBILITY │ +;; └─────────────────────────────────────────────────────┘ +;; +;; All built-in commands work with string keys for JSON +;; interop. Use `"commando-from"`, `"commando-fn"`, +;; `"=>"` etc. + +(:instruction + (commando/execute + [builtin/command-from-spec] + {"user" {"name" "Bob"} + "ref" {"commando-from" ["user"] "=>" ["get" "name"]}})) +;; => {"user" {"name" "Bob"}, "ref" "Bob"} + + +;; ┌─────────────────────────────────────────────────────┐ +;; │ 7. DEBUGGING │ +;; └─────────────────────────────────────────────────────┘ +;; +;; The `commando.debug` namespace helps visualize +;; what's happening inside an instruction. + +(require '[commando.debug :as debug]) + +;; • execute-debug +;; +;; Runs an instruction and prints a visual representation. +;; Modes: `:tree`, `:table`, `:graph`, `:stats` + +(debug/execute-debug + [builtin/command-from-spec] + {:a 1 + :b {:commando/from [:a]} + :c {:commando/from [:b] :=> [:fn inc]}} + :table) + +;; or with multiple modes in a row + +(debug/execute-debug + [builtin/command-from-spec] + {:a 1 + :b {:commando/from [:a]} + :c {:commando/from [:b] :=> [:fn inc]}} + [:instr-before :graph :instr-after :stats]) + +;; • execute-trace +;; +;; Shows timing/keys for all nested `execute` calls +;; (including recursive calls from macros/mutations). +;; +;; Here we define two arithmetic macros — :sum and :multiply — +;; and a :sum-of-products macro that uses them internally. +;; This creates three levels of nested execution: +;; +;; Level 1: top instruction → :sum-of-products macro +;; Level 2: :sum-of-products expands → two :multiply macros +;; Level 3: each :multiply expands → :commando/fn * + +(defmethod builtin/command-macro :sum [_ {:keys [a b]}] + {:__title "Sum :a + :b" + :commando/fn + :args [a b]}) + +(defmethod builtin/command-macro :multiply [_ {:keys [a b]}] + {:commando/fn * :args [a b]}) + +(defmethod builtin/command-macro :sum-of-products [_ {:keys [a b c d]}] + {:__title "Sum of products" + :p1 {:commando/macro :multiply :a a :b b} + :p2 {:commando/macro :multiply :a c :b d} + :p3 {:commando/sum :sum :a {:commando/from [:p1]} :b {:commando/from [:p2]}}}) + +(:instruction + (debug/execute-trace + [builtin/command-from-spec + builtin/command-fn-spec + builtin/command-macro-spec + builtin/command-apply-spec] + {:__title "Result Of Two" + :result-1 {:commando/macro :sum-of-products :a 2 :b 3 :c 4 :d 5} + :result-2 {:commando/macro :sum-of-products :a 2 :b 3 :c 4 :d 5}})) +;; => {:result 26} +;; because (2*3) + (4*5) = 6 + 20 = 26 +;; +;; The trace output will show three nested execute calls: +;; 1. Top-level instruction {:result ...} +;; 2. :sum-of-products {:p1 ... :p2 ... :commando/apply ...} +;; 3. :multiply {:commando/apply {:commando/fn * ...}} +;; +;; TIP: Use optional key `:__title` at the top-level instruction map +;; to name step of execution in output. + +;; ┌─────────────────────────────────────────────────────┐ +;; │ THAT'S IT! │ +;; └─────────────────────────────────────────────────────┘ +;; +;; You now know the core building blocks: +;; • Instructions — maps with data + commands +;; • Six builtin commands: `:commando/from`, `:commando/fn`, +;; `:commando/apply`, `:commando/context`, +;; `:commando/mutation`, `:commando/macro` +;; • Drivers (`:=>`) for post-processing + pipelines +;; • Custom commands via CommandMapSpec +;; +;; See the README for the full reference. Happy building! diff --git a/pom.xml b/pom.xml index f7f170f..1f50b6b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ jar org.clojars.funkcjonariusze commando - 1.1.0 + 1.2.0 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.1.0 + 1.2.0 diff --git a/src/commando/commands/builtin.cljc b/src/commando/commands/builtin.cljc index a543a11..74d22c1 100644 --- a/src/commando/commands/builtin.cljc +++ b/src/commando/commands/builtin.cljc @@ -6,9 +6,17 @@ [malli.core :as malli] [malli.error :as malli-error])) -;; ====================== -;; Fn -;; ====================== +;; == Fn =================================================== + +(def ^:private -schema:command-fn + [:map + [:commando/fn utils/ResolvableFn] + [:args {:optional true} coll?] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-fn (malli/validator -schema:command-fn)) +(def ^:private -explainer:command-fn (malli/explainer -schema:command-fn)) (def ^{:doc " @@ -39,26 +47,25 @@ {:type :commando/fn :recognize-fn #(and (map? %) (contains? % :commando/fn)) :validate-params-fn (fn [m] - (if-let [m-explain - (malli-error/humanize - (malli/explain - [:map - [:commando/fn utils/ResolvableFn] - [:args {:optional true} coll?] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m))] - m-explain - true)) + (if (-validator:command-fn m) + true + (malli-error/humanize (-explainer:command-fn m)))) :apply (fn [_instruction _command-map m] (let [m-fn (utils/resolve-fn (:commando/fn m)) m-args (:args m []) result (apply m-fn m-args)] result)) :dependencies {:mode :all-inside}}) -;; ====================== -;; Apply -;; ====================== +;; == Apply ================================================ + +(def ^:private -schema:command-apply + [:map + [:commando/apply :any] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-apply (malli/validator -schema:command-apply)) +(def ^:private -explainer:command-apply (malli/explainer -schema:command-apply)) (def ^{:doc " Description @@ -80,27 +87,38 @@ {:type :commando/apply :recognize-fn #(and (map? %) (contains? % :commando/apply)) :validate-params-fn (fn [m] - (if-let [m-explain - (malli-error/humanize - (malli/explain [:map - [:commando/apply :any] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] m))] - m-explain - true)) + (if (-validator:command-apply m) + true + (malli-error/humanize (-explainer:command-apply m)))) :apply (fn [_instruction _command-path-obj command-map] (:commando/apply command-map)) :dependencies {:mode :all-inside}}) -;; ====================== -;; From -;; ====================== +;; == From ================================================= (def ^:private -malli:commando-from-path (malli/deref [:sequential {:error/message "commando/from should be a sequence path to value in Instruction: [:some 2 \"value\"]"} [:or :string :keyword :int]])) +(def ^:private -schema:command-from-kw + [:map + [:commando/from -malli:commando-from-path] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-from-kw (malli/validator -schema:command-from-kw)) +(def ^:private -explainer:command-from-kw (malli/explainer -schema:command-from-kw)) + +(def ^:private -schema:command-from-str + [:map + ["commando-from" -malli:commando-from-path] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-from-str (malli/validator -schema:command-from-str)) +(def ^:private -explainer:command-from-str (malli/explainer -schema:command-from-str)) + (def ^{:doc " Description @@ -162,22 +180,12 @@ "The keyword :commando/from and the string \"commando-from\" cannot be used simultaneously in one command." (contains? m :commando/from) - (malli-error/humanize - (malli/explain - [:map - [:commando/from -malli:commando-from-path] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)) + (when-not (-validator:command-from-kw m) + (malli-error/humanize (-explainer:command-from-kw m))) (contains? m "commando-from") - (malli-error/humanize - (malli/explain - [:map - ["commando-from" -malli:commando-from-path] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)))] + (when-not (-validator:command-from-str m) + (malli-error/humanize (-explainer:command-from-str m))))] (if m-explain m-explain true))) @@ -188,9 +196,27 @@ :point-key [:commando/from "commando-from"]}}) -;; ====================== -;; Context -;; ====================== +;; == Context ============================================== + +(def ^:private -schema:command-context-kw + [:map + [:commando/context [:sequential {:error/message "commando/context should be a sequential path: [:some :key]"} + [:or :string :keyword :int]]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-context-kw (malli/validator -schema:command-context-kw)) +(def ^:private -explainer:command-context-kw (malli/explainer -schema:command-context-kw)) + +(def ^:private -schema:command-context-str + [:map + ["commando-context" [:sequential {:error/message "commando-context should be a sequential path: [\"some\" \"key\"]"} + [:or :string :keyword :int]]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-context-str (malli/validator -schema:command-context-str)) +(def ^:private -explainer:command-context-str (malli/explainer -schema:command-context-str)) (defn command-context-spec "Creates a CommandMapSpec that resolves references to external context data @@ -233,46 +259,31 @@ `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} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - 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} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)))] - (if m-explain m-explain true))) - :apply - (fn [_instruction _command-path-obj command-map] - (let [path (or (get command-map kw-key) (get command-map str-key))] - (get-in ctx path nil))) - :dependencies {:mode :none}})) - -;; ====================== -;; Mutation -;; ====================== + {:type :commando/context + :recognize-fn #(and (map? %) + (or + (contains? % :commando/context) + (contains? % "commando-context"))) + :validate-params-fn + (fn [m] + (let [m-explain + (cond + (and (contains? m :commando/context) (contains? m "commando-context")) + "The keyword :commando/context and the string \"commando-context\" cannot be used simultaneously in one command." + (contains? m :commando/context) + (when-not (-validator:command-context-kw m) + (malli-error/humanize (-explainer:command-context-kw m))) + (contains? m "commando-context") + (when-not (-validator:command-context-str m) + (malli-error/humanize (-explainer:command-context-str m))))] + (if m-explain m-explain true))) + :apply + (fn [_instruction _command-path-obj command-map] + (let [path (or (get command-map :commando/context) (get command-map "commando-context"))] + (get-in ctx path nil))) + :dependencies {:mode :none}}) + +;; == Mutation ============================================= (defmulti command-mutation (fn [tx-type _data] tx-type)) (defmethod command-mutation :default @@ -285,6 +296,22 @@ "'") {:commando/mutation undefined-tx-type}))) +(def ^:private -schema:command-mutation-kw + [:map [:commando/mutation [:or :keyword :string]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-mutation-kw (malli/validator -schema:command-mutation-kw)) +(def ^:private -explainer:command-mutation-kw (malli/explainer -schema:command-mutation-kw)) + +(def ^:private -schema:command-mutation-str + [:map ["commando-mutation" [:or :keyword :string]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-mutation-str (malli/validator -schema:command-mutation-str)) +(def ^:private -explainer:command-mutation-str (malli/explainer -schema:command-mutation-str)) + (def ^{:doc " Description command-mutation-spec - execute mutation of Instruction data. @@ -339,19 +366,11 @@ (contains? m "commando-mutation")) "The keyword :commando/mutation and the string \"commando-mutation\" cannot be used simultaneously in one command." (contains? m :commando/mutation) - (malli-error/humanize - (malli/explain - [:map [:commando/mutation [:or :keyword :string]] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)) + (when-not (-validator:command-mutation-kw m) + (malli-error/humanize (-explainer:command-mutation-kw m))) (contains? m "commando-mutation") - (malli-error/humanize - (malli/explain - [:map ["commando-mutation" [:or :keyword :string]] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)))] + (when-not (-validator:command-mutation-str m) + (malli-error/humanize (-explainer:command-mutation-str m))))] (if m-explain m-explain true))) @@ -363,9 +382,7 @@ (command-mutation (get m "commando-mutation") (dissoc m "commando-mutation")))) :dependencies {:mode :all-inside}}) -;; ====================== -;; Macro -;; ====================== +;; == Macro ================================================ (defmulti command-macro (fn [tx-type _data] tx-type)) (defmethod command-macro :default @@ -375,6 +392,24 @@ "command-macro. Undefinied '" undefinied-tx-type "'") {:commando/macro undefinied-tx-type}))) +(def ^:private -schema:command-macro-kw + [:map + [:commando/macro [:or :keyword :string]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-macro-kw (malli/validator -schema:command-macro-kw)) +(def ^:private -explainer:command-macro-kw (malli/explainer -schema:command-macro-kw)) + +(def ^:private -schema:command-macro-str + [:map + ["commando-macro" [:or :keyword :string]] + [:=> {:optional true} utils/malli:driver-spec] + ["=>" {:optional true} utils/malli:driver-spec]]) + +(def ^:private -validator:command-macro-str (malli/validator -schema:command-macro-str)) +(def ^:private -explainer:command-macro-str (malli/explainer -schema:command-macro-str)) + (def ^{:doc " Description command-macro-spec - help to define reusable instruction template, @@ -391,7 +426,7 @@ (:instruction (commando/execute [command-fn-spec command-from-spec command-apply-spec] - {:= :dot-product + {:=> [:get :dot-product] :commando/apply {:vector1-str [\"1\" \"2\" \"3\"] :vector2-str [\"4\" \"5\" \"6\"] @@ -420,7 +455,7 @@ To solve this problem we can use `command-macro` to define reusable instruction template (defmethod command-macro :vector-dot-product [_macro-type {:keys [vector1-str vector2-str]}] - {:= :dot-product + {:=> [:get :dot-product] :commando/apply {:vector1-str vector1-str :vector2-str vector2-str @@ -474,29 +509,19 @@ (contains? m "commando-macro")) "The keyword :commando/macro and the string \"commando-macro\" cannot be used simultaneously in one command." (contains? m :commando/macro) - (malli-error/humanize - (malli/explain - [:map - [:commando/macro [:or :keyword :string]] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)) + (when-not (-validator:command-macro-kw m) + (malli-error/humanize (-explainer:command-macro-kw m))) (contains? m "commando-macro") - (malli-error/humanize - (malli/explain - [:map - ["commando-macro" [:or :keyword :string]] - [:=> {:optional true} utils/malli:driver-spec] - ["=>" {:optional true} utils/malli:driver-spec]] - m)))] + (when-not (-validator:command-macro-str m) + (malli-error/humanize (-explainer:command-macro-str m))))] (if m-explain m-explain true))) :apply (fn [_instruction _command-map m] (let [[macro-type macro-data] (cond - (get m :commando/macro) [(get m :commando/macro) (dissoc m :commando/macro)] - (get m "commando-macro") [(get m "commando-macro") (dissoc m "commando-macro")]) + (contains? m :commando/macro) [(get m :commando/macro) (dissoc m :commando/macro)] + (contains? m "commando-macro") [(get m "commando-macro") (dissoc m "commando-macro")]) result (commando/execute (utils/command-map-spec-registry) (command-macro macro-type macro-data))] diff --git a/src/commando/commands/query_dsl.cljc b/src/commando/commands/query_dsl.cljc index 477b646..bb259c2 100644 --- a/src/commando/commands/query_dsl.cljc +++ b/src/commando/commands/query_dsl.cljc @@ -330,6 +330,18 @@ "Unedefinied command-resolve '" undefinied-tx-type "'") {:resolver/tx undefinied-tx-type}))) +(def ^:private -explainer:command-resolve-kw + (malli/explainer + [:map + [:commando/resolve :keyword] + [:QueryExpression {:optional true} QueryExpressionMalli]])) + +(def ^:private -explainer:command-resolve-str + (malli/explainer + [:map + ["commando-resolve" [:string {:min 1}]] + ["QueryExpression" {:optional true} QueryExpressionMalli]])) + (def ^{:doc " Description command-resolve-spec - behave like command-mutation-spec @@ -518,22 +530,10 @@ "The keyword :commando/resolve and the string \"commando-resolve\" cannot be used simultaneously in one command." (contains? m :commando/resolve) (malli-error/humanize - (malli/explain - [:map - [:commando/resolve :keyword] - [:QueryExpression - {:optional true} - QueryExpressionMalli]] - m)) + (-explainer:command-resolve-kw m)) (contains? m "commando-resolve") (malli-error/humanize - (malli/explain - [:map - ["commando-resolve" [:string {:min 1}]] - ["QueryExpression" - {:optional true} - QueryExpressionMalli]] - m)))] + (-explainer:command-resolve-str m)))] (if m-explain m-explain true))) diff --git a/src/commando/core.cljc b/src/commando/core.cljc index 6152572..5e32ee9 100644 --- a/src/commando/core.cljc +++ b/src/commando/core.cljc @@ -1,6 +1,6 @@ (ns commando.core (:require - [commando.impl.dependency :as deps] + [commando.impl.dependency :as dependency] [commando.impl.executing :as executing] [commando.impl.finding-commands :as finding-commands] [commando.impl.graph :as graph] @@ -90,17 +90,21 @@ [{: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"}))))) + (let [{:keys [commands trie]} (finding-commands/find-commands instruction registry)] + (-> sm + (assoc :internal/cm-list commands) + (assoc :internal/path-trie trie) + (smap/status-map-handle-success {:message "Commands were successfully collected"})))))) (defn ^:private build-deps-tree - [{:keys [instruction] :internal/keys [cm-list] :as status-map}] + "Builds forward dependency graph using the path-trie produced by find-commands." + [{:keys [instruction] :internal/keys [cm-list path-trie] :as status-map}] (smap/core-step-safe status-map "build-deps-tree" (fn [sm] - (-> sm - (assoc :internal/cm-dependency (deps/build-dependency-graph instruction cm-list)) - (smap/status-map-handle-success {:message "Dependency map was successfully built"}))))) + (let [fwd (dependency/build-dependency-graph instruction cm-list path-trie)] + (-> sm + (assoc :internal/cm-dependency fwd) + (smap/status-map-handle-success {:message "Dependency map was successfully built"})))))) (defn ^:private sort-commands-by-deps [status-map] @@ -126,16 +130,19 @@ (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)] + (let [[updated-instruction error-info cm-results] + (executing/execute-commands instruction cm-running-order)] (if error-info (-> sm (assoc :instruction updated-instruction) + (assoc :internal/cm-results cm-results) (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) + (assoc :internal/cm-results cm-results) (smap/status-map-handle-success {:message "All commands executed successfully"}))))))))) (defn ^:private prepare-execution-status-map [status-map] @@ -145,30 +152,44 @@ (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 (vector? registry) (registry/built? registry))]} - (binding [utils/*execute-internals* (utils/-execute-internals-push (str (random-uuid)))] - (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))))))) +;; -- Public API -- (defn failed? [status-map] (smap/failed? status-map)) (defn ok? [status-map] (smap/ok? status-map)) +(defn execute + "Evaluates an instruction with a command registry. + + The optional third argument is a config map: + :error-data-string - (boolean) serialize exception data as strings + :hook-execute-start - (fn [status-map]) called before execution + :hook-execute-end - (fn [status-map]) called after execution + + Config keys are inherited by nested execute calls. Inner calls can + override specific keys — non-overridden keys come from the parent. + + Examples: + ;; Full execution + (execute reg instruction) + + ;; With config + (execute reg instruction {:error-data-string false})" + ([registry instruction] (execute registry instruction nil)) + ([registry instruction opts] + {:pre [(or (vector? registry) (registry/built? registry))]} + (binding [utils/*execute-internals* (utils/-execute-internals-push (str (random-uuid))) + utils/*execute-config* (utils/execute-config-update opts)] + (let [start-time (utils/now) + config (utils/execute-config)] + (-> (smap/status-map-pure {:instruction instruction}) + (utils/hook-process (:hook-execute-start 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 config)) + (assoc :internal/original-instruction instruction)))))) + diff --git a/src/commando/debug.cljc b/src/commando/debug.cljc new file mode 100644 index 0000000..29b2f6f --- /dev/null +++ b/src/commando/debug.cljc @@ -0,0 +1,570 @@ +(ns commando.debug + "Debug visualization tools for commando instruction execution." + (:require + [commando.core :as commando] + [commando.impl.command-map :as cm] + [commando.impl.utils :as utils] + [clojure.string :as str] + #?(:clj [clojure.pprint :as pp]))) + +;; == Frame helpers ======================================= + +(def ^:private frame-width 60) + +(defn ^:private frame-top + [title] + (let [prefix (str "── " title " ") + fill (max 0 (- frame-width (count prefix)))] + (println) + (println (str prefix (str/join (repeat fill "─")))))) + +(defn ^:private frame-bottom [] + (println (str/join (repeat frame-width "─")))) + + +;; == Shared helpers ====================================== + +(defn ^:private top-level-cmd? + [cmd] + (= 1 (count (cm/command-path cmd)))) + +(defn ^:private value-exists-at-path? + [instr path] + (not= ::missing (get-in instr path ::missing))) + +(def ^:private pprint-width 120) + +(defn ^:private pprint-wide + [data] + #?(:clj (binding [pp/*print-right-margin* pprint-width + *print-namespace-maps* false] + (pp/pprint data)) + :cljs (println (pr-str data)))) + + +;; == Command description ================================= + +(defn ^:private describe-driver + [command-data] + (let [raw (or (get command-data :=>) (get command-data "=>"))] + (when raw + (cond + (keyword? raw) (name raw) + (string? raw) raw + (vector? raw) + (let [first-el (first raw)] + (if (or (vector? first-el) (sequential? first-el)) + (str "pipe " (str/join " | " (map #(if (vector? %) (name (first %)) (name %)) raw))) + (let [drv-name (if (string? first-el) first-el (name first-el))] + (if (> (count raw) 1) + (str drv-name " " (str/join " " (map pr-str (rest raw)))) + drv-name)))) + :else nil)))) + +(defn ^:private cmd-describe + [command-data] + (let [driver (describe-driver command-data) + base + (cond + (not (map? command-data)) + {:kind :data :label "data"} + + (contains? command-data :commando/mutation) + (let [mut-name (name (:commando/mutation command-data)) + params (cond-> [] + (:percent command-data) (conj (str (:percent command-data) "%")) + (:factor command-data) (conj (str "×" (:factor command-data))))] + {:kind :mutation + :label (str mut-name (when (seq params) (str " " (str/join " " params))))}) + + (contains? command-data :commando/from) + {:kind :from + :label "from" + :source (last (:commando/from command-data))} + + (contains? command-data :commando/context) + {:kind :context :label "context"} + + (contains? command-data :commando/fn) + (let [n (count (:args command-data []))] + {:kind :fn + :label (str "fn" (when (pos? n) (str " ×" n " args")))}) + + (contains? command-data :commando/apply) + {:kind :apply :label "apply"} + + (contains? command-data :commando/macro) + {:kind :macro + :label (str "macro:" (name (:commando/macro command-data)))} + + :else + {:kind :data :label "data"})] + (if driver + (assoc base :driver driver) + base))) + +(defn ^:private format-driver-suffix + [desc] + (if-let [drv (:driver desc)] + (str " => " drv) + "")) + +(defn ^:private format-path-name + [path] + (if (<= (count path) 1) + (str (last path)) + (str/join "." (map str path)))) + +(defn ^:private format-node-label + [key-name desc value show-value?] + (let [drv (format-driver-suffix desc)] + (if show-value? + (case (:kind desc) + :from (str key-name " ← " (:source desc) drv " = " (pr-str value)) + (str key-name " ‹" (:label desc) "›" drv " = " (pr-str value))) + (case (:kind desc) + :from (str key-name " ← " (:source desc) drv) + (str key-name " ‹" (:label desc) "›" drv))))) + +(defn ^:private format-node-label-no-value + [key-name desc] + (format-node-label key-name desc nil false)) + + +;; == Graph building ====================================== + +(defn ^:private resolve-top-deps + [cmd cm-dependency top-cmds] + (loop [frontier (get cm-dependency cmd #{}) + visited #{} + result #{}] + (if (empty? frontier) + result + (let [node (first frontier) + frontier (disj frontier node)] + (if (contains? visited node) + (recur frontier visited result) + (if (contains? top-cmds node) + (recur frontier (conj visited node) (conj result node)) + (recur (into frontier (get cm-dependency node #{})) + (conj visited node) + result))))))) + +(defn ^:private build-simplified-graph + [cm-dependency cm-running-order] + (let [all-cmds (set (keys cm-dependency)) + top-cmds (set (filter top-level-cmd? all-cmds)) + simplified-deps + (into {} (map (fn [cmd] [cmd (resolve-top-deps cmd cm-dependency top-cmds)]) top-cmds)) + dependents + (reduce-kv + (fn [acc cmd cmd-deps] + (reduce (fn [a dep] (update a dep (fnil conj #{}) cmd)) + acc cmd-deps)) + {} simplified-deps) + order-index (into {} (map-indexed (fn [i c] [c i]) cm-running-order)) + top-order (sort-by #(get order-index % 999) + (filter top-level-cmd? cm-running-order)) + roots (filterv #(empty? (get simplified-deps % #{})) top-order) + sorted-dependents + (reduce-kv + (fn [acc parent children-set] + (assoc acc parent (sort-by #(get order-index % 999) children-set))) + {} dependents)] + {:deps simplified-deps + :dependents sorted-dependents + :roots roots + :order (vec top-order)})) + +(defn ^:private build-full-graph + [cm-dependency cm-running-order] + (let [non-root? (fn [cmd] (seq (cm/command-path cmd))) + visible (set (filter non-root? (keys cm-dependency))) + visible-deps + (into {} (map (fn [cmd] + [cmd (set (filter visible (get cm-dependency cmd #{})))]) + visible)) + dependents + (reduce-kv + (fn [acc cmd cmd-deps] + (reduce (fn [a dep] (update a dep (fnil conj #{}) cmd)) + acc cmd-deps)) + {} visible-deps) + order-index (into {} (map-indexed (fn [i c] [c i]) cm-running-order)) + visible-order (sort-by #(get order-index % 999) + (filter non-root? cm-running-order)) + roots (filterv #(empty? (get visible-deps % #{})) visible-order) + sorted-dependents + (reduce-kv + (fn [acc parent children-set] + (assoc acc parent (sort-by #(get order-index % 999) children-set))) + {} dependents)] + {:deps visible-deps + :dependents sorted-dependents + :roots roots + :order (vec visible-order)})) + + +;; == Modes =============================================== + +;; -- :tree -- + +(defn ^:private pprint-tree + [result original-instruction] + (let [cm-dep (:internal/cm-dependency result) + cm-order (:internal/cm-running-order result) + instr (:instruction result) + {:keys [roots dependents]} (build-full-graph cm-dep cm-order) + printed (atom #{})] + (frame-top "Data Flow") + (letfn [(get-orig [cmd] (get-in original-instruction (cm/command-path cmd))) + (get-val [cmd] (get-in instr (cm/command-path cmd))) + (node-label [cmd] + (let [path (cm/command-path cmd) + orig (get-orig cmd) + value (get-val cmd) + desc (cmd-describe (if (map? orig) orig {})) + has-value? (value-exists-at-path? instr path) + key-name (format-path-name path)] + (format-node-label key-name desc value has-value?))) + (print-node [cmd prefix is-last] + (let [connector (if is-last "└─► " "├─► ") + revisit? (contains? @printed cmd) + label (node-label cmd)] + (if revisit? + (println (str " " prefix connector label " ⤶")) + (do + (swap! printed conj cmd) + (println (str " " prefix connector label)) + (let [kids (get dependents cmd []) + new-prefix (str prefix (if is-last " " "│ "))] + (doseq [[i child] (map-indexed vector kids)] + (print-node child new-prefix (= i (dec (count kids))))))))))] + (let [rv (vec roots)] + (doseq [[i root] (map-indexed vector rv)] + (swap! printed conj root) + (println (str " " (node-label root))) + (let [kids (get dependents root [])] + (doseq [[j child] (map-indexed vector kids)] + (print-node child "" (= j (dec (count kids)))))) + (when (< i (dec (count rv))) + (println))))) + (frame-bottom))) + + +;; -- :table -- + +(defn ^:private pprint-table-mode + [result original-instruction] + (let [cm-dep (:internal/cm-dependency result) + cm-order (:internal/cm-running-order result) + instr (:instruction result) + {:keys [deps order]} (build-full-graph cm-dep cm-order) + rows (map-indexed + (fn [i cmd] + (let [path (cm/command-path cmd) + key-name (format-path-name path) + orig (get-in original-instruction path) + desc (cmd-describe (if (map? orig) orig {})) + has-value? (value-exists-at-path? instr path) + value (get-in instr path) + dep-keys (get deps cmd #{}) + meaningful-deps + (filter (fn [dep] + (let [dp (cm/command-path dep) + dep-orig (get-in original-instruction dp)] + (or (top-level-cmd? dep) + (and (map? dep-orig) + (or (contains? dep-orig :commando/from) + (contains? dep-orig "commando-from")))))) + dep-keys) + dep-str (if (empty? meaningful-deps) + "—" + (str/join ", " (map #(format-path-name (cm/command-path %)) meaningful-deps)))] + {:n (inc i) + :key key-name + :type (str (case (:kind desc) + :from (str "← " (:source desc)) + (:label desc)) + (format-driver-suffix desc)) + :deps dep-str + :value (if has-value? (pr-str value) "—")})) + order) + w-n (count (str (count rows))) + w-key (apply max 4 (map (comp count :key) rows)) + w-type (apply max 4 (map (comp count :type) rows)) + w-deps (apply max 10 (map (comp count :deps) rows)) + w-val (min 40 (apply max 5 (map (comp count :value) rows))) + pad (fn [s w] (str s (str/join (repeat (max 0 (- w (count s))) " ")))) + pad-r (fn [s w] (str (str/join (repeat (max 0 (- w (count s))) " ")) s)) + hr (fn [j] (str " " (str/join (repeat w-n "─")) + " " j " " (str/join (repeat w-key "─")) + " " j " " (str/join (repeat w-type "─")) + " " j " " (str/join (repeat w-deps "─")) + " " j " " (str/join (repeat w-val "─")) + " "))] + (frame-top "Execution Map") + (println (hr "┬")) + (println (str " " (pad-r "#" w-n) + " │ " (pad "key" w-key) + " │ " (pad "type" w-type) + " │ " (pad "depends on" w-deps) + " │ " "value")) + (println (hr "┼")) + (doseq [row rows] + (println (str " " (pad-r (str (:n row)) w-n) + " │ " (pad (:key row) w-key) + " │ " (pad (:type row) w-type) + " │ " (pad (:deps row) w-deps) + " │ " (let [v (:value row)] + (if (> (count v) w-val) + (str (subs v 0 (- w-val 1)) "…") + v))))) + (println (hr "┴")) + (frame-bottom))) + + +;; -- :graph -- + +(defn ^:private pprint-graph-mode + [result original-instruction] + (let [cm-dep (:internal/cm-dependency result) + cm-order (:internal/cm-running-order result) + {:keys [deps dependents roots order]} (build-simplified-graph cm-dep cm-order) + sinks (filterv (fn [cmd] (empty? (get dependents cmd []))) order) + sink? (set sinks) + root? (set roots) + mid (filterv (fn [cmd] (and (not (root? cmd)) (not (sink? cmd)))) order) + fmt-key (fn [cmd] (str (last (cm/command-path cmd)))) + fmt-edges (fn [cmd] + (let [orig (get-in original-instruction (cm/command-path cmd)) + desc (cmd-describe (if (map? orig) orig {})) + kids (get dependents cmd [])] + (str (format-node-label-no-value (fmt-key cmd) desc) + (when (seq kids) + (str " ──► " (str/join ", " (map fmt-key kids)))))))] + (frame-top "Dependency Graph") + (when (seq roots) + (println " Sources (no dependencies):") + (doseq [r roots] + (println (str " " (fmt-edges r))))) + (when (seq mid) + (println) + (println " Transforms:") + (doseq [m mid] + (println (str " " (fmt-edges m))))) + (when (seq sinks) + (println) + (println " Sinks (no dependents):") + (println (str " " (str/join ", " (map fmt-key sinks))))) + (frame-bottom))) + + +;; -- :stats -- + +(defn ^:private pprint-stats-mode + [result _original-instruction] + (let [order (:internal/cm-running-order result) + top-n (count (filter top-level-cmd? order)) + total-n (count order) + errors (count (:errors result)) + stats (:stats result)] + (frame-top "Stats") + (println (str " Keys: " top-n " · Commands: " total-n " · Errors: " errors + " · Status: " (name (:status result)))) + (when (seq stats) + (let [max-key-len (apply max 0 (map (comp count name first) stats))] + (println) + (doseq [[index [stat-key _ formatted]] (map-indexed vector stats)] + (let [key-str (name stat-key) + padding (str/join (repeat (- max-key-len (count key-str)) " "))] + (println (str " " (if (= "execute" key-str) "=" (str (inc index))) + " " key-str " " padding formatted)))))) + (when (seq (:errors result)) + (println) + (println " Errors:") + (doseq [err (:errors result)] + (println (str " · " (pr-str err))))) + (frame-bottom))) + + +;; -- :instr-before / :instr-after -- + +(defn ^:private pprint-instr-before + [_result original-instruction] + (frame-top "Instruction (before)") + (pprint-wide original-instruction) + (frame-bottom)) + +(defn ^:private pprint-instr-after + [result _original-instruction] + (frame-top "Instruction (after)") + (pprint-wide (:instruction result)) + (frame-bottom)) + + +;; == execute-debug ======================================= + +(defn ^:private render-mode + [mode result original-instruction] + (case mode + :tree (pprint-tree result original-instruction) + :table (pprint-table-mode result original-instruction) + :graph (pprint-graph-mode result original-instruction) + :stats (pprint-stats-mode result original-instruction) + :instr-before (pprint-instr-before result original-instruction) + :instr-after (pprint-instr-after result original-instruction) + (throw (ex-info (str "Unknown execute-debug mode: " mode) + {:mode mode + :available [:tree :table :graph :stats :instr-before :instr-after]})))) + +(defn execute-debug + "Execute an instruction and print debug visualization. Returns the execution result. + + Accepts a single mode keyword or a vector of modes (printed in order). + + Modes: + :table — tabular execution map: order, type, deps, result value (default) + :tree — data flow tree with resolved values at each node + :graph — compact dependency graph showing structure only + :stats — execution statistics: timing, command counts, errors + :instr-before — wide pprint of the original instruction (before execution) + :instr-after — wide pprint of the instruction after execution + + Usage: + (execute-debug registry instruction) + (execute-debug registry instruction :tree) + (execute-debug registry instruction [:instr-before :table :stats]) + + Example: + (execute-debug + [builtin/command-from-spec] + {:a 1 :b {:commando/from [:a] :=> [:fn inc]}} + [:instr-before :table :stats])" + ([registry instruction] + (execute-debug registry instruction :table)) + ([registry instruction mode] + (let [result (commando/execute registry instruction)] + (if (vector? mode) + (doseq [m mode] (render-mode m result instruction)) + (render-mode mode result instruction)) + result))) + +;; == execute-trace ======================================= + +(defn ^:private trace-extract-children + [data] + (into [] + (keep (fn [[k v]] + (when (and (map? v) + (not (contains? #{:stats :instruction-keys :instruction-title} k))) + [k v]))) + data)) + +(defn ^:private trace-format-keys + [keys-vec] + (let [ks (keep (fn [k] (when-not (contains? #{"__title" :__title} k) (str k))) + keys-vec)] + (when (seq ks) + (let [display (take 5 ks)] + (cond-> (str/join ", " display) + (> (count ks) 5) (str ", …")))))) + +(defn ^:private trace-format-summary + [stats] + (when (seq stats) + (let [stats-map (into {} (map (fn [[k _ formatted]] [(name k) formatted]) stats)) + cmds-t (get stats-map "execute-commands!") + exec-t (get stats-map "execute")] + (cond + (and cmds-t exec-t) (str "execute-commands! " cmds-t " · execute " exec-t) + exec-t (str "execute " exec-t) + cmds-t (str "execute-commands! " cmds-t) + :else nil)))) + +(defn ^:private trace-print-node + [uuid data prefix is-last] + (let [short-id (subs (str uuid) 0 (min 8 (count (str uuid)))) + title (:instruction-title data) + keys-str (trace-format-keys (:instruction-keys data)) + label (cond-> short-id + title (str " ‹" title "›")) + connector (if is-last "└─► " "├─► ") + children (trace-extract-children data) + new-prefix (str prefix (if is-last " " "│ ")) + leaf? (empty? children) + body-prefix (str " " new-prefix (if leaf? " " "│ ")) + summary (trace-format-summary (:stats data))] + (println (str " " prefix connector label)) + (when keys-str + (println (str body-prefix keys-str))) + (when summary + (println (str body-prefix summary))) + (let [cv (vec children)] + (doseq [[i [cuuid cdata]] (map-indexed vector cv)] + (trace-print-node cuuid cdata new-prefix (= i (dec (count cv)))))))) + +(defn ^:private trace-print + [data] + (let [roots (trace-extract-children data)] + (frame-top "Execution Trace") + (let [rv (vec roots)] + (doseq [[i [uuid node-data]] (map-indexed vector rv)] + (let [short-id (subs (str uuid) 0 (min 8 (count (str uuid)))) + title (:instruction-title node-data) + keys-str (trace-format-keys (:instruction-keys node-data)) + label (cond-> short-id + title (str " ‹" title "›")) + summary (trace-format-summary (:stats node-data)) + children (trace-extract-children node-data)] + (println (str " " label)) + (when keys-str + (println (str " │ " keys-str))) + (when summary + (println (str " │ " summary))) + (let [cv (vec children)] + (doseq [[j [cuuid cdata]] (map-indexed vector cv)] + (trace-print-node cuuid cdata "" (= j (dec (count cv)))))) + (when (< i (dec (count rv))) + (println))))) + (frame-bottom))) + +(defn execute-trace + "Trace all nested commando/execute calls with timing. + + Calls commando/execute with the given registry and instruction, + prints a tree showing every execute invocation (including recursive + calls from macros/mutations) with timing stats and instruction keys. + + Add :__title or \"__title\" to an instruction to label it in the trace. + + Usage: + (execute-trace registry instruction) + (execute-trace registry instruction {:error-data-string false})" + ([registry instruction] + (execute-trace registry instruction nil)) + ([registry instruction opts] + (let [stats-state (atom {}) + trace-opts + (merge opts + {:hook-execute-start + (fn [e] + (swap! stats-state + (fn [s] + (update-in s (:stack utils/*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 utils/*execute-internals*) + #(merge % {:stats (:stats e) + :instruction-keys (when (map? (:instruction e)) + (vec (keys (:instruction e))))})))))}) + result (commando/execute registry instruction trace-opts)] + (trace-print @stats-state) + result))) + diff --git a/src/commando/impl/command_map.cljc b/src/commando/impl/command_map.cljc index c033961..055bd34 100644 --- a/src/commando/impl/command_map.cljc +++ b/src/commando/impl/command_map.cljc @@ -19,16 +19,15 @@ ^{:doc "Represents a command found in the instruction map at a specific `path`. Holds both the path to the command and its command specification data. + Hash is cached at construction time for fast map/set lookups. Equality is based only on path, not on data. This allows commands at the same path to be treated as equivalent regardless of their specification."} CommandMapPath - [path data] + [path data ^int _hash] Object (equals [this path-obj] (if (instance? CommandMapPath path-obj) (= (.-path this) (.-path path-obj)) false)) - (hashCode [_this] - #?(:clj (hash path) - :cljs (hash path))) + (hashCode [_this] _hash) (toString ^String [_] (str (cm-generate-id path) (when-let [meta-info (cm-path-string-meta data)] (str "[" meta-info "]")))))) #?(:clj (do (defmethod print-method CommandMapPath @@ -43,42 +42,63 @@ :doc "Represents a command found in the instruction map at a specific path. Holds both the path to the command and its command specification data. + Hash is cached at construction time for fast map/set lookups. Equality is based only on path, not on data. This allows commands at the same path to be treated as equivalent regardless of their specification."} CommandMapPath - [path data] + [path data _hash] cljs.core/IEquiv (-equiv [this path-obj] (and (instance? CommandMapPath path-obj) (= (.-path this) (.-path path-obj)))) cljs.core/IHash - (-hash [this] (hash (.-path this))) + (-hash [_this] _hash) IPrintWithWriter (-pr-writer [o writer _opts] (-write writer (.toString o))) Object (equals [this path-obj] (if (instance? CommandMapPath path-obj) (= (.-path this) (.-path path-obj)) false)) - (hashCode [_this] - #?(:clj (hash path) - :cljs (hash path))) + (hashCode [_this] _hash) (toString ^String [_] (str (cm-generate-id path) (when-let [meta-info (cm-path-string-meta data)] (str "[" meta-info "]")))))) +(defn command-map-path + "Constructs a CommandMapPath with cached hash for fast map/set lookups." + [path data] + (CommandMapPath. path data (hash path))) + (defn command-map? [obj] (instance? CommandMapPath obj)) (defn command-id [p] (when (instance? CommandMapPath p) (.toString p))) (defn command-path [p] (when (instance? CommandMapPath p) (.-path p))) (defn command-data [p] (when (instance? CommandMapPath p) (.-data p))) -(defn vector-starts-with? - [s prefix] - (if (or (nil? s) (nil? prefix)) +(defn coll-starts-with? + "Returns true if `coll` starts with all elements of `coll-prefix`, in order. + Works on any indexed, counted collection (vectors, arrays, strings). + Uses indexed loop instead of seq/take to avoid lazy sequence allocation. + + Examples: + (coll-starts-with? [:a :b :c] [:a :b]) ;=> true + (coll-starts-with? [:a :b :c] [:a :x]) ;=> false + (coll-starts-with? [:a :b :c] [:a :b :c :d]) ;=> false + (coll-starts-with? [:a :b] []) ;=> true + (coll-starts-with? nil [:a]) ;=> false" + [coll coll-prefix] + (if (or (nil? coll) (nil? coll-prefix)) false - (let [s-len (count s) - prefix-len (count prefix)] - (or (and (>= s-len prefix-len) (= (seq prefix) (take prefix-len s))) - ;; this mean situation while (vector-starts-with? [:A :B] []) -> true - (= prefix-len 0))))) + (let [coll-len (count coll) + prefix-len (count coll-prefix)] + (if (= prefix-len 0) + true + (if (< coll-len prefix-len) + false + (loop [i 0] + (if (= i prefix-len) + true + (if (= (nth coll i) (nth coll-prefix i)) + (recur (inc i)) + false)))))))) -(defn start-with? [p1 p2] (vector-starts-with? (command-path p1) (command-path p2))) +(defn start-with? [p1 p2] (coll-starts-with? (command-path p1) (command-path p2))) (def CommandMapSpec (malli/schema [:map diff --git a/src/commando/impl/dependency.cljc b/src/commando/impl/dependency.cljc index 38bd503..e552d9f 100644 --- a/src/commando/impl/dependency.cljc +++ b/src/commando/impl/dependency.cljc @@ -4,22 +4,6 @@ [commando.impl.command-map :as cm] [commando.impl.utils :as utils])) -(defn- build-path-trie - "Builds a trie from a list of CommandMapPath objects for efficient path-based lookups." - [cm-list] - (reduce - (fn [trie cmd] - (assoc-in trie (conj (cm/command-path cmd) ::command) cmd)) - {} - cm-list)) - -(defn- get-all-nested-commands - "Lazily traverses a trie. - Returns a lazy sequence of all command objects found." - [trie] - (->> (tree-seq map? (fn [node] (vals (dissoc node ::command))) trie) - (keep ::command))) - (defmulti find-command-dependencies "Finds command dependencies based on dependency-type. @@ -28,38 +12,40 @@ Modes: - :all-inside - depends on all commands inside the Command: if it Map - the values, if it Vector - elements of vectors - - :all-inside-recur - depends on all commands nested within this command's path - :point - depends on command(s) at a specific path, defined by :point-key setting key. :point collect only one depedency - the only one it refering. - - :point-and-all-inside-recur - joined approach of :point and :all-inside-recur. - collect dependencies for path it pointing along with nested dependecies under - the pointed item. First tries to find a command at the exact target path. If not found, - walks up the path hierarchy to find parent commands that will create/modify - the target path. - :none - no dependencies (not implemented, returns empty set by default)" (fn [_command-path-obj _instruction _path-trie dependency-type] dependency-type)) +;; -- Default -- + (defmethod find-command-dependencies :default [_command-path-obj _instruction _path-trie type] (throw (ex-info (str utils/exception-message-header "Undefined dependency mode: " type) - {:message (str utils/exception-message-header "Undefined dependency mode: " type) - :dependency-mode type}))) + {:message (str utils/exception-message-header "Undefined dependency mode: " type) + :dependency-mode type}))) + +;; -- None -- + +(defmethod find-command-dependencies :none [_command-path-obj _instruction _path-trie _type] #{}) + +;; -- All Inside -- (defmethod find-command-dependencies :all-inside [command-path-obj _instruction path-trie _type] + ;; Direct reduce-kv instead of dissoc+vals+keep+set chain. + ;; Avoids: 1 dissoc (new map), 1 vals (lazy seq), 1 keep (lazy seq), 1 set (materialization). (let [command-path (cm/command-path command-path-obj) sub-trie (get-in path-trie command-path)] - (->> (vals (dissoc sub-trie ::command)) - (keep ::command) - set))) + (reduce-kv (fn [acc k v] + (if (identical? k :commando.impl.pathtrie/command) + acc + (if-let [cmd (:commando.impl.pathtrie/command v)] + (conj acc cmd) + acc))) + #{} sub-trie))) -(defmethod find-command-dependencies :all-inside-recur - [command-path-obj _instruction path-trie _type] - (let [command-path (cm/command-path command-path-obj) - sub-trie (get-in path-trie command-path)] - (->> (get-all-nested-commands sub-trie) - (remove #(= % command-path-obj)) - set))) +;; -- Point -- (defn- find-anchor-path "Walks UP from current-path looking for the nearest ancestor map @@ -69,8 +55,8 @@ (loop [path (vec current-path)] (let [node (get-in instruction path)] (if (and (map? node) - (= anchor-name (or (get node "__anchor") - (get node :__anchor)))) + (= anchor-name (or (get node "__anchor") + (get node :__anchor)))) path (when (seq path) (recur (pop path))))))) @@ -86,34 +72,30 @@ (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)))))) + (loop [remaining (seq segments) + relative nil + path []] + (if-not remaining + (if relative (into relative path) path) + (let [segment (first remaining) + current-base (or relative base-path)] + (cond + (= segment "../") + (recur (next remaining) (vec (butlast current-base)) path) + + (= segment "./") + (recur (next remaining) (vec current-base) 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)] + (when anchor-path + (recur (next remaining) anchor-path path))) + + :else + (recur (next remaining) relative (conj path segment))))))) (defn path-exists-in-instruction? "Checks if a path exists in the instruction map." @@ -156,43 +138,21 @@ (defmethod find-command-dependencies :point [command-path-obj instruction path-trie _type] (let [target-path (point-target-path instruction command-path-obj)] - (if-let [point-command (get-in path-trie (conj target-path ::command))] + (if-let [point-command (get-in path-trie (conj target-path :commando.impl.pathtrie/command))] #{point-command} (throw-point-error command-path-obj target-path instruction)))) -(defn- point-find-parent-command - "Walks up the path hierarchy to find the first parent command that exists in the trie." - [path-trie target-path] - (loop [current-path target-path] - (when (seq current-path) - (if-let [cmd (get-in path-trie (conj current-path ::command))] - cmd - (recur (butlast current-path)))))) - -(defmethod find-command-dependencies :point-and-all-inside-recur - [command-path-obj instruction path-trie _type] - (let [target-path (point-target-path instruction command-path-obj) - sub-trie (get-in path-trie target-path) - commands-at-target (set (get-all-nested-commands sub-trie)) - parent-command (point-find-parent-command path-trie target-path)] - (cond - (not-empty commands-at-target) commands-at-target - parent-command #{parent-command} - (path-exists-in-instruction? instruction target-path) #{} - :else (throw-point-error command-path-obj target-path instruction)))) - -(defmethod find-command-dependencies :none [_command-path-obj _instruction _path-trie _type] #{}) +;; Dependency (defn build-dependency-graph - "Builds the dependency map for all commands in `cm-list`. - Returns a map from CommandMapPath objects to their dependency sets." - [instruction cm-list] - (let [path-trie (build-path-trie cm-list)] - (reduce (fn [dependency-acc command-path-obj] - (let [dependency-mode (get-in (cm/command-data command-path-obj) [:dependencies :mode])] - (assoc dependency-acc - command-path-obj - (find-command-dependencies command-path-obj instruction path-trie dependency-mode)))) - {} - cm-list))) + "Builds forward dependency graph using a pre-built path-trie. + Returns {CommandMapPath -> #{deps}}." + [instruction cm-list path-trie] + (persistent! + (reduce (fn [fwd command-path-obj] + (let [dep-mode (get-in (cm/command-data command-path-obj) [:dependencies :mode]) + deps (find-command-dependencies command-path-obj instruction path-trie dep-mode)] + (assoc! fwd command-path-obj deps))) + (transient {}) + cm-list))) diff --git a/src/commando/impl/executing.cljc b/src/commando/impl/executing.cljc index eb4f948..ada36a6 100644 --- a/src/commando/impl/executing.cljc +++ b/src/commando/impl/executing.cljc @@ -2,9 +2,7 @@ (:require [commando.impl.command-map :as cm])) -;; ==================== -;; Driver Multimethod -;; ==================== +;; == Driver Multimethod ================================== (defn ^:private resolve-command-driver "Parses :=> value into [driver-keyword driver-params]. @@ -46,9 +44,7 @@ (fn [driver-name _driver-params _applied-result _command-data _instruction _command-path-obj] driver-name)) -;; ==================== -;; Execution Engine -;; ==================== +;; == Execution Engine ===================================== (defn ^:private execute-single-command "Execute a single command and update the instruction at the given path. @@ -60,7 +56,9 @@ command-path (cm/command-path command-path-obj) root? (empty? command-path) command-data (if root? instruction (get-in instruction command-path)) - applied-result (apply-fn instruction command-path-obj (dissoc command-data :=> "=>")) + applied-result (apply-fn instruction command-path-obj (if (map? command-data) + (dissoc command-data :=> "=>") + command-data)) [drv-name drv-params] (resolve-command-driver command-data command-spec) result (command-driver drv-name drv-params applied-result @@ -69,20 +67,27 @@ (defn execute-commands "Execute commands in order, stopping on first failure. - Returns [updated-instruction error-info] where error-info is nil on success." + Returns [updated-instruction error-info cm-results]. + Transient results map: avoids N persistent map copies during execution loop. + Each command adds one entry — transient turns N×assoc into N×assoc! (zero HAMT copies)." [instruction commands] - (loop [current-instruction instruction - remaining-commands commands] - (if (empty? remaining-commands) - [current-instruction nil] - (let [command (first remaining-commands) - execution-result (try (execute-single-command current-instruction command) - (catch #?(:clj Exception - :cljs :default) - e - {:error {:command-path (cm/command-path command) - :command-type (:type (cm/command-data command)) - :original-error e}}))] - (if (:error execution-result) - [current-instruction (:error execution-result)] - (recur execution-result (rest remaining-commands))))))) + (let [cmd-count (count commands)] + (loop [current-instruction instruction + idx 0 + results (transient {})] + (if (= idx cmd-count) + [current-instruction nil (persistent! results)] + (let [command (nth commands idx) + command-path (cm/command-path command) + root? (empty? command-path) + execution-result (try (execute-single-command current-instruction command) + (catch #?(:clj Exception + :cljs :default) + e + {:error {:command-path command-path + :command-type (:type (cm/command-data command)) + :original-error e}}))] + (if (:error execution-result) + [current-instruction (:error execution-result) (persistent! results)] + (let [cmd-result (if root? execution-result (get-in execution-result command-path))] + (recur execution-result (inc idx) (assoc! results command cmd-result))))))))) diff --git a/src/commando/impl/finding_commands.cljc b/src/commando/impl/finding_commands.cljc index 7c7f997..8d4d8d8 100644 --- a/src/commando/impl/finding_commands.cljc +++ b/src/commando/impl/finding_commands.cljc @@ -1,23 +1,34 @@ (ns commando.impl.finding-commands (:require [commando.impl.command-map :as cm] + [commando.impl.pathtrie :as pathtrie] [commando.impl.utils :as utils])) -(defn ^:private coll-child-paths - "Returns child paths for regular collections that should be traversed." - [value current-path] +(defn ^:private enqueue-coll-children! + "Enqueues child paths for regular collections directly into the transient queue. + Avoids intermediate vector allocation compared to mapv approach." + [queue value current-path] (cond - (map? value) (doall (map (fn [[k _v]] (conj current-path k)) (seq value))) - (coll? value) (doall (map (fn [i] (conj current-path i)) (range (count value)))) - :else [])) + (map? value) + (reduce-kv (fn [q k _] (conj! q (conj current-path k))) queue value) -(defmulti ^:private command-child-paths - "Returns child paths that should be traversed for a command based on its dependency mode." - (fn [command-spec _value _current-path] (get-in command-spec [:dependencies :mode]))) + (coll? value) + (let [c (count value)] + (loop [i 0 q queue] + (if (= i c) q + (recur (inc i) (conj! q (conj current-path i)))))) -(defmethod command-child-paths :default [_command-spec _value _current-path] []) + :else queue)) -(defmethod command-child-paths :all-inside [_command-spec value current-path] (coll-child-paths value current-path)) +(defmulti ^:private enqueue-command-children! + "Enqueues child paths that should be traversed for a command based on its dependency mode. + Takes a transient queue vector and returns it with children added." + (fn [_queue command-spec _value _current-path] (get-in command-spec [:dependencies :mode]))) + +(defmethod enqueue-command-children! :default [queue _command-spec _value _current-path] queue) + +(defmethod enqueue-command-children! :all-inside [queue _command-spec value current-path] + (enqueue-coll-children! queue value current-path)) (defn command? [{:keys [recognize-fn] @@ -48,51 +59,46 @@ (some (fn [command-spec] (when (command? command-spec value) (let [value-valid-return (command-valid? command-spec value)] - (cond - (true? value-valid-return) command-spec - (or - (false? value-valid-return) - (nil? value-valid-return)) - (throw - (ex-info - (str - "Failed while validating params for " (:type command-spec) ". Check ':validate-params-fn' property for corresponding command with value it was evaluated on.") - {:command-type (:type command-spec) - :path path - :value value})) - :else - (throw - (ex-info - (str - "Failed while validating params for " (:type command-spec) ". Check ':validate-params-fn' property for corresponding command with value it was evaluated on.") - {:command-type (:type command-spec) - :reason value-valid-return - :path path - :value value})))))) + (if (true? value-valid-return) command-spec + (throw + (ex-info + (str + "Failed while validating params for " (:type command-spec) ". Check ':validate-params-fn' property for corresponding command with value it was evaluated on.") + {:command-type (:type command-spec) + :reason (when value-valid-return value-valid-return) + :path path + :value value})))))) command-spec-vector)) (defn find-commands - "Traverses the instruction tree (BFS algo) and collects all commands defined by the registry." - [instruction {:keys [registry-runtime] :as _command-registry}] - (loop [queue (vec [[]]) - found-commands [] - debug-stack-map {}] - (if (empty? queue) - found-commands - (let [current-path (first queue) - remaining-paths (subvec queue 1) - current-value (get-in instruction current-path) - debug-stack (if (:debug-result (utils/execute-config)) (get debug-stack-map current-path (list)) (list))] - (if-let [command-spec (instruction-command-spec registry-runtime current-value current-path)] - (let [command (cm/->CommandMapPath - current-path - (if (:debug-result (utils/execute-config)) (merge command-spec {:__debug_stack debug-stack}) command-spec)) - child-paths (command-child-paths command-spec current-value current-path) - updated-debug-stack-map (if (:debug-result (utils/execute-config)) - (reduce #(assoc %1 %2 (conj debug-stack command)) debug-stack-map child-paths) - {})] - (recur (into remaining-paths child-paths) (conj found-commands command) updated-debug-stack-map)) - ;; No match - traverse children if coll, skip if leaf - (recur (into remaining-paths (coll-child-paths current-value current-path)) - found-commands - debug-stack-map)))))) + "Traverses the instruction tree (BFS) and collects all commands defined by the registry. + Returns {:commands #{...} :trie {...}} — the command set and path-trie built in the same pass. + + Options: + Optimizations: + - Index-based transient queue: O(N) instead of O(N²) from subvec+into copying + - Transient found-commands set: O(N) set allocations saved + - Direct enqueue: no intermediate mapv vectors for child-path generation + - Transient trie root: N root-level HAMT copies avoided during bulk construction" + ([instruction command-registry] + (find-commands instruction command-registry nil)) + ([instruction {:keys [registry-runtime] :as _command-registry} _opts] + (loop [queue (transient [[]]) + idx 0 + found-commands (transient #{}) + trie (transient {})] + (if (= idx (count queue)) + {:commands (persistent! found-commands) :trie (persistent! trie)} + (let [current-path (nth queue idx) + current-value (get-in instruction current-path)] + (if-let [command-spec (instruction-command-spec registry-runtime current-value current-path)] + (let [command (cm/command-map-path current-path command-spec)] + (recur (enqueue-command-children! queue command-spec current-value current-path) + (inc idx) + (conj! found-commands command) + (pathtrie/trie-insert-command! trie command))) + (recur (enqueue-coll-children! queue current-value current-path) + (inc idx) + found-commands + trie))))))) + diff --git a/src/commando/impl/graph.cljc b/src/commando/impl/graph.cljc index 634fc6e..9969412 100644 --- a/src/commando/impl/graph.cljc +++ b/src/commando/impl/graph.cljc @@ -1,6 +1,4 @@ -(ns commando.impl.graph - (:require - [clojure.set :as set])) +(ns commando.impl.graph) (defn topological-sort "Efficiently sorts a directed acyclic graph using Kahn's algorithm with in-degree counting. @@ -9,26 +7,31 @@ and :cyclic containing the remaining nodes if a cycle is detected." [g] (let [;; Build the reverse graph to easily find dependents and collect all nodes. - rev-g (reduce-kv (fn [acc k vs] - (reduce (fn [a v] (update a v (fnil conj []) k)) acc vs)) - {} g) - all-nodes (set/union (set (keys g)) (set (keys rev-g))) + rev-g (persistent! + (reduce-kv (fn [acc k vs] + (reduce (fn [a v] + (let [existing (get a v [])] + (assoc! a v (conj existing k)))) + acc vs)) + (transient {}) g)) + node-count (count g) ;; calculate in-degrees for all nodes. - in-degrees (reduce-kv (fn [acc node deps] - (assoc acc node (count deps))) - {} g) + in-degrees (persistent! + (reduce-kv (fn [acc node deps] + (assoc! acc node (count deps))) + (transient {}) g)) ;; Initialize the queue with nodes that have no incoming edges. ;; Using a vector as a FIFO queue. - q (reduce (fn [queue node] - (if (zero? (get in-degrees node 0)) - (conj queue node) - queue)) - [] all-nodes)] + q (reduce-kv (fn [queue node deps] + (if (zero? (count deps)) + (conj queue node) + queue)) + [] g)] (loop [queue q - sorted-result [] - degrees in-degrees] + sorted-result (transient []) + degrees (transient in-degrees)] (if-let [node (first queue)] (let [dependents (get rev-g node []) ;; Reduce in-degree for all dependents @@ -36,17 +39,19 @@ [next-degrees new-zero-nodes] (reduce (fn [[degs zeros] dep] (let [new-degree (dec (get degs dep))] - [(assoc degs dep new-degree) + [(assoc! degs dep new-degree) (if (zero? new-degree) (conj zeros dep) zeros)])) [degrees []] dependents)] (recur (into (subvec queue 1) new-zero-nodes) - (conj sorted-result node) + (conj! sorted-result node) next-degrees)) - (if (= (count sorted-result) (count all-nodes)) - {:sorted sorted-result :cyclic {}} - (let [cyclic-nodes (->> degrees - (filter (fn [[_ v]] (pos? v))) - (into {}))] - {:sorted sorted-result :cyclic cyclic-nodes})))))) + (let [sorted (persistent! sorted-result)] + (if (= (count sorted) node-count) + {:sorted sorted :cyclic {}} + (let [final-degrees (persistent! degrees) + cyclic-nodes (->> final-degrees + (filter (fn [[_ v]] (pos? v))) + (into {}))] + {:sorted sorted :cyclic cyclic-nodes}))))))) diff --git a/src/commando/impl/pathtrie.cljc b/src/commando/impl/pathtrie.cljc new file mode 100644 index 0000000..a427dc2 --- /dev/null +++ b/src/commando/impl/pathtrie.cljc @@ -0,0 +1,45 @@ +(ns commando.impl.pathtrie + (:require + [commando.impl.command-map :as cm])) + +(defn trie-insert-command! + "Inserts a command into a trie with transient root." + [trie! cmd] + (let [path (cm/command-path cmd)] + (if (empty? path) + (assoc! trie! ::command cmd) + (let [k (first path) + sub (get trie! k {})] + (if (= (count path) 1) + (assoc! trie! k (assoc sub ::command cmd)) + (let [rest-keys (subvec (conj path ::command) 1)] + (assoc! trie! k (assoc-in sub rest-keys cmd)))))))) + +(defn build-path-trie + "Builds a trie from a collection of CommandMapPath objects. + Uses transient root for efficient bulk construction." + [cm-list] + (persistent! + (reduce trie-insert-command! (transient {}) cm-list))) + +(defn trie-remove-paths + "Removes all entries from a trie whose paths start with the given prefixes." + [trie paths-to-remove] + (reduce + (fn [t path] + (if (seq path) + (let [parent-path (butlast path) + leaf (last path)] + (if (seq parent-path) + (update-in t (vec parent-path) dissoc leaf) + (dissoc t leaf))) + {})) + trie + paths-to-remove)) + +(defn trie-insert-commands + "Inserts commands into an existing trie." + [trie new-commands] + (reduce (fn [trie cmd] + (assoc-in trie (conj (cm/command-path cmd) ::command) cmd)) + trie new-commands)) diff --git a/src/commando/impl/registry.cljc b/src/commando/impl/registry.cljc index d660ea1..f0ea2b0 100644 --- a/src/commando/impl/registry.cljc +++ b/src/commando/impl/registry.cljc @@ -68,6 +68,16 @@ default-command-map-spec default-command-value-spec]) +(def structural-command-types + "Set of internal/structural command types that don't affect execution order." + (into #{} (map :type internal-command-specs))) + +(defn structural-command-type? + "Returns true if the given command type is a structural/internal type + (:instruction/_value, :instruction/_map, :instruction/_vec)." + [command-type] + (contains? structural-command-types command-type)) + (defn built? "Returns true if the given value is a properly built registry map." [registry] @@ -107,16 +117,13 @@ (dissoc enriched-registry :registry-runtime)) (defn remove-runtime-registry-commands-from-command-list [cm-vector] - (let [cm-type-instruction-defaults - (into #{} (map :type internal-command-specs))] - (reduce (fn [acc command-map] - (if (contains? cm-type-instruction-defaults (:type (cm/command-data command-map))) - acc (conj acc command-map))) - [] cm-vector))) + ;; Reuse `structural-command-types` def instead of rebuilding the set on every call. + (reduce (fn [acc command-map] + (if (contains? structural-command-types (:type (cm/command-data command-map))) + acc (conj acc command-map))) + [] cm-vector)) -;; ---------------- -;; Registry Helpers -;; ---------------- +;; == Registry Helpers ===================================== (defn registry-add "Adds or replaces a spec in a built registry (identified by :type). Revalidates." @@ -136,3 +143,4 @@ (build new-vec))) + diff --git a/src/commando/impl/status_map.cljc b/src/commando/impl/status_map.cljc index 7bcc77c..b874dc2 100644 --- a/src/commando/impl/status_map.cljc +++ b/src/commando/impl/status_map.cljc @@ -3,12 +3,7 @@ [commando.impl.utils :as utils] [malli.core :as malli])) -(def ^:private status-map-message-schema - [:map [:message [:string {:min 5}]]]) - -;;;------ -;;; Stats -;;;------ +;; == Stats ================================================ (defn status-map-add-measurement "Calculates the duration from `start-time-ns` and `end-time-ns` and appends it as a tuple @@ -20,43 +15,40 @@ duration (utils/format-time duration)]))) +(def ^:private -coercer:status-map-message + (malli/coercer [:map [:message [:string {:min 5}]]])) + (defn status-map-handle-warning [status-map m] - (update status-map :warnings (fnil conj []) (malli/coerce status-map-message-schema m))) + (update status-map :warnings (fnil conj []) (-coercer:status-map-message m))) (defn status-map-handle-error [status-map m] (-> status-map - (update :errors (fnil conj []) (malli/coerce status-map-message-schema m)) + (update :errors (fnil conj []) (-coercer:status-map-message m)) (assoc :status :failed))) (defn status-map-handle-success [status-map m] - (update status-map :successes (fnil conj []) (malli/coerce status-map-message-schema m))) + (update status-map :successes (fnil conj []) (-coercer:status-map-message m))) (defn status-map-pure - ([] (status-map-pure nil)) + ([] (status-map-pure {})) ([m] - (merge {:uuid (:uuid utils/*execute-internals*) - :status :ok - :errors [] - :warnings [] - :successes [] - :stats []} - m))) - -(defn status-map-undefined-status - [status-map] - (throw (ex-info (str "Status map exception, :status value incorrect: " (:status status-map)) - {:status (:status status-map)}))) + (-> m + (assoc :uuid (:uuid utils/*execute-internals*)) + (cond-> + (not (contains? m :status)) (assoc :status :ok) + (not (contains? m :errors)) (assoc :errors []) + (not (contains? m :warnings)) (assoc :warnings []) + (not (contains? m :successes)) (assoc :successes []) + (not (contains? m :stats)) (assoc :stats []))))) (defn failed? [status-map] (= (:status status-map) :failed)) (defn ok? [status-map] (= (:status status-map) :ok)) -;; -------------------- -;; Core Pipeline Helper -;; -------------------- +;; == Core Pipeline Helper ================================= (defn core-step-safe "Executes a pipeline step with skip-on-failure, error safety net, and timing. diff --git a/src/commando/impl/utils.cljc b/src/commando/impl/utils.cljc index c896de8..2e022e0 100644 --- a/src/commando/impl/utils.cljc +++ b/src/commando/impl/utils.cljc @@ -4,21 +4,19 @@ (def exception-message-header "Commando. ") -;; ------------------ -;; Dynamic Properties -;; ------------------ +;; == Dynamic Properties ================================== (def ^:private -execute-config-default - {:debug-result false - :error-data-string true + {:error-data-string true :hook-execute-end nil :hook-execute-start nil}) (def ^:dynamic *execute-config* "Dynamic configuration for `commando/execute` behavior. - - `:debug-result` (boolean): When true, adds additional execution - information to the returned status-map, aiding in instruction analysis. + Bound automatically by `commando.core/execute` — prefer passing config + via the opts map to `execute` rather than binding directly. + - `:error-data-string` (boolean): When true, the `:data` key in serialized `ExceptionInfo` (via `commando.impl.utils/serialize-exception`) will be a string representation of the data. When false, it will return @@ -29,17 +27,15 @@ passed in value. Example - (binding [commando.impl.utils/*execute-config* - {:debug-result true - :error-data-string false - :hook-execute-start (fn [e] (println (:uuid e))) - :hook-execute-end (fn [e] (println (:uuid e) (:stats e)))}] - (commando.core/execute - [commando.commands.builtin/command-from-spec] - {\"1\" 1 - \"2\" {:commando/from [\"1\"]} - \"3\" {:commando/from [\"2\"]}}))" - -execute-config-default) + (commando.core/execute + [commando.commands.builtin/command-from-spec] + {\"1\" 1 + \"2\" {:commando/from [\"1\"]} + \"3\" {:commando/from [\"2\"]}} + {:error-data-string false + :hook-execute-start (fn [e] (println (:uuid e))) + :hook-execute-end (fn [e] (println (:uuid e) (:stats e)))})" + nil) (def ^:dynamic *execute-internals* @@ -62,10 +58,24 @@ (assoc :uuid uuid-execute-identifier) (update :stack conj uuid-execute-identifier))) +(def ^:private -config-keys + #{:error-data-string :hook-execute-start :hook-execute-end}) + (defn execute-config - "Returns the effective configuration for `commando/execute`, getting data from dynamic variable `commando.impl.utils/*execute-config*`" + "Returns the effective configuration for `commando/execute`. + Inside `execute` — returns the bound `*execute-config*`. + Outside — returns defaults." [] - (merge -execute-config-default *execute-config*)) + (or *execute-config* -execute-config-default)) + +(defn execute-config-update + "Merges config-relevant keys from `opts` into the current + effective config. Non-config keys (e.g. `:previous`) are ignored." + [opts] + (let [overrides (select-keys opts -config-keys)] + (if (empty? overrides) + (execute-config) + (merge (execute-config) overrides)))) (defn hook-process "Function will handle a hooks passed from users. @@ -100,9 +110,7 @@ `commando.core/execute-commands!`(binding)" [] (or *command-map-spec-registry* [])) -;; ------------------ -;; Function Resolvers -;; ------------------ +;; == Function Resolvers ================================== (defn resolve-fn "Normalize `x` to a function (fn? x) => true. @@ -164,9 +172,7 @@ [:cat [:* malli:driver-step-spec]]])) -;; ----------------- -;; Performance Tools -;; ----------------- +;; == Performance Tools =================================== (defn now "Returns a high-resolution timestamp in nanoseconds." @@ -183,12 +189,10 @@ (< t 1000000000) (str (float (/ t 1000000)) "ms") :else (str (float (/ t 1000000000)) "s"))) -;; ----------- -;; Error Tools -;; ----------- +;; == Error Tools ========================================== #?(:clj - (defn ^:private stacktrace->vec-str [^Throwable t] + (defn stacktrace->vec-str [^Throwable t] (mapv (fn [^StackTraceElement ste] [(.getClassName ste) (.getMethodName ste) @@ -196,7 +200,7 @@ (.getLineNumber ste)]) (.getStackTrace t)))) -#?(:clj (defn ^:private exception-dispatch-fn [e] (class e))) +#?(:clj (defn exception-dispatch-fn [e] (class e))) #?(:clj (defmulti ^{:doc "Multimethod for serializing exceptions to maps. @@ -313,181 +317,3 @@ See (map? e) e :else {:message (str e)})) - -;; ----------- -;; Stats Tools -;; ----------- - -;; -- print stats -- - -(defn print-stats - "Prints a formatted summary of the execution stats from a status-map. - - Example - (print-stats - (commando.core/execute - [commando.commands.builtin/command-from-spec] - {\"1\" 1 - \"2\" {:commando/from [\"1\"]} - \"3\" {:commando/from [\"2\"]}})) - OUT=> - Execution Stats: - 1 execute-commands! 281.453µs - = execute 1.926956ms - - (print-stats - (binding [commando.impl.utils/*execute-config* - {:debug-result true}] - (commando.core/execute - [commando.commands.builtin/command-from-spec] - {\"1\" 1 - \"2\" {:commando/from [\"1\"]} - \"3\" {:commando/from [\"2\"]}}))) - OUT=> - Execution Stats: - 1 use-registry 141.373µs - 2 find-commands 719.128µs - 3 build-deps-tree 141.061µs - 4 sort-commands-by-deps 112.841µs - 5 execute-commands! 78.601µs - = execute 1.466249ms - - - See More - `commando.impl.utils/*execute-config*`" - ([status-map] - (print-stats status-map nil)) - ([status-map title] - (when-let [stats (:stats status-map)] - (let [max-key-len (apply max 0 (map (comp count name first) stats))] - (println (str "\nExecution Stats" (when title (str "(" title ")")) ":")) - (doseq [[index [stat-key _ formatted]] (map-indexed vector stats)] - (let [key-str (name stat-key) - padding (str/join "" (repeat (- max-key-len (count key-str)) " "))] - (println (str - " " (if (= "execute" key-str) "=" (str (inc index)) ) - " " key-str " " padding formatted)))))))) - -;; -- 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)))))) - -(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 - :when (map? child-v)] - (flame-print {_child-k child-v} (str indent " :")))))) - -(defn ^:private flamegraph [data] - (println "Printing Flamegraph for executes:") - (flame-print data)) - -(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. - - 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* - {: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) - :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 deleted file mode 100644 index 71d1f12..0000000 --- a/test/perf/commando/core_perf_test.clj +++ /dev/null @@ -1,455 +0,0 @@ -(ns commando.core-perf-test - (:require - [cljfreechart.core :as cljfreechart] - [commando.commands.builtin] - [commando.commands.query-dsl] - [commando.core] - [commando.impl.utils :as commando-utils])) - -;; ======================================= -;; AVERAGE EXECUTION OF REAL WORLD EXAMPLE -;; ======================================= - -(defn ^:private calculate-average-stats - "Takes a collection of status-maps and calculates the average duration for each stat-key." - [status-maps] - {:pre [(not-empty status-maps)]} - (let [keys-order (map first (:stats (first status-maps))) - all-stats (mapcat :stats status-maps) - grouped-stats (group-by first all-stats) - averages-grouped - (reduce (fn [acc [stat-key measurements]] - (let [total-duration (reduce + (map second measurements)) - count-measurements (count measurements) - average-duration (long (/ total-duration count-measurements))] - (assoc acc stat-key [stat-key average-duration (commando.impl.utils/format-time average-duration)]))) - {} grouped-stats)] - {:stats (mapv #(get averages-grouped %) keys-order)})) - -(defmacro repeat-n-and-print-stats - "Repeats the execution of `body` `n` times, collects the status-maps," - [n & body] - `(let [results# (doall (for [_# (range ~n)] - ~@body)) - avg-stats# (calculate-average-stats results#)] - (print "Repeating instruction " ~n " times") - (commando-utils/print-stats avg-stats#))) - -(defn real-word-calculation-average-of-50 [] - (println "\n=====================Benchmark=====================") - (println "Real Word calculation. Show average of 50 execution") - (println "===================================================") - (repeat-n-and-print-stats 50 - (commando.core/execute - [commando.commands.builtin/command-fn-spec - commando.commands.builtin/command-from-spec - commando.commands.builtin/command-apply-spec] - { ;; -------------------------------------------------------------------------------- - ;; RAW DATA & CONFIGURATION - ;; -------------------------------------------------------------------------------- - :config - {:commission-rates {:standard 0.07 :senior 0.12} - :bonus-threshold 50000 - :performance-bonus 2500 - :tax-rate 0.21 - :department-op-cost {:sales 15000 :marketing 10000 :engineering 25000}} - - :products - {"prod-001" {:name "Alpha Widget" :price 250.0} - "prod-002" {:name "Beta Gadget" :price 475.0} - "prod-003" {:name "Gamma Gizmo" :price 1200.0}} - - :employees - {"emp-101" {:name "John Doe" :department :sales :level :senior} - "emp-102" {:name "Jane Smith" :department :sales :level :standard} - "emp-103" {:name "Peter Jones" :department :marketing :level :senior} - "emp-201" {:name "Mary Major" :department :engineering :level :standard}} - - :sales-records - [ ;; John's Sales - {:employee-id "emp-101" :product-id "prod-003" :units-sold 50} - {:employee-id "emp-101" :product-id "prod-001" :units-sold 120} - ;; Jane's Sales - {:employee-id "emp-102" :product-id "prod-001" :units-sold 80} - {:employee-id "emp-102" :product-id "prod-002" :units-sold 40} - ;; Peter's Sales (Marketing can also sell) - {:employee-id "emp-103" :product-id "prod-002" :units-sold 10}] - - :calculations - {:sales-revenues - {:commando/fn (fn [sales products] - (mapv (fn [sale] - (let [product (get products (:product-id sale))] - (assoc sale :total-revenue (* (:units-sold sale) (:price product))))) - sales)) - :args [{:commando/from [:sales-records]} - {:commando/from [:products]}]} - - :employee-sales-totals - {:commando/fn (fn [sales-revenues] - (reduce (fn [acc sale] - (update acc - (:employee-id sale) - (fnil + 0) - (:total-revenue sale))) - {} - sales-revenues)) - :args [{:commando/from [:calculations :sales-revenues]}]} - - :employee-commissions - {:commando/apply - {:sales-totals {:commando/from [:calculations :employee-sales-totals]} - :employees {:commando/from [:employees]} - :rates {:commando/from [:config :commission-rates]}} - :=> [:fn (fn [{:keys [sales-totals employees rates]}] - (into {} - (map (fn [[emp-id total-sales]] - (let [employee (get employees emp-id) - rate-key (:level employee) - commission-rate (get rates rate-key 0)] - [emp-id (* total-sales commission-rate)])) - sales-totals)))]} - - :employee-bonuses - {:commando/apply - {:sales-totals {:commando/from [:calculations :employee-sales-totals]} - :threshold {:commando/from [:config :bonus-threshold]} - :bonus-amount {:commando/from [:config :performance-bonus]}} - :=> [:fn (fn [{:keys [sales-totals threshold bonus-amount]}] - (into {} - (map (fn [[emp-id total-sales]] - [emp-id (if (> total-sales threshold) bonus-amount 0)]) - sales-totals)))]} - - :employee-total-compensation - {:commando/fn (fn [commissions bonuses] - (merge-with + commissions bonuses)) - :args [{:commando/from [:calculations :employee-commissions]} - {:commando/from [:calculations :employee-bonuses]}]} - - :department-financials - {:commando/apply - {:employees {:commando/from [:employees]} - :sales-totals {:commando/from [:calculations :employee-sales-totals]} - :compensations {:commando/from [:calculations :employee-total-compensation]} - :op-costs {:commando/from [:config :department-op-cost]}} - :=> [:fn (fn [{:keys [employees sales-totals compensations op-costs]}] - (let [initial-agg {:sales {:total-revenue 0 :total-compensation 0} - :marketing {:total-revenue 0 :total-compensation 0} - :engineering {:total-revenue 0 :total-compensation 0}}] - (as-> (reduce-kv (fn [agg emp-id emp-data] - (let [dept (:department emp-data) - revenue (get sales-totals emp-id 0) - compensation (get compensations emp-id 0)] - (-> agg - (update-in [dept :total-revenue] + revenue) - (update-in [dept :total-compensation] + compensation)))) - initial-agg - employees) data - (merge-with - (fn [dept-data op-cost] - (let [profit (- (:total-revenue dept-data) - (+ (:total-compensation dept-data) op-cost))] - (assoc dept-data - :operating-cost op-cost - :net-profit profit))) - data - op-costs))))]}} - - :final-report - {:commando/apply - {:dept-financials {:commando/from [:calculations :department-financials]} - :total-sales-per-employee {:commando/from [:calculations :employee-sales-totals]} - :total-compensation-per-employee {:commando/from [:calculations :employee-total-compensation]} - :tax-rate {:commando/from [:config :tax-rate]}} - :=> [:fn (fn [{:keys [dept-financials total-sales-per-employee total-compensation-per-employee tax-rate]}] - (let [company-total-revenue (reduce + (map :total-revenue (vals dept-financials))) - company-total-compensation (reduce + (map :total-compensation (vals dept-financials))) - company-total-op-cost (reduce + (map :operating-cost (vals dept-financials))) - company-gross-profit (- company-total-revenue - (+ company-total-compensation company-total-op-cost)) - taxes-payable (* company-gross-profit tax-rate) - company-net-profit (- company-gross-profit taxes-payable)] - {:company-summary - {:total-revenue company-total-revenue - :total-compensation company-total-compensation - :total-operating-cost company-total-op-cost - :gross-profit company-gross-profit - :taxes-payable taxes-payable - :net-profit-after-tax company-net-profit} - :department-breakdown dept-financials - :employee-performance - {:top-earner (key (apply max-key val total-compensation-per-employee)) - :top-seller (key (apply max-key val total-sales-per-employee))}}))]}})) - - -;; ============================== -;; FLAME FOR RECURSIVE INVOCATION -;; ============================== - -(defmethod commando.commands.query-dsl/command-resolve :query-B [_ {:keys [x QueryExpression]}] - (let [x (or x 10)] - (-> {:map {:a - {:b {:c x} - :d {:c (inc x) - :f (inc (inc x))}}} - :query-A (commando.commands.query-dsl/resolve-instruction-qe - "error" - {:commando/resolve :query-A - :x 1})} - (commando.commands.query-dsl/->query-run QueryExpression)))) - -(defmethod commando.commands.query-dsl/command-resolve :query-A [_ {:keys [x QueryExpression]}] - (let [x (or x 10)] - (-> {:map {:a - {:b {:c x} - :d {:c (inc x) - :f (inc (inc x))}}} - - :resolve-fn (commando.commands.query-dsl/resolve-fn - "error" - (fn [{:keys [x]}] - (let [y (or x 1) - range-y (if (< 10 y) 10 y)] - (for [z (range 0 range-y)] - {:a - {:b {:c (+ y z)} - :d {:c (inc (+ y z)) - :f (inc (inc (+ y z)))}}})))) - - - - :instruction-A (commando.commands.query-dsl/resolve-instruction - "error" - {:__title "Resolve instruction-A" - :commando/fn (fn [& [y]] - {:a - {:b {:c y} - :d {:c (inc y) - :f (inc (inc y))}}}) - :args [x]}) - - - :query-A (commando.commands.query-dsl/resolve-instruction-qe - "error" - {:__title "Resolve query-A" - :commando/resolve :query-A - :x 1}) - :query-B (commando.commands.query-dsl/resolve-instruction-qe - "error" - {:__title "Resolve query-B" - :commando/resolve :query-B - :x 1})} - (commando.commands.query-dsl/->query-run QueryExpression)))) - -(defn run-execute-in-depth-with-using-queryDSL [] - (println "\n===================Benchmark=====================") - (println "Run commando/execute in depth with using queryDSL") - (println "=================================================") - (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] - {:__title "TOPLEVEL" - :commando/resolve :query-A - :x 1 - :QueryExpression - [{:map - [{:a - [:b]}]} - {:instruction-A [:a]} - {:query-A - [{:map - [{:a - [:b]}]} - {:query-A - [{:map - [{:a - [:b]}]} - {:query-A - [{:map - [{:a - [:b]}]}]}]}]} - {:query-B - [{:map - [{:a - [:b]}]} - {:query-A - [{:map - [{:a - [:b]}]} - {:query-A - [{:instruction-A [:a]}]}]}]}]}))) - -;; ===================================== -;; BUILDING DEPENDECY COMPLEX TEST CASES -;; ===================================== - -(defn instruction-build-v+m [{:keys [wide-n long-n]}] - {:dependecy-token (* 2 wide-n long-n) - :source-maps - (mapv (fn [_n] - (into {} (mapv (fn [v] [(keyword (str "k" v)) v]) - (range 1 wide-n)))) - (range 1 long-n)) - :result-maps - (mapv (fn [n] - (into {} - (mapv - (fn [v] - (let [k (keyword (str "k" v))] - [k {:commando/from [:source-maps n k]}])) - (range 1 wide-n)))) - (range 1 long-n))}) - -(defn instruction-build-m [{:keys [wide-n long-n]}] - {:dependecy-token (* 2 wide-n long-n) - :source-maps - (reduce (fn [acc n] - (assoc acc (keyword (str "r" n)) - (into {} (mapv (fn [v] [(keyword (str "k" v)) v]) - (range 1 wide-n))))) - {} - (range 1 long-n)) - :result-maps - (reduce (fn [acc n] - (assoc acc (keyword (str "r" n)) - (into {} - (mapv - (fn [v] - (let [k (keyword (str "k" v))] - [k {:commando/from [:source-maps (keyword (str "r" n)) k]}])) - (range 1 wide-n))))) - {} - (range 1 long-n))}) - -(defn execute-complexity [{:keys [mode wide-n long-n]}] - (let [instruction-builder (case mode - :m (instruction-build-m {:wide-n wide-n :long-n long-n}) - :v+m (instruction-build-v+m {:wide-n wide-n :long-n long-n}))] - (binding [commando.impl.utils/*execute-config* - {:debug-result true}] - (let [result (commando.core/execute - [commando.commands.builtin/command-from-spec] - instruction-builder) - stats-grouped (reduce (fn [acc [k v _label]] - (assoc acc k v)) - {} - (:stats result))] - {:dependecy-token (:dependecy-token instruction-builder) - :stats (:stats result) - :stats-grouped stats-grouped})))) - -;; ================================ -;; PLOT LOAD TEST CASES IN PNG FILE -;; WITH USING JFREECHART -;; ================================ - -(defn ^:private chat-custom-styles [chart] - (let [plotObject (.getPlot chart) - plotObjectRenderer (.getRenderer plotObject)] - (.setBackgroundPaint chart (java.awt.Color/new 255, 255, 255)) - (.setBackgroundPaint plotObject (java.awt.Color/new 255, 255, 255)) - (.setSeriesPaint plotObjectRenderer 0 (java.awt.Color/new 64, 115, 62)) - (.setSeriesPaint plotObjectRenderer 1 (java.awt.Color/new 62, 65, 115)) - (.setSeriesPaint plotObjectRenderer 2 (java.awt.Color/new 115, 94, 62)) - (.setSeriesPaint plotObjectRenderer 3 (java.awt.Color/new 115, 62, 62)) - (.setOutlineVisible plotObject false) - chart)) - -(defn execute-steps-grow_s_x_dep [] - (println "\n==================Benchmark====================") - (println "execute-steps(massive dep grow) secs_x_deps.png") - (println "===============================================") - (let [instruction-stats-result [(execute-complexity {:mode :v+m :wide-n 50 :long-n 50}) - (execute-complexity {:mode :v+m :wide-n 50 :long-n 500}) - (execute-complexity {:mode :v+m :wide-n 50 :long-n 5000}) - (execute-complexity {:mode :v+m :wide-n 50 :long-n 50000})] - chart-data (mapv (fn [e] (let [{:keys [dependecy-token stats-grouped]} e] - (-> stats-grouped - (dissoc "execute") - (update-vals (fn [nanosecs-t] - ;; (/ nanosecs-t 1000000) ;; miliseconds - (/ nanosecs-t 1000000000) ;; seconds - )) - (assoc "dependecy-token" dependecy-token)))) - instruction-stats-result)] - (doseq [{:keys [dependecy-token stats]} instruction-stats-result] - (commando-utils/print-stats {:stats stats} (str "Dependency Counts: " dependecy-token))) - (cljfreechart/save-chart-as-file - (-> chart-data - (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) - (cljfreechart/make-bar-chart "commando.core/execute steps on massive count of dependencies" - {:category-title "Dependency Counts" - :value-title "Seconds"}) - (chat-custom-styles)) - "./test/perf/commando/execute-steps(massive dep grow) secs_x_deps.png" {:width 1200 :height 400}))) - -(defn execute-steps-normal_ms_x_dep [] - (println "\n================Benchmark================") - (println "execute-steps(normal) milisecs_x_deps.png") - (println "=========================================") - (let [instruction-stats-result - [(execute-complexity {:mode :m :wide-n 5 :long-n 10}) - (execute-complexity {:mode :m :wide-n 5 :long-n 14}) - (execute-complexity {:mode :m :wide-n 5 :long-n 15}) - (execute-complexity {:mode :m :wide-n 5 :long-n 20})] - chart-data (mapv (fn [e] (let [{:keys [dependecy-token stats-grouped]} e] - (-> stats-grouped - (dissoc "execute") - (update-vals (fn [nanosecs-t] - (/ nanosecs-t 1000000) ;; miliseconds - ;; (/ nanosecs-t 1000000000) ;; seconds - )) - (assoc "dependecy-token" dependecy-token)))) - instruction-stats-result)] - (doseq [{:keys [dependecy-token stats]} instruction-stats-result] - (commando-utils/print-stats {:stats stats} (str "Dependency Counts: " dependecy-token))) - (cljfreechart/save-chart-as-file - (-> chart-data - (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) - (cljfreechart/make-bar-chart "commando.core/execute steps" - {:category-title "Dependency Counts" - :value-title "Miliseconds"}) - (chat-custom-styles)) - "./test/perf/commando/execute-steps(normal) milisecs_x_deps.png" {:width 1200 :height 400}))) - -(defn execute-normal_ms_x_dep [] - (println "\n=============Benchmark=============") - (println "execute(normal) milisecs_x_deps.png") - (println "===================================") - (let [instruction-stats-result - [(execute-complexity {:mode :v+m :wide-n 25 :long-n 25}) - (execute-complexity {:mode :v+m :wide-n 50 :long-n 50}) - (execute-complexity {:mode :v+m :wide-n 100 :long-n 100}) - (execute-complexity {:mode :v+m :wide-n 200 :long-n 200})] - chart-data (mapv (fn [e] (let [{:keys [dependecy-token stats-grouped]} e] - (-> stats-grouped - (select-keys ["execute"]) - (update-vals (fn [nanosecs-t] - (float (/ nanosecs-t 1000000)) ;; miliseconds - ;; (float (/ nanosecs-t 1000000000)) ;; seconds - )) - (assoc "dependecy-token" dependecy-token)))) - instruction-stats-result)] - (doseq [{:keys [dependecy-token stats]} instruction-stats-result] - (commando-utils/print-stats {:stats stats} (str "Dependency Counts: " dependecy-token))) - (cljfreechart/save-chart-as-file - (-> chart-data - (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) - (cljfreechart/make-bar-chart "commando.core/execute times" - {:category-title "Dependency Counts" - :value-title "Miliseconds"}) - (chat-custom-styles)) - "./test/perf/commando/execute(normal) milisecs_x_deps.png" {:width 1200 :height 400}))) - -(defn -main [] - ;; Execution stats. - (real-word-calculation-average-of-50) - (run-execute-in-depth-with-using-queryDSL) - ;; Drow plot for special cases. - (execute-steps-normal_ms_x_dep) - (execute-normal_ms_x_dep) - (execute-steps-grow_s_x_dep)) - diff --git a/test/perf/commando/execute(normal) milisecs_x_deps.png b/test/perf/commando/execute(normal) milisecs_x_deps.png deleted file mode 100644 index 4f28981..0000000 --- a/test/perf/commando/execute(normal) milisecs_x_deps.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c95740b0ee83130fb35d576357fcc8912fc9f4223e284f2faa87d8f5a47e3dbc -size 20366 diff --git a/test/perf/commando/execute-steps(massive dep grow) secs_x_deps.png b/test/perf/commando/execute-steps(massive dep grow) secs_x_deps.png deleted file mode 100644 index df05d85..0000000 --- a/test/perf/commando/execute-steps(massive dep grow) secs_x_deps.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6114d208235d618bb388771c635eae98ffed6b1234887366b5463382bca8f1bd -size 18310 diff --git a/test/perf/commando/execute-steps(normal) milisecs_x_deps.png b/test/perf/commando/execute-steps(normal) milisecs_x_deps.png deleted file mode 100644 index 0979830..0000000 --- a/test/perf/commando/execute-steps(normal) milisecs_x_deps.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5a12d26057edc1fb0406047f40774167eea092625014ea74f13c92127ab170f7 -size 21889 diff --git a/test/perf/commando/execute/Execute. Massive Dependency Steps.png b/test/perf/commando/execute/Execute. Massive Dependency Steps.png new file mode 100644 index 0000000..70089f3 --- /dev/null +++ b/test/perf/commando/execute/Execute. Massive Dependency Steps.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b52fc71a895b7a4c68a84e1064687ba46d75d03c3f44ad2dc7490582d2952661 +size 16748 diff --git a/test/perf/commando/execute/Execute. Normal Steps.png b/test/perf/commando/execute/Execute. Normal Steps.png new file mode 100644 index 0000000..b828f99 --- /dev/null +++ b/test/perf/commando/execute/Execute. Normal Steps.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11421352641cb86307b80eaff32a31ab21c52797b29c564be399570157652b13 +size 24806 diff --git a/test/perf/commando/execute/Execute. Normal Total.png b/test/perf/commando/execute/Execute. Normal Total.png new file mode 100644 index 0000000..51ae000 --- /dev/null +++ b/test/perf/commando/execute/Execute. Normal Total.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ac53573c13516e3f121d695f03483f9b666312996a8edbe9e39dc8838177808 +size 15432 diff --git a/test/perf/commando/execute/Execute. Real World Avg50.png b/test/perf/commando/execute/Execute. Real World Avg50.png new file mode 100644 index 0000000..958c095 --- /dev/null +++ b/test/perf/commando/execute/Execute. Real World Avg50.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb21844e608c4077d654a497c550baa710ed51b031b0e1d9b34443e22854fd52 +size 19466 diff --git a/test/perf/commando/execute/QueryDSL. Recursive Execution.png b/test/perf/commando/execute/QueryDSL. Recursive Execution.png new file mode 100644 index 0000000..9ca8293 --- /dev/null +++ b/test/perf/commando/execute/QueryDSL. Recursive Execution.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c13650ebc7c516469b2975fb430c76499dfb89fc8b6d14327fb25038f7223d1d +size 18515 diff --git a/test/perf/commando/execute/execute_test.clj b/test/perf/commando/execute/execute_test.clj new file mode 100644 index 0000000..b65dfd3 --- /dev/null +++ b/test/perf/commando/execute/execute_test.clj @@ -0,0 +1,525 @@ +(ns commando.execute.execute-test + (:require + [cljfreechart.core :as cljfreechart] + [commando.commands.builtin] + [commando.commands.query-dsl] + [commando.core] + [commando.debug :as debug] + [commando.impl.utils :as commando-utils])) + +;; ===================================== +;; BUILDING DEPENDENCY COMPLEX TEST CASES +;; ===================================== + +(defn build-instruction-vec+map [{:keys [wide-n long-n]}] + {:dependency-token (* 2 wide-n long-n) + :source-maps + (mapv (fn [_n] + (into {} (mapv (fn [v] [(keyword (str "k" v)) v]) + (range 1 wide-n)))) + (range 1 long-n)) + :result-maps + (mapv (fn [n] + (into {} + (mapv + (fn [v] + (let [k (keyword (str "k" v))] + [k {:commando/from [:source-maps n k]}])) + (range 1 wide-n)))) + (range 1 long-n))}) + +(defn build-instruction-map [{:keys [wide-n long-n]}] + {:dependency-token (* 2 wide-n long-n) + :source-maps + (reduce (fn [acc n] + (assoc acc (keyword (str "r" n)) + (into {} (mapv (fn [v] [(keyword (str "k" v)) v]) + (range 1 wide-n))))) + {} + (range 1 long-n)) + :result-maps + (reduce (fn [acc n] + (assoc acc (keyword (str "r" n)) + (into {} + (mapv + (fn [v] + (let [k (keyword (str "k" v))] + [k {:commando/from [:source-maps (keyword (str "r" n)) k]}])) + (range 1 wide-n))))) + {} + (range 1 long-n))}) + +(defn execute-complexity [{:keys [mode wide-n long-n]}] + (let [instruction-builder (case mode + :m (build-instruction-map {:wide-n wide-n :long-n long-n}) + :v+m (build-instruction-vec+map {:wide-n wide-n :long-n long-n}))] + (let [result (commando.core/execute + [commando.commands.builtin/command-from-spec] + instruction-builder) + stats-grouped (reduce (fn [acc [k v _label]] + (assoc acc k v)) + {} + (:stats result))] + {:dependency-token (:dependency-token instruction-builder) + :stats (:stats result) + :stats-grouped stats-grouped}))) + +;; ======================================= +;; CHART HELPERS +;; ======================================= + +(def ^:private perf-output-dir "./test/perf/commando/execute/") + +(def ^:private modern-palette + [(java.awt.Color/new 79 134 247) ;; blue + (java.awt.Color/new 52 199 137) ;; green + (java.awt.Color/new 247 181 56) ;; amber + (java.awt.Color/new 239 83 80) ;; red + (java.awt.Color/new 156 120 242) ;; purple + (java.awt.Color/new 38 198 218)]) ;; cyan + +(def ^:private font-family "SansSerif") + +(defn ^:private chart-custom-styles [chart] + (let [plot (.getPlot chart) + renderer (.getRenderer plot) + domain-axis (.getDomainAxis plot) + range-axis (.getRangeAxis plot) + white (java.awt.Color/new 255 255 255) + grid-color (java.awt.Color/new 230 230 230) + text-color (java.awt.Color/new 60 60 60) + title-font (java.awt.Font. font-family java.awt.Font/BOLD 15) + axis-label-font (java.awt.Font. font-family java.awt.Font/PLAIN 12) + tick-font (java.awt.Font. font-family java.awt.Font/PLAIN 11) + legend-font (java.awt.Font. font-family java.awt.Font/PLAIN 11)] + ;; backgrounds + (.setBackgroundPaint chart white) + (.setBackgroundPaint plot white) + (.setOutlineVisible plot false) + ;; title + (when-let [title (.getTitle chart)] + (.setFont title title-font) + (.setPaint title text-color)) + ;; grid + (.setRangeGridlinePaint plot grid-color) + (.setRangeGridlineStroke plot (java.awt.BasicStroke. 1.0)) + (.setDomainGridlinesVisible plot false) + ;; axes + (doto domain-axis + (.setLabelFont axis-label-font) + (.setLabelPaint text-color) + (.setTickLabelFont tick-font) + (.setTickLabelPaint text-color) + (.setAxisLinePaint grid-color) + (.setTickMarkPaint grid-color)) + (doto range-axis + (.setLabelFont axis-label-font) + (.setLabelPaint text-color) + (.setTickLabelFont tick-font) + (.setTickLabelPaint text-color) + (.setAxisLinePaint grid-color) + (.setTickMarkPaint grid-color)) + ;; bars + (.setShadowVisible renderer false) + (.setBarPainter renderer (org.jfree.chart.renderer.category.StandardBarPainter.)) + (.setItemMargin renderer 0.02) + (.setMaximumBarWidth renderer 0.08) + ;; series colors + (doseq [i (range (count modern-palette))] + (.setSeriesPaint renderer i (nth modern-palette i))) + ;; legend + (when-let [legend (.getLegend chart)] + (.setItemFont legend legend-font) + (.setItemPaint legend text-color) + (.setBackgroundPaint legend white) + (.setFrame legend (org.jfree.chart.block.BlockBorder. white))) + chart)) + +(defn ^:private save-benchmark-chart + [{:keys [chart-data group-key chart-title category-title value-title file-name + width height] + :or {width 1200 height 400}}] + (cljfreechart/save-chart-as-file + (-> chart-data + (cljfreechart/make-category-dataset {:group-key group-key}) + (cljfreechart/make-bar-chart chart-title + {:category-title category-title + :value-title value-title}) + (chart-custom-styles)) + (str perf-output-dir file-name) {:width width :height height})) + +(defn ^:private print-stats [stats] + (#'debug/pprint-stats-mode + {:stats stats :status :ok :errors [] :internal/cm-running-order []} + nil)) + +(defn ^:private run-complexity-benchmark + [{:keys [title file-name runs stats-transform value-title]}] + (let [results (mapv execute-complexity runs)] + (doseq [{:keys [dependency-token stats]} results] + (println (str "Dependency Counts: " dependency-token)) + (print-stats stats)) + (save-benchmark-chart + {:chart-data (mapv (fn [{:keys [dependency-token stats-grouped]}] + (-> (stats-transform stats-grouped) + (assoc "dependency-token" dependency-token))) + results) + :group-key "dependency-token" + :chart-title title + :category-title "Dependency Counts" + :value-title value-title + :file-name file-name}))) + +;; ======================================= +;; AVERAGE EXECUTION OF REAL WORLD EXAMPLE +;; ======================================= + +(defn ^:private calculate-average-stats + "Takes a collection of status-maps and calculates the average duration for each stat-key." + [status-maps] + {:pre [(not-empty status-maps)]} + (let [keys-order (map first (:stats (first status-maps))) + all-stats (mapcat :stats status-maps) + grouped-stats (group-by first all-stats) + averages-grouped + (reduce (fn [acc [stat-key measurements]] + (let [total-duration (reduce + (map second measurements)) + count-measurements (count measurements) + average-duration (long (/ total-duration count-measurements))] + (assoc acc stat-key [stat-key average-duration (commando-utils/format-time average-duration)]))) + {} grouped-stats)] + {:stats (mapv #(get averages-grouped %) keys-order)})) + +(defmacro ^:private repeat-n-execute + "Repeats the execution of `body` `n` times, returns average stats." + [n & body] + `(let [results# (doall (for [_# (range ~n)] + ~@body))] + (calculate-average-stats results#))) + +(defn real-world-calculation-average-of-50 [] + (let [avg-stats + (repeat-n-execute 50 + (commando.core/execute + [commando.commands.builtin/command-fn-spec + commando.commands.builtin/command-from-spec + commando.commands.builtin/command-apply-spec] + { ;; -------------------------------------------------------------------------------- + ;; RAW DATA & CONFIGURATION + ;; -------------------------------------------------------------------------------- + :config + {:commission-rates {:standard 0.07 :senior 0.12} + :bonus-threshold 50000 + :performance-bonus 2500 + :tax-rate 0.21 + :department-op-cost {:sales 15000 :marketing 10000 :engineering 25000}} + + :products + {"prod-001" {:name "Alpha Widget" :price 250.0} + "prod-002" {:name "Beta Gadget" :price 475.0} + "prod-003" {:name "Gamma Gizmo" :price 1200.0}} + + :employees + {"emp-101" {:name "John Doe" :department :sales :level :senior} + "emp-102" {:name "Jane Smith" :department :sales :level :standard} + "emp-103" {:name "Peter Jones" :department :marketing :level :senior} + "emp-201" {:name "Mary Major" :department :engineering :level :standard}} + + :sales-records + [ ;; John's Sales + {:employee-id "emp-101" :product-id "prod-003" :units-sold 50} + {:employee-id "emp-101" :product-id "prod-001" :units-sold 120} + ;; Jane's Sales + {:employee-id "emp-102" :product-id "prod-001" :units-sold 80} + {:employee-id "emp-102" :product-id "prod-002" :units-sold 40} + ;; Peter's Sales (Marketing can also sell) + {:employee-id "emp-103" :product-id "prod-002" :units-sold 10}] + + :calculations + {:sales-revenues + {:commando/fn (fn [sales products] + (mapv (fn [sale] + (let [product (get products (:product-id sale))] + (assoc sale :total-revenue (* (:units-sold sale) (:price product))))) + sales)) + :args [{:commando/from [:sales-records]} + {:commando/from [:products]}]} + + :employee-sales-totals + {:commando/fn (fn [sales-revenues] + (reduce (fn [acc sale] + (update acc + (:employee-id sale) + (fnil + 0) + (:total-revenue sale))) + {} + sales-revenues)) + :args [{:commando/from [:calculations :sales-revenues]}]} + + :employee-commissions + {:commando/apply + {:sales-totals {:commando/from [:calculations :employee-sales-totals]} + :employees {:commando/from [:employees]} + :rates {:commando/from [:config :commission-rates]}} + :=> [:fn (fn [{:keys [sales-totals employees rates]}] + (into {} + (map (fn [[emp-id total-sales]] + (let [employee (get employees emp-id) + rate-key (:level employee) + commission-rate (get rates rate-key 0)] + [emp-id (* total-sales commission-rate)])) + sales-totals)))]} + + :employee-bonuses + {:commando/apply + {:sales-totals {:commando/from [:calculations :employee-sales-totals]} + :threshold {:commando/from [:config :bonus-threshold]} + :bonus-amount {:commando/from [:config :performance-bonus]}} + :=> [:fn (fn [{:keys [sales-totals threshold bonus-amount]}] + (into {} + (map (fn [[emp-id total-sales]] + [emp-id (if (> total-sales threshold) bonus-amount 0)]) + sales-totals)))]} + + :employee-total-compensation + {:commando/fn (fn [commissions bonuses] + (merge-with + commissions bonuses)) + :args [{:commando/from [:calculations :employee-commissions]} + {:commando/from [:calculations :employee-bonuses]}]} + + :department-financials + {:commando/apply + {:employees {:commando/from [:employees]} + :sales-totals {:commando/from [:calculations :employee-sales-totals]} + :compensations {:commando/from [:calculations :employee-total-compensation]} + :op-costs {:commando/from [:config :department-op-cost]}} + :=> [:fn (fn [{:keys [employees sales-totals compensations op-costs]}] + (let [initial-agg {:sales {:total-revenue 0 :total-compensation 0} + :marketing {:total-revenue 0 :total-compensation 0} + :engineering {:total-revenue 0 :total-compensation 0}}] + (as-> (reduce-kv (fn [agg emp-id emp-data] + (let [dept (:department emp-data) + revenue (get sales-totals emp-id 0) + compensation (get compensations emp-id 0)] + (-> agg + (update-in [dept :total-revenue] + revenue) + (update-in [dept :total-compensation] + compensation)))) + initial-agg + employees) data + (merge-with + (fn [dept-data op-cost] + (let [profit (- (:total-revenue dept-data) + (+ (:total-compensation dept-data) op-cost))] + (assoc dept-data + :operating-cost op-cost + :net-profit profit))) + data + op-costs))))]}} + + :final-report + {:commando/apply + {:dept-financials {:commando/from [:calculations :department-financials]} + :total-sales-per-employee {:commando/from [:calculations :employee-sales-totals]} + :total-compensation-per-employee {:commando/from [:calculations :employee-total-compensation]} + :tax-rate {:commando/from [:config :tax-rate]}} + :=> [:fn (fn [{:keys [dept-financials total-sales-per-employee total-compensation-per-employee tax-rate]}] + (let [company-total-revenue (reduce + (map :total-revenue (vals dept-financials))) + company-total-compensation (reduce + (map :total-compensation (vals dept-financials))) + company-total-op-cost (reduce + (map :operating-cost (vals dept-financials))) + company-gross-profit (- company-total-revenue + (+ company-total-compensation company-total-op-cost)) + taxes-payable (* company-gross-profit tax-rate) + company-net-profit (- company-gross-profit taxes-payable)] + {:company-summary + {:total-revenue company-total-revenue + :total-compensation company-total-compensation + :total-operating-cost company-total-op-cost + :gross-profit company-gross-profit + :taxes-payable taxes-payable + :net-profit-after-tax company-net-profit} + :department-breakdown dept-financials + :employee-performance + {:top-earner (key (apply max-key val total-compensation-per-employee)) + :top-seller (key (apply max-key val total-sales-per-employee))}}))]}})) + chart-data [(-> (reduce (fn [acc [stat-key nanos _]] + (assoc acc stat-key (float (/ nanos 1000000)))) + {} (:stats avg-stats)) + (assoc "benchmark" "avg of 50"))]] + (println "Repeating instruction 50 times") + (print-stats (:stats avg-stats)) + (save-benchmark-chart + {:chart-data chart-data + :group-key "benchmark" + :chart-title "Real World calculation — average pipeline steps (50 runs)" + :category-title "Pipeline Step" + :value-title "Milliseconds" + :file-name "Execute. Real World Avg50.png"}))) + + +;; ============================== +;; FLAME FOR RECURSIVE INVOCATION +;; ============================== + +(defmethod commando.commands.query-dsl/command-resolve :query-B [_ {:keys [x QueryExpression]}] + (let [x (or x 10)] + (-> {:map {:a + {:b {:c x} + :d {:c (inc x) + :f (inc (inc x))}}} + :query-A (commando.commands.query-dsl/resolve-instruction-qe + "error" + {:commando/resolve :query-A + :x 1})} + (commando.commands.query-dsl/->query-run QueryExpression)))) + +(defmethod commando.commands.query-dsl/command-resolve :query-A [_ {:keys [x QueryExpression]}] + (let [x (or x 10)] + (-> {:map {:a + {:b {:c x} + :d {:c (inc x) + :f (inc (inc x))}}} + + :resolve-fn (commando.commands.query-dsl/resolve-fn + "error" + (fn [{:keys [x]}] + (let [y (or x 1) + range-y (if (< 10 y) 10 y)] + (for [z (range 0 range-y)] + {:a + {:b {:c (+ y z)} + :d {:c (inc (+ y z)) + :f (inc (inc (+ y z)))}}})))) + + + + :instruction-A (commando.commands.query-dsl/resolve-instruction + "error" + {:__title "Resolve instruction-A" + :commando/fn (fn [& [y]] + {:a + {:b {:c y} + :d {:c (inc y) + :f (inc (inc y))}}}) + :args [x]}) + + + :query-A (commando.commands.query-dsl/resolve-instruction-qe + "error" + {:__title "Resolve query-A" + :commando/resolve :query-A + :x 1}) + :query-B (commando.commands.query-dsl/resolve-instruction-qe + "error" + {:__title "Resolve query-B" + :commando/resolve :query-B + :x 1})} + (commando.commands.query-dsl/->query-run QueryExpression)))) + +(defn run-execute-in-depth-with-using-queryDSL [] + (let [result + (debug/execute-trace + [commando.commands.query-dsl/command-resolve-spec + commando.commands.builtin/command-from-spec + commando.commands.builtin/command-fn-spec] + {:__title "TOPLEVEL" + :commando/resolve :query-A + :x 1 + :QueryExpression + [{:map + [{:a + [:b]}]} + {:instruction-A [:a]} + {:query-A + [{:map + [{:a + [:b]}]} + {:query-A + [{:map + [{:a + [:b]}]} + {:query-A + [{:map + [{:a + [:b]}]}]}]}]} + {:query-B + [{:map + [{:a + [:b]}]} + {:query-A + [{:map + [{:a + [:b]}]} + {:query-A + [{:instruction-A [:a]}]}]}]}]}) + chart-data [(-> (reduce (fn [acc [stat-key nanos _]] + (assoc acc stat-key (float (/ nanos 1000000)))) + {} (:stats result)) + (assoc "benchmark" "queryDSL recursive"))]] + (save-benchmark-chart + {:chart-data chart-data + :group-key "benchmark" + :chart-title "Recursive QueryDSL execution — pipeline steps" + :category-title "Pipeline Step" + :value-title "Milliseconds" + :file-name "QueryDSL. Recursive Execution.png"}))) + + + +;; ================================ +;; DEPENDENCY SCALING BENCHMARKS +;; ================================ + +(defn execute-steps-grow_s_x_dep [] + (run-complexity-benchmark + {:title "commando.core/execute steps on massive count of dependencies" + :file-name "Execute. Massive Dependency Steps.png" + :value-title "Seconds" + :runs [{:mode :v+m :wide-n 50 :long-n 50} + {:mode :v+m :wide-n 50 :long-n 500} + {:mode :v+m :wide-n 50 :long-n 5000} + {:mode :v+m :wide-n 50 :long-n 50000}] + :stats-transform (fn [stats-grouped] + (-> stats-grouped + (dissoc "execute") + (update-vals (fn [nanosecs-t] + (/ nanosecs-t 1000000000)))))})) + +(defn execute-steps-normal_ms_x_dep [] + (run-complexity-benchmark + {:title "commando.core/execute steps" + :file-name "Execute. Normal Steps.png" + :value-title "Milliseconds" + :runs [{:mode :m :wide-n 5 :long-n 10} + {:mode :m :wide-n 5 :long-n 14} + {:mode :m :wide-n 5 :long-n 15} + {:mode :m :wide-n 5 :long-n 20}] + :stats-transform (fn [stats-grouped] + (-> stats-grouped + (dissoc "execute") + (update-vals (fn [nanosecs-t] + (/ nanosecs-t 1000000)))))})) + +(defn execute-normal_ms_x_dep [] + (run-complexity-benchmark + {:title "commando.core/execute times" + :file-name "Execute. Normal Total.png" + :value-title "Milliseconds" + :runs [{:mode :v+m :wide-n 25 :long-n 25} + {:mode :v+m :wide-n 50 :long-n 50} + {:mode :v+m :wide-n 100 :long-n 100} + {:mode :v+m :wide-n 200 :long-n 200}] + :stats-transform (fn [stats-grouped] + (-> stats-grouped + (select-keys ["execute"]) + (update-vals (fn [nanosecs-t] + (float (/ nanosecs-t 1000000))))))})) + +(defn -main [] + ;; Real-world scenario with chart. + (real-world-calculation-average-of-50) + ;; Recursive QueryDSL with chart. + (run-execute-in-depth-with-using-queryDSL) + ;; Dependency scaling benchmarks. + (execute-steps-normal_ms_x_dep) + (execute-normal_ms_x_dep) + (execute-steps-grow_s_x_dep)) diff --git a/test/unit/commando/commands/builtin_test.cljc b/test/unit/commando/commands/builtin_test.cljc index 38165d4..4fba0bd 100644 --- a/test/unit/commando/commands/builtin_test.cljc +++ b/test/unit/commando/commands/builtin_test.cljc @@ -2,9 +2,9 @@ (:require #?(:cljs [cljs.test :refer [deftest is testing]] :clj [clojure.test :refer [deftest is testing]]) + [clojure.string] [commando.commands.builtin :as command-builtin] [commando.core :as commando] - [commando.impl.utils :as commando-utils] [malli.core :as malli] [commando.test-helpers :as helpers])) @@ -31,12 +31,10 @@ (testing "Failure test cases" (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-fn-spec] + (commando/execute [command-builtin/command-fn-spec] {:commando/fn "STRING" - :args [[1 2 3] [3 2 1]]})) + :args [[1 2 3] [3 2 1]]} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -50,12 +48,10 @@ "Waiting on error, bacause commando/fn has wrong type for :commando/fn") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-fn-spec] + (commando/execute [command-builtin/command-fn-spec] {:commando/fn (fn [& [v1 v2]] (reduce + (map * v1 v2))) - :args "BROKEN"})) + :args "BROKEN"} + {:error-data-string false}) (fn [error] (= (-> error :error :data (dissoc :value)) @@ -80,7 +76,17 @@ :=> [:fn (fn [e] (-> e :value inc))]} :result-with-deps {:commando/apply {:commando/from [:value]} :=> [:fn inc]}}))) - "Uncorrectly processed :commando/apply"))) + "Uncorrectly processed :commando/apply") + (is (= {"0" {:final "5"}} + (->> {"0" {:commando/apply {"1" {:commando/apply {"2" {:commando/apply {"3" {:commando/apply {"4" {:final + "5"}} + :=> [:get "4"]}} + :=> [:get "3"]}} + :=> [:get "2"]}} + :=> [:get "1"]}} + (commando/execute [command-builtin/command-apply-spec]) + :instruction)) + "Nested apply: commands inside commands are executed correctly"))) ;; =========================== ;; FROM-SPEC @@ -217,9 +223,9 @@ (testing "Failure test cases" (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* {:error-data-string false}] - (commando/execute [command-builtin/command-from-spec] - {:ref {:commando/from ["@nonexistent" :value]}})) + (commando/execute [command-builtin/command-from-spec] + {:ref {:commando/from ["@nonexistent" :value]}} + {:error-data-string false}) (fn [error] (= (get-in error [:error :data]) @@ -229,10 +235,10 @@ "Anchor not found: should produce error with :anchor key in data") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* {:error-data-string false}] - (commando/execute [command-builtin/command-from-spec] + (commando/execute [command-builtin/command-from-spec] {"source" {:a 1 :b 2} - "missing" {:commando/from ["UNEXISING"]}})) + "missing" {:commando/from ["UNEXISING"]}} + {:error-data-string false}) (fn [error] (= (get-in error [:error :data]) @@ -242,10 +248,10 @@ "Waiting on error, bacause commando/from seding to unexising path") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* {:error-data-string false}] - (commando/execute [command-builtin/command-from-spec] + (commando/execute [command-builtin/command-from-spec] {"source" {:a 1 :b 2} - "missing" {"commando-from" ["UNEXISING"]}})) + "missing" {"commando-from" ["UNEXISING"]}} + {:error-data-string false}) (fn [error] (= (get-in error [:error :data]) @@ -255,13 +261,11 @@ "Waiting on error, bacause \"commando-from\" seding to unexising path") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-from-spec] + (commando/execute [command-builtin/command-from-spec] {"value" 1 "result" {:commando/from ["value"] - "commando-from" ["value"]}})) + "commando-from" ["value"]}} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -271,12 +275,10 @@ :value {:commando/from ["value"], "commando-from" ["value"]}}))) "Using string and keyword form shouldn't be allowed") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute + (commando/execute [command-builtin/command-from-spec] - {:commando/from "BROKEN"})) + {:commando/from "BROKEN"} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -285,7 +287,19 @@ :value {:commando/from "BROKEN"} :reason {:commando/from ["commando/from should be a sequence path to value in Instruction: [:some 2 \"value\"]"]}}))) "Waiting on error, ':validate-params-fn' for commando/from. Corrupted path \"BROKEN\" ") -)) + (testing ":point dependency lookup in set/list cause a failure" + (is (commando/ok? (commando/execute [command-builtin/command-from-spec] + {"map" {:a 1 :b 2} "=" {:commando/from ["map" :a]}})) + "Map lookup succeeds") + (is (= 1 (get-in (commando/execute [command-builtin/command-from-spec] + {"vector" [1 2 3] "=" {:commando/from ["vector" 0]}}) + [:instruction "="]))) + (is (commando/failed? (commando/execute [command-builtin/command-from-spec] + {"set" #{1 2 3} "=" {:commando/from ["set" 0]}})) + "Set lookup fails") + (is (commando/failed? (commando/execute [command-builtin/command-from-spec] + {"list" (list 1 2 3) "=" {:commando/from ["list" 0]}})) + "List lookup fails")))) ;; =========================== ;; CONTEXT-SPEC @@ -332,10 +346,9 @@ "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 [ctx-spec] - {:val {:commando/context "NOT-A-PATH"}})) + (commando/execute [ctx-spec] + {:val {:commando/context "NOT-A-PATH"}} + {:error-data-string false}) (fn [error] (= (-> error :error :data) {:command-type :commando/context @@ -344,10 +357,9 @@ :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 [ctx-spec] - {:val {:commando/context [:colors] "commando-context" ["colors"]}})) + (commando/execute [ctx-spec] + {:val {:commando/context [:colors] "commando-context" ["colors"]}} + {:error-data-string false}) (fn [error] (= (-> error :error :data) {:command-type :commando/context @@ -404,14 +416,12 @@ (testing "Failure test cases" (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-mutation-spec] + (commando/execute [command-builtin/command-mutation-spec] {:commando/mutation :dot-product "commando-mutation" "dot-product" "vector1" [1 2 3] - "vector2" [3 2 1]})) + "vector2" [3 2 1]} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -426,11 +436,9 @@ "Using string and keyword form shouldn't be allowed") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-mutation-spec] - {:commando/mutation (fn [] "BROKEN")})) + (commando/execute [command-builtin/command-mutation-spec] + {:commando/mutation (fn [] "BROKEN")} + {:error-data-string false}) (fn [error] (= (-> error :error :data (dissoc :value)) @@ -440,13 +448,11 @@ "Waiting on error, bacause commando/mutation has wrong type for :commando/mutation") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-mutation-spec] + (commando/execute [command-builtin/command-mutation-spec] {:commando/mutation :dot-product :vector1 [1 "_" 3] - :vector2 [3 2 1]})) + :vector2 [3 2 1]} + {:error-data-string false}) (fn [error] (= (-> error :error (helpers/remove-stacktrace) (dissoc :data)) @@ -536,10 +542,7 @@ (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/execute [command-builtin/command-macro-spec command-builtin/command-fn-spec command-builtin/command-from-spec @@ -547,7 +550,8 @@ {:commando/macro :string-vectors-dot-product "commando-macro" "string-vectors-dot-product" "vector1-str" ["1" "2" "3"] - "vector2-str" ["4" "5" "6"]})) + "vector2-str" ["4" "5" "6"]} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -562,11 +566,9 @@ "Using string and keyword form shouldn't be allowed") (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute [command-builtin/command-macro-spec] - {:commando/macro (fn [])})) + (commando/execute [command-builtin/command-macro-spec] + {:commando/macro (fn [])} + {:error-data-string false}) (fn [error] (= (-> error :error :data (dissoc :value)) @@ -574,3 +576,18 @@ :reason {:commando/macro ["should be a keyword" "should be a string"]}, :path []}))) "Waiting on error, bacause commando/mutation has wrong type for :commando/mutation"))) + +;; =========================== +;; NON-MAP COMMAND DATA +;; =========================== + +(deftest non-map-command-data + (testing "String-based recognize-fn (command data is not a map)" + (let [bang-spec {:type :bang + :recognize-fn #(and (string? %) (clojure.string/ends-with? % "!")) + :apply (fn [_ _ s] (clojure.string/upper-case s)) + :dependencies {:mode :none}} + result (commando/execute [bang-spec] {:calm "hello" :excited "hello!"})] + (is (commando/ok? result) "Non-map command executes without error") + (is (= "hello" (get-in (:instruction result) [:calm])) "Non-command values unchanged") + (is (= "HELLO!" (get-in (:instruction result) [:excited])) "String command applied correctly")))) diff --git a/test/unit/commando/commands/query_dsl_test.cljc b/test/unit/commando/commands/query_dsl_test.cljc index 4ccc1cd..ff013c6 100644 --- a/test/unit/commando/commands/query_dsl_test.cljc +++ b/test/unit/commando/commands/query_dsl_test.cljc @@ -5,7 +5,6 @@ [clojure.string :as string] [commando.commands.builtin :as command-builtin] [commando.commands.query-dsl :as command-query-dsl] - [commando.impl.utils :as commando-utils] [commando.core :as commando] [commando.test-helpers :as helpers])) @@ -514,16 +513,14 @@ (is (helpers/status-map-contains-error? - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute + (commando/execute registry {:commando/resolve :test-instruction-qe "commando-resolve" :test-instruction-qe :x 1 :QueryExpression - [:string]})) + [:string]} + {:error-data-string false}) (fn [error] (= (-> error :error :data) @@ -568,16 +565,14 @@ (is (helpers/status-map-contains-error? (get-in - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute + (commando/execute registry {:commando/resolve :test-instruction-qe :x 20 :QueryExpression [{:resolve-fn-error - [:a]}]})) + [:a]}]} + {:error-data-string false}) [:instruction :resolve-fn-error]) (fn [error] (= @@ -591,16 +586,14 @@ (is (helpers/status-map-contains-error? (get-in - (binding [commando-utils/*execute-config* - {:debug-result false - :error-data-string false}] - (commando/execute + (commando/execute registry {:commando/resolve :test-instruction-qe :x 20 :QueryExpression [{:resolve-instruction-with-error - [{:a [:b]}]}]})) + [{:a [:b]}]}]} + {:error-data-string false}) [:instruction :resolve-instruction-with-error]) (fn [error] (= diff --git a/test/unit/commando/core_test.cljc b/test/unit/commando/core_test.cljc index e0f7cae..61c4c80 100644 --- a/test/unit/commando/core_test.cljc +++ b/test/unit/commando/core_test.cljc @@ -3,14 +3,13 @@ #?(:cljs [cljs.test :refer [deftest is testing]] :clj [clojure.test :refer [deftest is testing]]) [commando.commands.builtin :as cmds-builtin] - [commando.impl.utils :as commando-utils] [commando.core :as commando] [commando.impl.command-map :as cm] - [commando.test-helpers :as helpers] + [commando.impl.pathtrie :as pathtrie] + [commando.impl.utils :as utils] [malli.core :as malli] [commando.impl.registry :as commando-registry])) - (def test-add-id-command {:type :test/add-id :recognize-fn #(and (map? %) (contains? % :test/add-id)) @@ -24,523 +23,31 @@ (commando-registry/build) (commando-registry/enrich-runtime-registry))) -(def fail-validation-command - {:type :fail-validation - :recognize-fn #(and (map? %) (contains? % :should-fail)) - :validate-params-fn (fn [_] false) - :apply identity - :dependencies {:mode :all-inside}}) - -(def fail-recognize-command - {:type :fail-recognize - :recognize-fn (fn [_] (throw (ex-info "Recognition failed" {}))) - :apply identity - :dependencies {:mode :all-inside}}) +;; -- Failing commands -- -(deftest find-commands - (testing "Basic cases" - (is (= [(cm/->CommandMapPath [] #'commando-registry/default-command-map-spec)] - (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {} - :registry registry}))) - "Empty instruction return _map command") - (is (= [(cm/->CommandMapPath [] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:some-val] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:some-other] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:my-value] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:i] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:v] #'commando-registry/default-command-vec-spec) - (cm/->CommandMapPath [:some-val :a] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:i :am] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:i :am :deep] #'commando-registry/default-command-value-spec)] - (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {:some-val {:a 2} - :some-other 3 - :my-value :is-here - :i {:am {:deep :nested}} - :v []} - :registry registry}))) - "Instruction return internal commands _map, _vec, _value.") - (is (= [(cm/->CommandMapPath [] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:set] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:list] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:primitive] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:java-obj] #'commando-registry/default-command-value-spec)] - (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {:set #{:commando/from [:target]} - :list (list {:commando/from [:target]}) - :primitive 42 - :java-obj #?(:clj (java.util.Date.) - :cljs (js/Date.))} - :registry registry}))) - "Any type that not Map,Vector(and registry not contain other commands) became a _value standart internal command") - (is (= [(cm/->CommandMapPath [] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:set] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:list] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:valid] #'commando-registry/default-command-vec-spec) - (cm/->CommandMapPath [:target] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:valid 0] cmds-builtin/command-from-spec)] - (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {:set #{:not-found} - :list (list :not-found) - :valid [{:commando/from [:target]}] - :target 42} - :registry registry}))) - "commando/from find and returned with corresponding command-map-path object") - (is (= - [(cm/->CommandMapPath [] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:a] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:target] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:a "some"] #'commando-registry/default-command-map-spec) - (cm/->CommandMapPath [:a "some" :c] #'commando-registry/default-command-vec-spec) - (cm/->CommandMapPath [:a "some" :c 0] #'commando-registry/default-command-value-spec) - (cm/->CommandMapPath [:a "some" :c 1] cmds-builtin/command-from-spec)] - (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {:a {"some" {:c [:some {:commando/from [:target]}]}} - :target 42} - :registry registry}))) - "Example of usage commando/from inside of deep map") - (is (= :failed - (:status (#'commando/find-commands {:status :failed}))) - "Failed status is preserved") - (is - (let [mixed-keys-result (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction {"string-key" {:commando/from [:a]} - :keyword-key {:commando/from [:a]} - 42 {:commando/from [:a]} - :a 1} - :registry registry}))] - (is (some #(= (cm/command-path %) ["string-key"]) mixed-keys-result) "Correctly handles string keys in paths") - (is (some #(= (cm/command-path %) [:keyword-key]) mixed-keys-result) "Correctly handles keyword keys in paths") - (is (some #(= (cm/command-path %) [42]) mixed-keys-result) "Correctly handles numeric keys in paths"))))) - -; Test data for build-deps-tree -(def cmd1 (cm/->CommandMapPath [:goal1] test-add-id-command)) -(def cmd2 (cm/->CommandMapPath [:goal2 :ref] cmds-builtin/command-from-spec)) - -(def parent-cmd (cm/->CommandMapPath [:parent] test-add-id-command)) -(def child-cmd (cm/->CommandMapPath [:parent :child] test-add-id-command)) +(def failing-commands + {:bad-cmd {:type :test/bad + :recognize-fn #(and (map? %) (contains? % :will-fail)) + :apply (fn [_ _ _] (throw (ex-info "Intentional failure" {}))) + :dependencies {:mode :all-inside}} + :timeout-cmd {:type :test/failing + :recognize-fn #(and (map? %) (contains? % :fail)) + :apply (fn [_ _ _] (throw (ex-info "Command failed" {}))) + :dependencies {:mode :none}}}) -(def target-cmd (cm/->CommandMapPath [:target] test-add-id-command)) -(def ref-cmd (cm/->CommandMapPath [:ref] cmds-builtin/command-from-spec)) +(def nil-handler-command + {:type :test/nil-handler + :recognize-fn #(and (map? %) (contains? % :handle-nil)) + :apply (fn [_instruction _command-path _command-map] nil) + :dependencies {:mode :none}}) -(def failed-ref-cmd (cm/->CommandMapPath [:ref] cmds-builtin/command-from-spec)) +;; -- Status maps for execute-commands! -- -; Test status maps -(def failed-status-map +(def fail-status-map {:status :failed - :instruction {} - :registry registry - :internal/cm-list []}) -(def empty-ok-status-map - {:status :ok - :instruction {:a 1} - :registry registry - :internal/cm-list []}) - -(def deps-test-status-map - {:status :ok - :instruction {:goal1 {:test/add-id :fn} - :goal2 {:ref {:commando/from [:goal1 :test-id]}}} - :registry registry - :internal/cm-list [cmd1 cmd2]}) - -(def all-inside-status-map - {:status :ok - :instruction {:parent {:test/add-id :fn - :child {:test/add-id :fn}}} - :registry registry - :internal/cm-list [parent-cmd child-cmd]}) - -(def point-deps-status-map - {:status :ok - :instruction {:target {:test/add-id :fn} - :ref {:commando/from [:target]}} - :registry registry - :internal/cm-list [target-cmd ref-cmd]}) - -(def error-status-map - {:status :ok - :instruction {:ref {:commando/from [:nonexistent]}} - :registry registry - :internal/cm-list [failed-ref-cmd]}) - -(def chain-cmd-a (cm/->CommandMapPath [:a] cmds-builtin/command-from-spec)) -(def chain-cmd-b (cm/->CommandMapPath [:b] cmds-builtin/command-from-spec)) -(def chain-cmd-c (cm/->CommandMapPath [:c] test-add-id-command)) - -(def diamond-cmd-a (cm/->CommandMapPath [:a] cmds-builtin/command-from-spec)) -(def diamond-cmd-b (cm/->CommandMapPath [:b] cmds-builtin/command-from-spec)) -(def diamond-cmd-c (cm/->CommandMapPath [:c] cmds-builtin/command-from-spec)) -(def diamond-cmd-d (cm/->CommandMapPath [:d] test-add-id-command)) - -(def circular-cmd-a (cm/->CommandMapPath [:a] cmds-builtin/command-from-spec)) -(def circular-cmd-b (cm/->CommandMapPath [:b] cmds-builtin/command-from-spec)) - -(def hierarchy-parent (cm/->CommandMapPath [:root] test-add-id-command)) -(def hierarchy-child1 (cm/->CommandMapPath [:root :child1] cmds-builtin/command-from-spec)) -(def hierarchy-child2 (cm/->CommandMapPath [:root :child2] test-add-id-command)) - -(def deep-shallow (cm/->CommandMapPath [:deep :nested :cmd] cmds-builtin/command-from-spec)) -(def shallow-target (cm/->CommandMapPath [:target] test-add-id-command)) - -(def multi-ref-cmd (cm/->CommandMapPath [:multi] cmds-builtin/command-from-spec)) -(def ref-target1 (cm/->CommandMapPath [:target1] test-add-id-command)) -(def ref-target2 (cm/->CommandMapPath [:target2] test-add-id-command)) - -(def sibling1 (cm/->CommandMapPath [:container :sib1] cmds-builtin/command-from-spec)) -(def sibling2 (cm/->CommandMapPath [:container :sib2] test-add-id-command)) - -; Complex test status maps -(def chained-deps-map - {:status :ok - :instruction {:a {:commando/from [:b]} - :b {:commando/from [:c]} - :c {:test/add-id :fn}} - :registry registry - :internal/cm-list [chain-cmd-a chain-cmd-b chain-cmd-c]}) - -(def diamond-deps-map - {:status :ok - :instruction {:a {:commando/from [:b]} - :b {:commando/from [:d]} - :c {:commando/from [:d]} - :d {:test/add-id :fn}} - :registry registry - :internal/cm-list [diamond-cmd-a diamond-cmd-b diamond-cmd-c diamond-cmd-d]}) - -(def circular-deps-map - {:status :ok - :instruction {:a {:commando/from [:b]} - :b {:commando/from [:a]}} - :registry registry - :internal/cm-list [circular-cmd-a circular-cmd-b]}) - -(def hierarchy-deps-map - {:status :ok - :instruction {:root {:test/add-id :fn - :child1 {:commando/from [:root :child2]} - :child2 {:test/add-id :fn}}} - :registry registry - :internal/cm-list [hierarchy-parent hierarchy-child1 hierarchy-child2]}) - -(def deep-cross-ref-map - {:status :ok - :instruction {:deep {:nested {:cmd {:commando/from [:target]}}} - :target {:test/add-id :fn}} - :registry registry - :internal/cm-list [deep-shallow shallow-target]}) - -(def multi-point-deps-map - {:status :ok - :instruction {:multi {:commando/from [:target1]} - :target1 {:test/add-id :fn} - :target2 {:test/add-id :fn}} - :registry registry - :internal/cm-list [multi-ref-cmd ref-target1 ref-target2]}) - -(def sibling-deps-map - {:status :ok - :instruction {:container {:sib1 {:commando/from [:container :sib2]} - :sib2 {:test/add-id :fn}}} + :instruction {"test" 1} :registry registry - :internal/cm-list [sibling1 sibling2]}) - -(defn cmd-by-path [path commands] (first (filter #(= (cm/command-path %) path) commands))) - -(deftest build-deps-tree - (testing "Status handling" - (is (commando/failed? (#'commando/build-deps-tree failed-status-map)) "Failed status is preserved") - (is (commando/failed? (#'commando/build-deps-tree error-status-map)) - "Returns failed status when point dependency fails") - (is (commando/failed? (#'commando/build-deps-tree - {:status :ok - :instruction {:bad {:commando/from [:nonexistent :path]}} - :registry registry - :internal/cm-list [(cm/->CommandMapPath [:bad] cmds-builtin/command-from-spec)]})) - "Returns failed status for non-existent path references") - (is (commando/ok? (#'commando/build-deps-tree empty-ok-status-map)) "Success status with empty command list") - (is (commando/failed? (#'commando/build-deps-tree deps-test-status-map)) - "Status failed cause :test-id unexist in dependency step")) - (testing "Base case" - (let [result (#'commando/build-deps-tree all-inside-status-map) - deps (:internal/cm-dependency result)] - (is (contains? result :internal/cm-dependency) "Contains dependency map") - (is (and (contains? deps parent-cmd) (contains? deps child-cmd)) "Contains all commands") - (is (contains? (get (:internal/cm-dependency (#'commando/build-deps-tree all-inside-status-map)) parent-cmd) - child-cmd) - "Parent command depends on child command")) - (is (contains? (get (:internal/cm-dependency (#'commando/build-deps-tree point-deps-status-map)) ref-cmd) - target-cmd) - "Ref command depends on target command") - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree chained-deps-map))] - (is (contains? (get deps chain-cmd-a) chain-cmd-b) "A depends on B") - (is (contains? (get deps chain-cmd-b) chain-cmd-c) "B depends on C") - (is (empty? (get deps chain-cmd-c)) "C has no dependencies")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree diamond-deps-map))] - (is (contains? (get deps diamond-cmd-b) diamond-cmd-d) "B depends on D") - (is (contains? (get deps diamond-cmd-c) diamond-cmd-d) "C depends on D") - (is (empty? (get deps diamond-cmd-d)) "D has no dependencies")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree hierarchy-deps-map))] - (is (contains? (get deps hierarchy-parent) hierarchy-child1) "Parent depends on child1 (all-inside)") - (is (contains? (get deps hierarchy-parent) hierarchy-child2) "Parent depends on child2 (all-inside)") - (is (contains? (get deps hierarchy-child1) hierarchy-child2) "Child1 depends on child2 (point ref)")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree deep-cross-ref-map))] - (is (contains? (get deps deep-shallow) shallow-target) "Deep nested command depends on shallow target")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree multi-point-deps-map))] - (is (contains? (get deps multi-ref-cmd) ref-target1) "Multi-ref command depends on target1")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree sibling-deps-map))] - (is (contains? (get deps sibling1) sibling2) "Same-level sibling dependencies, sibling1 depends on sibling2"))) - (testing "Complex multi-level dependency resolution" - (let [large-test-instruction - {:config {:database {:test/add-id :database} - :cache {:test/add-id :cache}} - :users {:fetch {:commando/from [:config :database]} - :validate {:commando/from [:users :fetch]} - :transform {:commando/from [:config :cache]}} - :products {:load {:test/add-id :products - :items {:fetch {:commando/from [:config :database]} - :enrich {:commando/from [:products :load :items :fetch]}}} - :cache {:commando/from [:products :load]}} - :orders {:create {:commando/from [:users :validate]} - :prepare {:commando/from [:products :cache]} - :finalize {:test/add-id :finalize - :needs-create {:commando/from [:orders :create]} - :needs-prepare {:commando/from [:orders :prepare]}} - :process {:test/add-id :orders - :steps {:validate {:commando/from [:orders :create]} - :payment {:commando/from [:orders :process :steps :validate]} - :fulfill {:commando/from [:orders :process :steps :payment]}}}} - :reports {:daily {:commando/from [:orders :process]} - :weekly [{:commando/from [:reports :daily]} {:commando/from [:users :transform]}]}} - large-test-commands (:internal/cm-list (#'commando/find-commands - {:status :ok - :instruction large-test-instruction - :registry registry})) - large-deps-status-map {:status :ok - :instruction large-test-instruction - :registry registry - :internal/cm-list large-test-commands} - result (#'commando/build-deps-tree large-deps-status-map) - deps (:internal/cm-dependency result) - config-db (cmd-by-path [:config :database] large-test-commands) - config-cache (cmd-by-path [:config :cache] large-test-commands) - users-fetch (cmd-by-path [:users :fetch] large-test-commands) - users-validate (cmd-by-path [:users :validate] large-test-commands) - users-transform (cmd-by-path [:users :transform] large-test-commands) - products-load (cmd-by-path [:products :load] large-test-commands) - products-load-items (cmd-by-path [:products :load :items] large-test-commands) - products-load-addid (cmd-by-path [:products :load :test/add-id] large-test-commands) - products-items-fetch (cmd-by-path [:products :load :items :fetch] large-test-commands) - products-items-enrich (cmd-by-path [:products :load :items :enrich] large-test-commands) - products-cache (cmd-by-path [:products :cache] large-test-commands) - orders-create (cmd-by-path [:orders :create] large-test-commands) - orders-prepare (cmd-by-path [:orders :prepare] large-test-commands) - orders-finalize (cmd-by-path [:orders :finalize] large-test-commands) - orders-needs-create (cmd-by-path [:orders :finalize :needs-create] large-test-commands) - orders-needs-prepare (cmd-by-path [:orders :finalize :needs-prepare] large-test-commands) - orders-process (cmd-by-path [:orders :process] large-test-commands) - orders-process-steps (cmd-by-path [:orders :process :steps] large-test-commands) - orders-process-addid (cmd-by-path [:orders :process :test/add-id] large-test-commands) - orders-steps-validate (cmd-by-path [:orders :process :steps :validate] large-test-commands) - orders-steps-payment (cmd-by-path [:orders :process :steps :payment] large-test-commands) - orders-steps-fulfill (cmd-by-path [:orders :process :steps :fulfill] large-test-commands) - reports-daily (cmd-by-path [:reports :daily] large-test-commands) - reports-weekly (cmd-by-path [:reports :weekly 0] large-test-commands) - reports-weekly2 (cmd-by-path [:reports :weekly 1] large-test-commands)] - (is (commando/ok? result) "Successfully processes large dependency tree") - (is (= 35 (count large-test-commands)) "Sanity input check: All 21 commands are present") - (is (= 35 (count deps)) "Dependency map contains all 21 commands") - (is (not-empty (get deps config-db)) "config.database has dependencies to itself") - (is (not-empty (get deps config-cache)) "config.cache has dependencies to itself") - (is (contains? (get deps users-fetch) config-db) "users.fetch depends on config.database") - (is (contains? (get deps products-items-fetch) config-db) "products.load.items.fetch depends on config.database") - (is (contains? (get deps users-validate) users-fetch) "users.validate depends on users.fetch") - (is (contains? (get deps users-transform) config-cache) "users.transform depends on config.cache") - (is (contains? (get deps products-items-enrich) products-items-fetch) - "products.load.items.enrich depends on products.load.items.fetch") - (is (contains? (get deps products-cache) products-load) - "products.cache pointing at products.load has as a dependency it pointed") - (is - (= (get deps products-load) #{products-load-items products-load-addid}) - "products.load depends on all-inside children (items map and test/add-id value), not on external deps") - (is (contains? (get deps orders-create) users-validate) "orders.create depends on users.validate") - (is (contains? (get deps orders-prepare) products-cache) "orders.prepare depends on products.cache") - (is (contains? (get deps orders-finalize) orders-needs-create) - "orders.finalize depends on needs-create (all-inside) - multi-reference dependencies via all-inside pattern") - (is (contains? (get deps orders-finalize) orders-needs-prepare) - "orders.finalize depends on needs-prepare (all-inside) - multi-reference dependencies via all-inside pattern") - (is (contains? (get deps orders-needs-create) orders-create) - "orders.finalize.needs-create depends on orders.create") - (is (contains? (get deps orders-needs-prepare) orders-prepare) - "orders.finalize.needs-prepare depends on orders.prepare") - (is (= (get deps orders-process) #{orders-process-steps orders-process-addid}) - "orders.process depends on all-inside children (steps map and test/add-id value)") - (is (contains? (get deps orders-steps-validate) orders-create) - "orders.process.steps.validate depends on orders.create") - (is (contains? (get deps orders-steps-payment) orders-steps-validate) - "orders.process.steps.payment depends on orders.process.steps.validate") - (is (contains? (get deps orders-steps-fulfill) orders-steps-payment) - "orders.process.steps.fulfill depends on orders.process.steps.payment") - (is (contains? (get deps reports-daily) orders-process) "reports.daily depends on orders.process") - (is (contains? (get deps reports-weekly) reports-daily) "reports.weekly depends on reports.daily") - (is (contains? (get deps reports-weekly2) users-transform) - "reports.weekly depends on users.transform (multi-path dependency)"))) - (testing "Circular dependencies" - ;; TODO this will work here but to think about how it should be in - ;; global exec test (especially after sort) - (let [result (#'commando/build-deps-tree circular-deps-map)] - (is (or (commando/ok? result) (commando/failed? result)) - "Handles circular dependencies (either succeeds or fails gracefully)"))) - (testing "Empty command list" - (let [empty-map {:status :ok - :instruction {} - :registry registry - :internal/cm-list []} - result (#'commando/build-deps-tree empty-map)] - (is (commando/ok? result) "Handles empty command list successfully") - (is (empty? (:internal/cm-dependency result)) "Dependency map is empty")))) - -(deftest dependency-modes-test - (testing ":all-inside mode" - (let [goal2-cmd (cm/->CommandMapPath [:goal-2] test-add-id-command) - goal2-someval-cmd (cm/->CommandMapPath [:goal-2 :some-val] test-add-id-command) - test-status-map {:status :ok - :instruction {:goal-2 {:test/add-id :fn - :some-val {:test/add-id :nested}}} - :registry registry - :internal/cm-list [goal2-cmd goal2-someval-cmd]} - result (#'commando/build-deps-tree test-status-map) - deps (:internal/cm-dependency result)] - (is (commando/ok? result) "Successfully processes :all-inside dependency") - (is (contains? (get deps goal2-cmd) goal2-someval-cmd) - "goal-2 command depends on goal-2.some-val command (nested inside)"))) - (testing ":point mode" - (let [goal1-cmd (cm/->CommandMapPath [:goal-1] test-add-id-command) - ref-cmd (cm/->CommandMapPath [:ref] cmds-builtin/command-from-spec) - test-status-map {:status :ok - :instruction {:goal-1 {:test/add-id :fn} - :ref {:commando/from [:goal-1]}} - :registry registry - :internal/cm-list [goal1-cmd ref-cmd]} - result (#'commando/build-deps-tree test-status-map) - deps (:internal/cm-dependency result)] - (is (commando/ok? result) "Successfully processes :point dependency") - (is (contains? (get deps ref-cmd) goal1-cmd) - "ref command depends on goal-1 command (parent path dependency because goal-1 will create :id key)"))) - (testing ":none mode - no dependencies" - (let [none-command {:type :test/none - :recognize-fn #(and (map? %) (contains? % :test/none)) - :apply identity - :dependencies {:mode :none}} - none-cmd (cm/->CommandMapPath [:standalone] none-command) - test-status-map {:status :ok - :instruction {:standalone {:test/none :independent}} - :registry (commando/registry-create [none-command]) - :internal/cm-list [none-cmd]} - result (#'commando/build-deps-tree test-status-map) - deps (:internal/cm-dependency result)] - (is (commando/ok? result) "Successfully processes :none dependency") - (is (empty? (get deps none-cmd)) "Command with :none mode has no dependencies")))) - -(deftest sort-entities-by-deps - (testing "Status handling" - (is (commando/failed? (#'commando/sort-commands-by-deps failed-status-map)) "Failed status is preserved") - (is (commando/ok? (#'commando/sort-commands-by-deps empty-ok-status-map)) - "Success status with empty dependency map")) - (testing "Simple dependency chain ordering" - (let [deps-map {:status :ok - :instruction {:a {:commando/from [:b]} - :b {:commando/from [:c]} - :c {:test/add-id :fn}} - :registry registry - :internal/cm-dependency {chain-cmd-a #{chain-cmd-b} - chain-cmd-b #{chain-cmd-c} - chain-cmd-c #{}}} - result (#'commando/sort-commands-by-deps deps-map) - order (:internal/cm-running-order result)] - (is (commando/ok? result) "Successfully sorts linear dependency chain") - (is (= 3 (count order)) "Returns all commands in order") - (is (< (.indexOf order chain-cmd-c) (.indexOf order chain-cmd-b) (.indexOf order chain-cmd-a)) - "Commands ordered correctly: c before b before a"))) - (testing "Diamond dependency pattern" - (let [deps-map {:status :ok - :instruction {:a {:commando/from [:b :c]} - :b {:commando/from [:d]} - :c {:commando/from [:d]} - :d {:test/add-id :fn}} - :registry registry - :internal/cm-dependency {diamond-cmd-a #{diamond-cmd-b diamond-cmd-c} - diamond-cmd-b #{diamond-cmd-d} - diamond-cmd-c #{diamond-cmd-d} - diamond-cmd-d #{}}} - result (#'commando/sort-commands-by-deps deps-map) - order (:internal/cm-running-order result)] - (is (commando/ok? result) "Successfully sorts diamond dependency") - (is (= 4 (count order)) "Returns all commands in order") - (is (< (.indexOf order diamond-cmd-d) (.indexOf order diamond-cmd-b)) "D executes before B") - (is (< (.indexOf order diamond-cmd-d) (.indexOf order diamond-cmd-c)) "D executes before C") - (is (< (.indexOf order diamond-cmd-b) (.indexOf order diamond-cmd-a)) "B executes before A") - (is (< (.indexOf order diamond-cmd-c) (.indexOf order diamond-cmd-a)) "C executes before A"))) - (testing "Circular dependency detection" - (let [deps-map {:status :ok - :instruction {:a {:commando/from [:b]} - :b {:commando/from [:a]}} - :registry registry - :internal/cm-dependency {circular-cmd-a #{circular-cmd-b} - circular-cmd-b #{circular-cmd-a}}} - result (#'commando/sort-commands-by-deps deps-map)] - (is (commando/failed? result) "Detects circular dependency and returns failed status") - (is (some #(re-find #"cyclic dependency" %) (map :message (:errors result))) - "Error message mentions cyclic dependency")))) - -(def instruction - {:config {:database {} - :cache {}} - :z-users {:fetch {:commando/from [:config :database]} - :validate {:commando/from [:z-users :fetch]}} - :a-products {:commando/from [:z-users]} - :m-orders {:create {:commando/from [:z-users :validate]} - :prepare {:commando/from [:a-products :fetch]} - :finalize {:needs-create {:commando/from [:m-orders :create]} - :needs-prepare {:commando/from [:m-orders :prepare]}}} - :z-reports {:daily {:commando/from [:m-orders]} - :weekly {:commando/from [:m-orders :finalize :needs-create] - :=> [:get :create]}} - :a-analytics {:summary {:commando/from [:z-reports :weekly]} - :export {:commando/from [:a-analytics :summary]}}}) - -(def mutation-timestamp-execution-map - {:status :ok - :instruction {"timestamp" {:commando/mutation :time/current-dd-mm-yyyy-hh-mm-ss}} - :registry (commando/registry-create [cmds-builtin/command-mutation-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["timestamp"] cmds-builtin/command-mutation-spec)]}) - -(def from-transformation-execution-map - {:status :ok - :instruction {"source" {:data 42 - :extra "info"} - "transformed" {:commando/from ["source"] - :=> [:get :data]}} - :registry (commando/registry-create [cmds-builtin/command-from-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["transformed"] cmds-builtin/command-from-spec)]}) - -(def apply-transformation-execution-map - {:status :ok - :instruction {"processed" {:commando/apply [1 2 3 4 5] - :=> [:fn #(apply + %)]}} - :registry (commando/registry-create [cmds-builtin/command-apply-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["processed"] cmds-builtin/command-apply-spec)]}) - -;; ================================ -;; SHARED TEST DATA DEFINITIONS -;; ================================ + :warnings ["Previous failure"]}) (def empty-execution-map {:status :ok @@ -548,87 +55,45 @@ :registry registry :internal/cm-running-order []}) -(def basic-success-map +(def basic-command-execution-map {:status :ok - :registry (commando/registry-create [test-add-id-command]) - :internal/cm-running-order []}) + :instruction {"val" 10 + "cmd" {:test/add-id "data"}} + :registry (commando/registry-create registry) + :internal/cm-running-order [(cm/command-map-path ["cmd"] test-add-id-command)]}) (def from-command {:status :ok :instruction {"source" 42 "ref" {:commando/from ["source"]}} :registry (commando/registry-create [cmds-builtin/command-from-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["ref"] cmds-builtin/command-from-spec)]}) + :internal/cm-running-order [(cm/command-map-path ["ref"] cmds-builtin/command-from-spec)]}) (def fn-command {:status :ok :instruction {"calc" {:commando/fn + :args [1 2 3]}} :registry (commando/registry-create [cmds-builtin/command-fn-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["calc"] cmds-builtin/command-fn-spec)]}) + :internal/cm-running-order [(cm/command-map-path ["calc"] cmds-builtin/command-fn-spec)]}) (def apply-command {:status :ok :instruction {"transform" {:commando/apply {"data" 10} :=> [:fn #(get % "data")]}} :registry (commando/registry-create [cmds-builtin/command-apply-spec]) - :internal/cm-running-order [(cm/->CommandMapPath ["transform"] cmds-builtin/command-apply-spec)]}) - -(def add-id-command-execution - {:status :ok - :instruction {"cmd" {:test/add-id "some-value"}} - :registry (commando/registry-create [test-add-id-command]) - :internal/cm-running-order [(cm/->CommandMapPath ["cmd"] test-add-id-command)]}) - -(def dependency-scenarios - {:all-inside {:instruction {"parent" {:test/add-id "parent-val" - "child" {:test/add-id "child-val"}}} - :commands [(cm/->CommandMapPath ["parent" "child"] test-add-id-command) - (cm/->CommandMapPath ["parent"] test-add-id-command)]} - :point {:instruction {"target" {:test/add-id "target-val"} - "ref" {:commando/from ["target" :id]}} - :commands [(cm/->CommandMapPath ["target"] test-add-id-command) - (cm/->CommandMapPath ["ref"] cmds-builtin/command-from-spec)]} - :none {:instruction {"independent" {:test/none "value"}} - :none-cmd {:type :test/none - :recognize-fn #(and (map? %) (contains? % :test/none)) - :apply (fn [_instruction _command-path _command-map] "independent-result") - :dependencies {:mode :none}}}}) - -(def failing-commands - {:bad-cmd {:type :test/bad - :recognize-fn #(and (map? %) (contains? % :will-fail)) - :apply (fn [_ _ _] (throw (ex-info "Intentional failure" {}))) - :dependencies {:mode :all-inside}} - :timeout-cmd {:type :test/failing - :recognize-fn #(and (map? %) (contains? % :fail)) - :apply (fn [_ _ _] (throw (ex-info "Command failed" {}))) - :dependencies {:mode :none}}}) + :internal/cm-running-order [(cm/command-map-path ["transform"] cmds-builtin/command-apply-spec)]}) -(def fail-status-map - {:status :failed - :instruction {"test" 1} - :registry registry - :warnings ["Previous failure"]}) - -(def basic-command-execution-map - {:status :ok - :instruction {"val" 10 - "cmd" {:test/add-id "data"}} - :registry (commando/registry-create registry) - :internal/cm-running-order [(cm/->CommandMapPath ["cmd"] test-add-id-command)]}) - -(def timeout-command-execution-map +(def nil-handler-execution-map {:status :ok - :instruction {"cmd" {:fail true}} - :registry (commando/registry-create [(:timeout-cmd failing-commands)]) - :internal/cm-running-order [(cm/->CommandMapPath ["cmd"] (:timeout-cmd failing-commands))]}) + :instruction {"nil-handler" {:handle-nil nil}} + :registry (commando/registry-create [nil-handler-command]) + :internal/cm-running-order [(cm/command-map-path ["nil-handler"] nil-handler-command)]}) (def bad-command-execution-map {:status :ok :instruction {"bad" {:will-fail true}} :registry (commando/registry-create [(:bad-cmd failing-commands)]) - :internal/cm-running-order [(cm/->CommandMapPath ["bad"] (:bad-cmd failing-commands))]}) + :internal/cm-running-order [(cm/command-map-path ["bad"] (:bad-cmd failing-commands))]}) (def midway-fail-execution-map {:status :ok @@ -637,249 +102,24 @@ "never" {:test/add-id "should-not-execute"}} :registry (commando/registry-create [test-add-id-command (:bad-cmd failing-commands)]) - :internal/cm-running-order [(cm/->CommandMapPath ["good"] test-add-id-command) - (cm/->CommandMapPath ["bad"] (:bad-cmd failing-commands)) - (cm/->CommandMapPath ["never"] test-add-id-command)]}) - -(def nil-handler-command - {:type :test/nil-handler - :recognize-fn #(and (map? %) (contains? % :handle-nil)) - :apply (fn [_instruction _command-path _command-map] nil) - :dependencies {:mode :none}}) - -(def nil-handler-execution-map - {:status :ok - :instruction {"nil-handler" {:handle-nil nil}} - :registry (commando/registry-create [nil-handler-command]) - :internal/cm-running-order [(cm/->CommandMapPath ["nil-handler"] nil-handler-command)]}) + :internal/cm-running-order [(cm/command-map-path ["good"] test-add-id-command) + (cm/command-map-path ["bad"] (:bad-cmd failing-commands)) + (cm/command-map-path ["never"] test-add-id-command)]}) (def deep-nested-execution-map {:status :ok :instruction {"level1" {"level2" {"level3" {"deep" {:test/add-id "deep-value"}}}}} :registry (commando/registry-create [test-add-id-command]) - :internal/cm-running-order [(cm/->CommandMapPath ["level1" "level2" "level3" "deep"] test-add-id-command)]}) + :internal/cm-running-order [(cm/command-map-path ["level1" "level2" "level3" "deep"] test-add-id-command)]}) (def large-commands-execution-map - (let [commands (mapv #(cm/->CommandMapPath [%] test-add-id-command) (range 20)) + (let [commands (mapv #(cm/command-map-path [%] test-add-id-command) (range 20)) instruction (into {} (map #(vector % {:test/add-id (str "value-" %)}) (range 20)))] {:status :ok :instruction instruction :registry (commando/registry-create [test-add-id-command]) :internal/cm-running-order commands})) -(def full-registry-all - [cmds-builtin/command-from-spec - cmds-builtin/command-fn-spec - cmds-builtin/command-apply-spec - cmds-builtin/command-mutation-spec - cmds-builtin/command-macro-spec - test-add-id-command]) - -(def from-instruction - {"a" 10 - "b" {:commando/from ["a"]}}) - -(def fn-instruction - {"calc" {:commando/fn + - :args [1 2 3]}}) - -(def apply-instruction - {"transform" {:commando/apply [1 2 3] - :=> [:fn count]}}) - -(def mixed-instruction - {"source" 100 - "doubled" {:commando/fn * - :args [{:commando/from ["source"]} 2]} - "processed" {:commando/apply {:commando/from ["doubled"]} - :=> [:fn str]} - "metadata" {:test/add-id "info"}}) - -(def transform-instruction - {"data" {:nested {:value 42}} - "extracted" {:commando/from ["data"] - :=> [:get-in [:nested :value]]}}) - -;; Complex dependency scenario instructions -(def linear-chain-instruction - {"step1" {:test/add-id "first"} - "step2" {:commando/from ["step1"]} - "step3" {:commando/from ["step2"]} - "step4" {:commando/from ["step3"]}}) - -(def fan-out-instruction - {"base" {:test/add-id "shared"} - "branch1" {:commando/from ["base"]} - "branch2" {:commando/from ["base"]} - "branch3" {:commando/from ["base"]}}) - -(def diamond-instruction - {"root" {:test/add-id "root"} - "left" {:commando/from ["root"]} - "right" {:commando/from ["root"]} - "merge" {:commando/from ["left"]}}) - -(def hierarchical-instruction - {"parent" {:test/add-id "parent" - "child1" {:test/add-id "child1"} - "child2" {:commando/from ["parent" "child1"]}}}) - -;; Error scenarios data and registries -(def error-registry [cmds-builtin/command-from-spec - test-add-id-command - (:timeout-cmd failing-commands)]) - -(def invalid-cmd - {:type :test/invalid - :recognize-fn #(and (map? %) (contains? % :invalid)) - :validate-params-fn (fn [_] false) - :apply identity - :dependencies {:mode :none}}) - -(def throwing-cmd - {:type :test/throwing - :recognize-fn (fn [_] (throw (ex-info "Recognition failed" {}))) - :apply identity - :dependencies {:mode :none}}) - -(def failing-case-instruction - {"good" {:test/add-id "works"} - "bad" {:fail true}}) - -(def invalid-ref-instruction {"cmd" {:commando/from ["nonexistent"]}}) - -(def circular-instruction - {"a" {:commando/from ["b"]} - "b" {:commando/from ["a"]}}) - -(def empty-registry-instruction {"cmd" {:test/add-id "value"}}) - -(def invalid-validation-instruction {"cmd" {:invalid true}}) - -(def throwing-recognition-instruction {"cmd" {:any "value"}}) - -;; Performance/scalability -(def large-independent-instruction - (into {} - (for [i (range 200)] - [i {:test/add-id i}]))) - -(def deep-dependency-instruction - (let [depth 50] - (reduce (fn [inst i] (if (= i 0) (assoc inst i {:test/add-id i}) (assoc inst i {:commando/from [(dec i)]}))) - {} - (range depth)))) - -(def wide-fan-out-instruction - (let [base {"base" {:test/add-id "shared"}}] - (into base - (for [i (range 100)] - [i {:commando/from ["base"]}])))) - -(def custom-op-cmd - {:type :OP - :recognize-fn #(and (map? %) (contains? % :OP)) - :validate-params-fn (fn [m] (malli/validate [:map [:OP [:enum :SUMM :MULTIPLY]] [:ARGS [:+ :any]]] m)) - :apply (fn [_instruction _command-path-obj m] - (case (:OP m) - :SUMM (apply + (:ARGS m)) - :MULTIPLY (apply * (:ARGS m)))) - :dependencies {:mode :all-inside}}) - -(def custom-arg-cmd - {:type :ARG - :recognize-fn #(and (map? %) (contains? % :ARG)) - :validate-params-fn (fn [m] (malli/validate [:map [:ARG [:+ :any]]] m)) - :apply (fn [instruction _command-path-obj m] (get-in instruction (:ARG m))) - :dependencies {:mode :point - :point-key [:ARG]}}) - -(def custom-registry [custom-op-cmd custom-arg-cmd]) - -;; Helper-integration instructions -(def value-ref-instruction - {"value" 42 - "ref" {:commando/from ["value"]}}) - -;; Structure test instructions -(def structure-map-instruction - {"map" {:a 1 - :b 2 - :c 3} - "=" {:commando/from ["map" :a]}}) -(def structure-vector-instruction - {"vector" [1 2 3] - "=" {:commando/from ["vector" 0]}}) -(def structure-set-instruction - {"set" #{1 2 3} - "=" {:commando/from ["set" 0]}}) -(def structure-list-instruction - {"list" (list 1 2 3) - "=" {:commando/from ["list" 0]}}) - -;; Instruction-command test instructions -(def sum-collection-instruction - {"0" {:commando/from ["=SUM"] - :=> [:fn (fn [e] (apply + e))]} - "1" 1 - "2" {:container {:commando/from ["1"] - :=> [:fn inc]}} - "3" {:container {:commando/from ["2"] - :=> [:get :container]}} - "=SUM" [{:commando/from ["1"]} - {:commando/from ["2"] - :=> [:get :container]} - {:commando/from ["3"] - :=> [:get :container]}]}) - -(def unexisting-path-instruction - {"1" 1 - "2" {:container {:commando/from ["UNEXISTING_PATH"]}}}) - -;; Custom instruction sets -(def custom-instruction-flat - {"A" 5 - "B" 10 - "result-multiply-1" {:OP :MULTIPLY - :ARGS [{:ARG ["A"]} 4]} - "result-multiply-2" {:OP :MULTIPLY - :ARGS [{:ARG ["B"]} 2]} - "result" {:OP :SUMM - :ARGS [{:ARG ["result-multiply-1"]} {:ARG ["result-multiply-2"]} 1]}}) - -(def custom-instruction-nested - {"A" 5 - "B" 10 - "result" {:OP :SUMM - :ARGS [{:OP :MULTIPLY - :ARGS [{:ARG ["A"]} 4]} - {:OP :MULTIPLY - :ARGS [{:ARG ["B"]} 2]} - 1]}}) - -;; Test data for execute-function-comprehensive-test -(def registry-from-spec [cmds-builtin/command-from-spec]) -(def test-instruction - {"source" 42 - "ref" {:commando/from ["source"]}}) - -(def basic-from-registry [cmds-builtin/command-from-spec - test-add-id-command]) -(def nested-instruction {"level1" {"level2" {"cmd" {:test/add-id "deep"}}}}) -(def vector-instruction {"items" [{:test/add-id "first"} {:test/add-id "second"}]}) -(def mixed-keys-instruction - {"string-key" {:test/add-id "str"} - :keyword-key {:test/add-id "kw"} - 42 {:test/add-id "num"}}) -(def large-instruction - (into {} - (for [i (range 100)] - [i {:test/add-id i}]))) -(def deep-nested-instruction {0 {1 {2 {3 {4 {5 {6 {7 {8 {9 {"cmd" {:test/add-id "deep"}}}}}}}}}}}}) - - -(def add-id-test-instruction {"cmd" {:test/add-id "test"}}) - (deftest execute-commands!-test (testing "Status handling" (is (commando/failed? (#'commando/execute-commands! fail-status-map)) "Failed status is preserved") @@ -924,247 +164,84 @@ (is (= nil (get-in (#'commando/execute-commands! nil-handler-execution-map) [:instruction "nil-handler"])) "Nil values handled correctly"))) -(def relative-path-instruction - {"1" 1 - "2" {"container" {:commando/from ["../" "../" "1"]}} - "3" {"container" {:commando/from ["../" "../" "2"]}}}) +;; -- Integration: execute pipeline -- -(def base-instruction-compiler - {"0" {:commando/from ["=SUM"] - :=> [:fn (fn [e] (apply + e))]} - "1" 1 - "2" {"container" {:commando/from ["1"]}} - "3" {"container" {:commando/from ["2"] - :=> [:get "container"]}} - "=SUM" [{:commando/from ["1"]} - {:commando/from ["2"] - :=> [:get "container"]} - {:commando/from ["3"] - :=> [:get "container"]}]}) +(def custom-op-cmd + {:type :OP + :recognize-fn #(and (map? %) (contains? % :OP)) + :validate-params-fn (fn [m] (malli/validate [:map [:OP [:enum :SUMM :MULTIPLY]] [:ARGS [:+ :any]]] m)) + :apply (fn [_instruction _command-path-obj m] + (case (:OP m) + :SUMM (apply + (:ARGS m)) + :MULTIPLY (apply * (:ARGS m)))) + :dependencies {:mode :all-inside}}) -(def toplevel-vector-instruction - [{:value 10} - {:commando/from [0 :value] - :=> [:fn inc]} - {:commando/from [1] - :=> [:fn (partial * 2)]}]) +(def custom-arg-cmd + {:type :ARG + :recognize-fn #(and (map? %) (contains? % :ARG)) + :validate-params-fn (fn [m] (malli/validate [:map [:ARG [:+ :any]]] m)) + :apply (fn [instruction _command-path-obj m] (get-in instruction (:ARG m))) + :dependencies {:mode :point + :point-key [:ARG]}}) (deftest execute-test (testing "Status" - (is (commando/ok? (commando/execute registry-from-spec test-instruction)) "Status :ok when successful") - (is (= :ok - (:status (commando/execute basic-from-registry - {"data" 123 - "info" "text"}))) + (is (commando/ok? (commando/execute [cmds-builtin/command-from-spec] + {"source" 42 "ref" {:commando/from ["source"]}})) + "Status :ok when successful") + (is (commando/ok? (commando/execute [cmds-builtin/command-from-spec test-add-id-command] + {"data" 123 "info" "text"})) "Instruction with no commands succeeds") - (is (commando/ok? (commando/execute basic-from-registry mixed-keys-instruction)) "Mixed data types as keys succeed") - (is (commando/failed? (commando/execute [] empty-registry-instruction))) - (is (commando/failed? (commando/execute error-registry failing-case-instruction))) - (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] invalid-ref-instruction))) - (is (not-empty (:errors (commando/execute [cmds-builtin/command-from-spec] invalid-ref-instruction)))) - (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] circular-instruction)) - "Circular dependencies") - (is (commando/failed? (commando/execute [invalid-cmd] invalid-validation-instruction)) "Invalid command validation") - (is (commando/failed? (commando/execute [throwing-cmd] throwing-recognition-instruction)) - "Command recognition exception") - (let [result (binding [commando-utils/*execute-config* {:error-data-string false}] - (commando/execute [cmds-builtin/command-from-spec] unexisting-path-instruction))] - (is (commando/failed? result))) - (is (commando/ok? (commando/execute [cmds-builtin/command-apply-spec] {"plain" {:commando/apply [1 2 3]}})) - "Missing :=> — identity pass-through via default :get-in driver") - (is (= [1 2 3] - (get-in (commando/execute [cmds-builtin/command-apply-spec] {"plain" {:commando/apply [1 2 3]}}) [:instruction "plain"])) - "Without :=>, :commando/apply returns its value as-is")) - (testing "Basic cases" - (is (= 42 (get-in (commando/execute registry-from-spec test-instruction) [:instruction "ref"])) - "Command executed correctly") - (is (= 42 (get-in (commando/execute registry-from-spec test-instruction) [:instruction "source"])) - "Static value preserved") - (is (commando/ok? (commando/execute basic-from-registry {})) "Empty instruction succeeds") - (is (= {} (:instruction (commando/execute basic-from-registry {}))) - "Empty instruction preserves input instruction map") - (is (= {"data" 123 - "info" "text"} - (->> {"data" 123 - "info" "text"} - (commando/execute basic-from-registry) - :instruction)) - "Instruction with no commands preserves data") - (is (contains? (get-in (commando/execute basic-from-registry nested-instruction) - [:instruction "level1" "level2" "cmd"]) - :id) - "Nested command executes") - (is (contains? (get-in (commando/execute basic-from-registry vector-instruction) [:instruction "items" 0]) :id) - "First vector item has id") - (is (contains? (get-in (commando/execute basic-from-registry vector-instruction) [:instruction "items" 1]) :id) - "Second vector item has id") - (is (contains? (get-in (commando/execute basic-from-registry mixed-keys-instruction) [:instruction "string-key"]) - :id) - "String key command executes") - (is (contains? (get-in (commando/execute basic-from-registry mixed-keys-instruction) [:instruction :keyword-key]) - :id) - "Keyword key command executes") - (is (contains? (get-in (commando/execute basic-from-registry mixed-keys-instruction) [:instruction 42]) :id) - "Number key command executes") - (is (every? #(contains? (get-in (commando/execute basic-from-registry large-instruction) [:instruction %]) :id) - (range 100)) - "All large instruction commands execute") - (is (contains? (get-in (commando/execute basic-from-registry deep-nested-instruction) - [:instruction 0 1 2 3 4 5 6 7 8 9 "cmd"]) - :id) - "Deep nested command executes") - (is (= {"cmd" {:test/add-id "value"}} (:instruction (commando/execute [] empty-registry-instruction))) - "empty registry preserves instruction") - (is (= 200 - (count (filter #(contains? % :id) - (vals (:instruction (commando/execute basic-from-registry large-independent-instruction))))))) - (is (every? #(contains? (get-in (commando/execute basic-from-registry deep-dependency-instruction) [:instruction %]) - :id) - (range 50))) - (is (contains? (get-in (commando/execute basic-from-registry wide-fan-out-instruction) [:instruction "base"]) :id)) - (is (every? #(contains? (get-in (commando/execute basic-from-registry wide-fan-out-instruction) [:instruction %]) - :id) - (range 100))) - (is (= 5 - (get-in (commando/execute [cmds-builtin/command-from-spec] sum-collection-instruction) [:instruction "0"]))) - (is (= {"A" 5 - "B" 10 - "result-multiply-1" 20 - "result-multiply-2" 20 - "result" 41} - (:instruction (commando/execute custom-registry custom-instruction-flat)))) - (is (= {"A" 5 - "B" 10 - "result" 41} - (:instruction (commando/execute custom-registry custom-instruction-nested)))) - (is (= {"0" {:final "5"}} - (->> {"0" {:commando/apply {"1" {:commando/apply {"2" {:commando/apply {"3" {:commando/apply {"4" {:final - "5"}} - :=> [:get "4"]}} - :=> [:get "3"]}} - :=> [:get "2"]}} - :=> [:get "1"]}} - (commando/execute [cmds-builtin/command-apply-spec]) - :instruction)) - "Commands inside commands are executed correctly") - (is (= "john" - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"source" {:user-name "john" - :age 25} - "name" {:commando/from ["source"] - :=> [:get :user-name]}})) - ["name"])) - "Value extracted with :=> [:get] in commando/from using keyword") - (is (= 25 - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"source" {"age" 25} - "age" {:commando/from ["source"] - :=> [:get "age"]}})) - ["age"])) - "Value extracted with :=> [:get] in commando/from using string") - (is (= 15 - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"numbers" [1 2 3 4 5] - "sum" {:commando/from ["numbers"] - :=> [:fn #(reduce + %)]}})) - ["sum"])) - "commando/from :=> [:fn] applying function works") - (is (= 1 - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"numbers" [1 2 3 4 5] - "first" {:commando/from ["numbers"] - :=> [:fn first]}})) - ["first"])) - "commando/from :=> [:fn] applying function works") - (is (nil? (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"source" {:a 1 - :b 2} - "missing" {:commando/from ["source"] - :=> [:get :nonexistent]}})) - ["missing"])) - "commando/from :=> [:get] nil returned when key is missing") - (is (nil? (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] - {"source" {:a 1 - :b 2} - "missing" {:commando/from ["source"] - :=> [:get "bóg"]}})) - ["missing"])) - "commando/from :=> [:get] nil returned when key is missing") - (is (= '(20 40 60) - (get-in (:instruction (commando/execute [cmds-builtin/command-apply-spec - cmds-builtin/command-from-spec] - {"base" [10 20 30] - "doubled" {:commando/apply {:commando/from ["base"]} - :=> [:fn #(map (partial * 2) %)]}})) - ["doubled"])) - "commando/apply works with :=> [:fn] driver") - (testing "Single command types" - (is (= 10 (get-in (commando/execute [cmds-builtin/command-from-spec] from-instruction) [:instruction "b"]))) - (is (= 6 (get-in (commando/execute [cmds-builtin/command-fn-spec] fn-instruction) [:instruction "calc"]))) - (is - (= 3 (get-in (commando/execute [cmds-builtin/command-apply-spec] apply-instruction) [:instruction "transform"]))) - (is (contains? (get-in (commando/execute [test-add-id-command] add-id-test-instruction) [:instruction "cmd"]) - :id))) - (testing "Mixed command types in single instruction" - (is (= 100 (get-in (commando/execute full-registry-all mixed-instruction) [:instruction "source"]))) - (is (= 200 (get-in (commando/execute full-registry-all mixed-instruction) [:instruction "doubled"]))) - (is (= "200" (get-in (commando/execute full-registry-all mixed-instruction) [:instruction "processed"]))) - (is (contains? (get-in (commando/execute full-registry-all mixed-instruction) [:instruction "metadata"]) :id))) - (testing "Linear dependency chain" - (is (contains? (get-in (commando/execute basic-from-registry linear-chain-instruction) [:instruction "step1"]) - :id)) - (is (contains? (get-in (commando/execute basic-from-registry linear-chain-instruction) [:instruction "step2"]) - :id)) - (is (contains? (get-in (commando/execute basic-from-registry linear-chain-instruction) [:instruction "step3"]) - :id)) - (is (contains? (get-in (commando/execute basic-from-registry linear-chain-instruction) [:instruction "step4"]) - :id))) - (testing "Fan-out dependencies" - (is (contains? (get-in (commando/execute basic-from-registry fan-out-instruction) [:instruction "base"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry fan-out-instruction) [:instruction "branch1"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry fan-out-instruction) [:instruction "branch2"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry fan-out-instruction) [:instruction "branch3"]) :id))) - (testing "Diamond dependencies" - (is (contains? (get-in (commando/execute basic-from-registry diamond-instruction) [:instruction "root"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry diamond-instruction) [:instruction "left"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry diamond-instruction) [:instruction "right"]) :id)) - (is (contains? (get-in (commando/execute basic-from-registry diamond-instruction) [:instruction "merge"]) :id))) - (testing "Hierarchical all-inside dependencies" - (is (contains? (get-in (commando/execute basic-from-registry hierarchical-instruction) [:instruction "parent"]) - :id)) - (is (contains? (get-in (commando/execute basic-from-registry hierarchical-instruction) - [:instruction "parent" "child1"]) - :id)) - (is (contains? (get-in (commando/execute basic-from-registry hierarchical-instruction) - [:instruction "parent" "child2"]) - :id))) - (testing ":point dependency lookup in set/list cause a failure" - (is (and (commando/ok? (commando/execute [cmds-builtin/command-from-spec] structure-map-instruction)) - (= 1 - (get-in (commando/execute [cmds-builtin/command-from-spec] structure-map-instruction) - [:instruction "="])))) - (is (and (commando/ok? (commando/execute [cmds-builtin/command-from-spec] structure-vector-instruction)) - (= 1 - (get-in (commando/execute [cmds-builtin/command-from-spec] structure-vector-instruction) - [:instruction "="])))) - (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] structure-set-instruction))) - (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] structure-list-instruction)))) - (testing "Navigation with relative path ../" - (is (= 1 - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] relative-path-instruction)) - ["2" "container"])) - "Parent path resolves to correct value") - (is (= {"container" 1} - (get-in (:instruction (commando/execute [cmds-builtin/command-from-spec] relative-path-instruction)) - ["3" "container"])) - "Nested parent path with transformation works")) - (testing "Top-level Vector Instruction" - (let [result (commando/execute [cmds-builtin/command-from-spec] toplevel-vector-instruction)] - (is (commando/ok? result) "This type of instruction is also acceptable") - (is (= [{:value 10} 11 22] (:instruction result)) - "Result of toplevel-vector instruction not match with example"))) - (testing "Execute with built registry" - (let [built-reg (commando/registry-create full-registry-all) - result1 (commando/execute built-reg base-instruction-compiler)] - (is (commando/ok? result1) "Built registry execution succeeds") - (is (= 3 (get-in (:instruction result1) ["0"])) "Calculation correct with built registry"))))) + (is (commando/ok? (commando/execute [cmds-builtin/command-from-spec test-add-id-command] {})) + "Empty instruction succeeds") + (is (commando/failed? (commando/execute [] {"cmd" {:test/add-id "value"}})) + "Empty registry fails") + (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] + {"a" {:commando/from ["b"]} "b" {:commando/from ["a"]}})) + "Circular dependencies detected") + (is (commando/failed? (commando/execute [cmds-builtin/command-from-spec] + {"cmd" {:commando/from ["nonexistent"]}})) + "Non-existent path reference fails")) + (testing "Custom commands" + (is (= {"A" 5 "B" 10 "result-multiply-1" 20 "result-multiply-2" 20 "result" 41} + (:instruction (commando/execute [custom-op-cmd custom-arg-cmd] + {"A" 5 + "B" 10 + "result-multiply-1" {:OP :MULTIPLY :ARGS [{:ARG ["A"]} 4]} + "result-multiply-2" {:OP :MULTIPLY :ARGS [{:ARG ["B"]} 2]} + "result" {:OP :SUMM :ARGS [{:ARG ["result-multiply-1"]} {:ARG ["result-multiply-2"]} 1]}}))) + "Flat custom instruction with OP/ARG commands") + (is (= {"A" 5 "B" 10 "result" 41} + (:instruction (commando/execute [custom-op-cmd custom-arg-cmd] + {"A" 5 + "B" 10 + "result" {:OP :SUMM + :ARGS [{:OP :MULTIPLY :ARGS [{:ARG ["A"]} 4]} + {:OP :MULTIPLY :ARGS [{:ARG ["B"]} 2]} + 1]}}))) + "Nested custom instruction")) + (testing "Top-level Vector Instruction" + (let [result (commando/execute [cmds-builtin/command-from-spec] + [{:value 10} + {:commando/from [0 :value] :=> [:fn inc]} + {:commando/from [1] :=> [:fn (partial * 2)]}])] + (is (commando/ok? result) "Vector instruction is acceptable") + (is (= [{:value 10} 11 22] (:instruction result)))))) + +;; ----------- +;; Internals always retained +;; ----------- + +(deftest internals-always-retained-test + (testing "Status-map always retains internal keys" + (let [result (commando/execute [cmds-builtin/command-from-spec] + {:a 1 :b {:commando/from [:a]}})] + (is (commando/ok? result)) + (is (some? (:internal/cm-list result)) "cm-list retained") + (is (some? (:internal/cm-dependency result)) "dependency graph retained") + (is (some? (:internal/path-trie result)) "path-trie retained") + (is (some? (:internal/cm-running-order result)) "running order retained") + (is (some? (:internal/cm-results result)) "cm-results retained") + (is (some? (:internal/original-instruction result)) "original instruction retained") + (is (some? (:registry result)) "registry retained")))) + diff --git a/test/unit/commando/debug_test.cljc b/test/unit/commando/debug_test.cljc new file mode 100644 index 0000000..a99daf5 --- /dev/null +++ b/test/unit/commando/debug_test.cljc @@ -0,0 +1,223 @@ +(ns commando.debug-test + "Visual samples for commando.debug — run to see printed output. + Not strict unit tests; primarily for eyeballing debug output." + (:require + #?(:cljs [cljs.test :refer [deftest is testing]] + :clj [clojure.test :refer [deftest is testing]]) + [commando.commands.builtin :as builtin] + [commando.core :as commando] + [commando.debug :as debug])) + +;; ============================================================ +;; Domain mutations (same as account_test) +;; ============================================================ + +(defmethod builtin/command-mutation :allocate + [_ {:keys [percent from]}] + (* from (/ percent 100.0))) + +(defmethod builtin/command-mutation :deduct + [_ {:keys [from amount]}] + (- from amount)) + +(defmethod builtin/command-mutation :half-of + [_ {:keys [from]}] + (/ from 2)) + +(defmethod builtin/command-mutation :rate + [_ {:keys [from factor]}] + (* from factor)) + +;; ============================================================ +;; Shared test data +;; ============================================================ + +(def ^:private distribution-instruction + {:money {:commando/context [:money]} + :VAL1 {:commando/context [:VAL1]} + :ACCOUNT-1 + {:commando/from [:money]} + :ACCOUNT-1-1 + {:commando/mutation :allocate + :percent 10 + :from {:commando/from [:ACCOUNT-1]}} + :ACCOUNT-1-1-1 + {:commando/mutation :allocate + :percent 50 + :from {:commando/from [:ACCOUNT-1-1]}} + :ACCOUNT-1-1-1-1 + {:commando/mutation :allocate + :percent 1 + :from {:commando/from [:ACCOUNT-1-1-1]}} + :ACCOUNT-1-1-2 + {:commando/mutation :allocate + :percent 50 + :from {:commando/from [:ACCOUNT-1-1]}} + :ACCOUNT-1-2 + {:commando/mutation :allocate + :percent 10 + :from {:commando/from [:ACCOUNT-1]}} + :ACCOUNT-1-3 + {:commando/mutation :allocate + :percent 80 + :from {:commando/from [:ACCOUNT-1]}} + :XVAL-half + {:commando/mutation :half-of + :from {:commando/from [:VAL1]}} + :XVAL + {:commando/mutation :rate + :from {:commando/from [:XVAL-half]} + :factor 0.25} + :ACCOUNT-1-3-1 + {:commando/mutation :deduct + :from {:commando/from [:ACCOUNT-1-3]} + :amount {:commando/from [:XVAL]}}}) + +(defn ^:private make-distribution-registry [input] + [(builtin/command-context-spec input) + builtin/command-from-spec + builtin/command-mutation-spec]) + +;; ============================================================ +;; execute-debug modes — accounting distribution +;; ============================================================ + +(deftest debug-tree-mode-test + (let [input {:money 1000 :VAL1 10} + registry (make-distribution-registry input)] + + (testing ":tree mode" + (println "\n--- :tree mode ---") + (let [r (debug/execute-debug registry distribution-instruction :tree)] + (is (commando/ok? r)) + (is (= 798.75 (get-in (:instruction r) [:ACCOUNT-1-3-1]))))) + + (testing ":table mode" + (println "\n--- :table mode ---") + (let [r (debug/execute-debug registry distribution-instruction :table)] + (is (commando/ok? r)))) + + (testing ":graph mode" + (println "\n--- :graph mode ---") + (let [r (debug/execute-debug registry distribution-instruction :graph)] + (is (commando/ok? r)))) + + (testing ":stats mode" + (println "\n--- :stats mode ---") + (let [r (debug/execute-debug registry distribution-instruction :stats)] + (is (commando/ok? r)))))) + +;; ============================================================ +;; execute-trace — nested macro/mutation +;; ============================================================ + +(defmethod builtin/command-mutation :rand-n + [_macro-type {:keys [v]}] + (:instruction + (commando/execute + [builtin/command-apply-spec] + {:commando/apply v + := (fn [n] (rand-int n))}))) + +(defmethod builtin/command-macro :sum-n + [_macro-type {:keys [v]}] + {:__title "Summarize Random" + :commando/fn (fn [& v-coll] (apply + v-coll)) + :args [v + {:commando/mutation :rand-n + :v 200}]}) + +(deftest trace-nested-macro-mutation-test + (testing "execute-trace with nested macro + mutation" + (println "\n--- execute-trace: nested macro/mutation ---") + (let [r (debug/execute-trace + [builtin/command-fn-spec + builtin/command-from-spec + builtin/command-macro-spec + builtin/command-mutation-spec] + {:value {:commando/mutation :rand-n :v 200} + :result {:commando/macro :sum-n + :v {:commando/from [:value]}}})] + (is (commando/ok? r))))) + +;; ============================================================ +;; execute-trace — vector dot product +;; ============================================================ + +(defmethod builtin/command-macro :v-str->v-int + [_macro-type {:keys [vector-str]}] + {:commando/fn (fn [str-vec] + (mapv #(#?(:clj Integer/parseInt :cljs js/parseInt) %) str-vec)) + :args [vector-str]}) + +(defmethod builtin/command-macro :vector-dot-product + [_macro-type {:keys [vector1-str vector2-str]}] + {:=> [:get :dot-product] + :commando/apply + {:vector1-str vector1-str + :vector2-str vector2-str + :vector1 + {:commando/macro :v-str->v-int + :vector-str {:commando/from ["../" "../" :vector1-str]}} + :vector2 + {:commando/macro :v-str->v-int + :vector-str {:commando/from ["../" "../" :vector2-str]}} + :dot-product + {:commando/fn (fn [& [v1 v2]] (reduce + (map * v1 v2))) + :args [{:commando/from ["../" "../" "../" :vector1]} + {:commando/from ["../" "../" "../" :vector2]}]}}}) + +(deftest trace-vector-dot-product-test + (testing "execute-trace with vector dot product macro" + (println "\n--- execute-trace: vector dot product ---") + (let [r (debug/execute-trace + [builtin/command-macro-spec + builtin/command-fn-spec + builtin/command-from-spec + builtin/command-apply-spec] + {:vector-dot-1 + {:commando/macro :vector-dot-product + :vector1-str ["1" "2" "3"] + :vector2-str ["4" "5" "6"]} + :vector-dot-2 + {:commando/macro :vector-dot-product + :vector1-str ["10" "20" "30"] + :vector2-str ["4" "5" "6"]}})] + (is (commando/ok? r)) + (is (= 32 (get-in (:instruction r) [:vector-dot-1]))) + (is (= 320 (get-in (:instruction r) [:vector-dot-2])))))) + +;; ============================================================ +;; execute-debug modes — with drivers +;; ============================================================ + +(deftest debug-with-drivers-test + (let [input {:user {:name "Alice" :age 30 :role "admin"}} + instruction {:user-data {:commando/context [:user]} + :user-name {:commando/from [:user-data] + :=> [:get :name]} + :user-role {:commando/from [:user-data] + :=> [:get :role]} + :greeting {:commando/fn (fn [name role] + (str "Hello " name " (" role ")")) + :args [{:commando/from [:user-name]} + {:commando/from [:user-role]}]}} + registry [(builtin/command-context-spec input) + builtin/command-from-spec + builtin/command-fn-spec]] + + (testing ":tree with drivers" + (println "\n--- :tree with drivers ---") + (let [r (debug/execute-debug registry instruction :tree)] + (is (commando/ok? r)) + (is (= "Hello Alice (admin)" (get-in (:instruction r) [:greeting]))))) + + (testing ":table with drivers" + (println "\n--- :table with drivers ---") + (let [r (debug/execute-debug registry instruction :table)] + (is (commando/ok? r)))) + + (testing "combined [:instr-before :table :instr-after]" + (println "\n--- combined mode ---") + (let [r (debug/execute-debug registry instruction [:instr-before :table :instr-after])] + (is (commando/ok? r)))))) diff --git a/test/unit/commando/impl/command_map_test.cljc b/test/unit/commando/impl/command_map_test.cljc index 1badcfd..2c7f534 100644 --- a/test/unit/commando/impl/command_map_test.cljc +++ b/test/unit/commando/impl/command_map_test.cljc @@ -7,59 +7,59 @@ (deftest command-map (testing "Path objects" - (let [path-obj (sut/->CommandMapPath ["product" 0] {:a 99})] + (let [path-obj (sut/command-map-path ["product" 0] {:a 99})] (is (= (sut/command-path path-obj) ["product" 0])) (is (= (sut/command-data path-obj) {:a 99})) - (is (= (sut/->CommandMapPath ["A"] {:a "ONE"}) (sut/->CommandMapPath ["A"] {:a "ONE"}))) - (is (not= (sut/->CommandMapPath ["A"] {:a "ONE"}) (sut/->CommandMapPath ["CHANGED"] {:a "ONE"}))) - (is (= (sut/->CommandMapPath ["A"] {:a "ONE"}) (sut/->CommandMapPath ["A"] {:a "CHANGED"}))) - (is (= (sut/->CommandMapPath ["A"] {:a "ONE"}) (sut/->CommandMapPath ["A"] {:a "CHANGED"})))) - (is (= (conj #{(sut/->CommandMapPath ["product" 0] {:a 99})} (sut/->CommandMapPath ["product" 0] {:a 99})) - #{(sut/->CommandMapPath ["product" 0] {:a "NOT SAME"})})) - (is (= (disj #{(sut/->CommandMapPath ["product" 0] {:a 99}) (sut/->CommandMapPath ["product" 1] {:a 99})} - (sut/->CommandMapPath ["product" 1] {:a "ANOTHER"})) - #{(sut/->CommandMapPath ["product" 0] {:a 99})})) + (is (= (sut/command-map-path ["A"] {:a "ONE"}) (sut/command-map-path ["A"] {:a "ONE"}))) + (is (not= (sut/command-map-path ["A"] {:a "ONE"}) (sut/command-map-path ["CHANGED"] {:a "ONE"}))) + (is (= (sut/command-map-path ["A"] {:a "ONE"}) (sut/command-map-path ["A"] {:a "CHANGED"}))) + (is (= (sut/command-map-path ["A"] {:a "ONE"}) (sut/command-map-path ["A"] {:a "CHANGED"})))) + (is (= (conj #{(sut/command-map-path ["product" 0] {:a 99})} (sut/command-map-path ["product" 0] {:a 99})) + #{(sut/command-map-path ["product" 0] {:a "NOT SAME"})})) + (is (= (disj #{(sut/command-map-path ["product" 0] {:a 99}) (sut/command-map-path ["product" 1] {:a 99})} + (sut/command-map-path ["product" 1] {:a "ANOTHER"})) + #{(sut/command-map-path ["product" 0] {:a 99})})) (is (= (mapv str (sort-by sut/command-path - [(sut/->CommandMapPath ["A" 10] {:a 99}) - (sut/->CommandMapPath ["A" 0] {:a 99}) - (sut/->CommandMapPath ["B"] {:a 99}) - (sut/->CommandMapPath ["X"] {:a 99})])) + [(sut/command-map-path ["A" 10] {:a 99}) + (sut/command-map-path ["A" 0] {:a 99}) + (sut/command-map-path ["B"] {:a 99}) + (sut/command-map-path ["X"] {:a 99})])) ["root,B" "root,X" "root,A,0" "root,A,10"])) (is (= (mapv str (sort-by str - [(sut/->CommandMapPath ["A" 10] {:a 99}) - (sut/->CommandMapPath ["A" 0] {:a 99}) - (sut/->CommandMapPath ["B"] {:a 99}) - (sut/->CommandMapPath ["X"] {:a 99})])) + [(sut/command-map-path ["A" 10] {:a 99}) + (sut/command-map-path ["A" 0] {:a 99}) + (sut/command-map-path ["B"] {:a 99}) + (sut/command-map-path ["X"] {:a 99})])) ["root,A,0" "root,A,10" "root,B" "root,X"])) (let [map-with-paths - {(sut/->CommandMapPath ["cheque"] {:a 99}) #{(sut/->CommandMapPath ["product" 0] {:a 99}) - (sut/->CommandMapPath ["product" 1] {:a 99})} - (sut/->CommandMapPath ["product" 0] {:a 2}) #{(sut/->CommandMapPath ["product" 0 :productValue] {:a 2})} - (sut/->CommandMapPath ["product" 0 :productValue] {:a 1}) #{} - (sut/->CommandMapPath ["product" 1] {:a 2}) #{(sut/->CommandMapPath ["product" 1 :productValue] {:a 2})} - (sut/->CommandMapPath ["product" 1 :productValue] {:a 1}) #{}}] - (is (= (get map-with-paths (sut/->CommandMapPath ["cheque"] {})) - #{(sut/->CommandMapPath ["product" 0] {}) (sut/->CommandMapPath ["product" 1] {})})))) + {(sut/command-map-path ["cheque"] {:a 99}) #{(sut/command-map-path ["product" 0] {:a 99}) + (sut/command-map-path ["product" 1] {:a 99})} + (sut/command-map-path ["product" 0] {:a 2}) #{(sut/command-map-path ["product" 0 :productValue] {:a 2})} + (sut/command-map-path ["product" 0 :productValue] {:a 1}) #{} + (sut/command-map-path ["product" 1] {:a 2}) #{(sut/command-map-path ["product" 1 :productValue] {:a 2})} + (sut/command-map-path ["product" 1 :productValue] {:a 1}) #{}}] + (is (= (get map-with-paths (sut/command-map-path ["cheque"] {})) + #{(sut/command-map-path ["product" 0] {}) (sut/command-map-path ["product" 1] {})})))) (testing "command-id function - returns string representation" - (let [cmd-path (sut/->CommandMapPath ["goal" :sub] {:type :test})] + (let [cmd-path (sut/command-map-path ["goal" :sub] {:type :test})] (is (= "root,goal,:sub[test]" (sut/command-id cmd-path)) "Returns correct string ID for command") (is (nil? (sut/command-id {})) "Returns nil for non-CommandMapPath objects") (is (nil? (sut/command-id nil)) "Returns nil for nil input") (is (nil? (sut/command-id "string")) "Returns nil for string input"))) (testing "command-map? predicate - identifies CommandMapPath objects" - (let [cmd-path (sut/->CommandMapPath ["test"] {})] + (let [cmd-path (sut/command-map-path ["test"] {})] (is (sut/command-map? cmd-path) "Returns true for CommandMapPath objects") (is (not (sut/command-map? {})) "Returns false for regular maps") (is (not (sut/command-map? [])) "Returns false for vectors") (is (not (sut/command-map? nil)) "Returns false for nil") (is (not (sut/command-map? "string")) "Returns false for strings"))) (testing "start-with? function - tests path hierarchy relationships" - (let [parent (sut/->CommandMapPath ["goal"] {}) - child (sut/->CommandMapPath ["goal" :sub] {}) - grandchild (sut/->CommandMapPath ["goal" :sub :deep] {}) - unrelated (sut/->CommandMapPath ["other"] {})] + (let [parent (sut/command-map-path ["goal"] {}) + child (sut/command-map-path ["goal" :sub] {}) + grandchild (sut/command-map-path ["goal" :sub :deep] {}) + unrelated (sut/command-map-path ["other"] {})] (is (sut/start-with? child parent) "Child path starts with parent path") (is (sut/start-with? grandchild parent) "Grandchild path starts with parent path") (is (sut/start-with? grandchild child) "Grandchild path starts with child path") @@ -67,25 +67,25 @@ (is (not (sut/start-with? child unrelated)) "Unrelated paths do not start with each other") (is (sut/start-with? parent parent) "Path starts with itself"))) (testing "API functions with edge cases - handle unusual inputs gracefully" - (let [empty-path (sut/->CommandMapPath [] {:type :root}) - mixed-keys (sut/->CommandMapPath ["string" :keyword 42 'symbol] {})] + (let [empty-path (sut/command-map-path [] {:type :root}) + mixed-keys (sut/command-map-path ["string" :keyword 42 'symbol] {})] (is (= "root[root]" (sut/command-id empty-path)) "Empty path generates correct ID") (is (= [] (sut/command-path empty-path)) "Empty path is accessible") (is (= "root,string,:keyword,42,symbol" (sut/command-id mixed-keys)) "Mixed key types in path generate correct ID") (is (= ["string" :keyword 42 'symbol] (sut/command-path mixed-keys)) "Mixed key types are preserved in path"))) (testing "Hash consistency - equal objects have equal hashes" - (let [cmd1 (sut/->CommandMapPath ["test"] {:data 1}) - cmd2 (sut/->CommandMapPath ["test"] {:data 2}) - cmd3 (sut/->CommandMapPath ["different"] {:data 1})] + (let [cmd1 (sut/command-map-path ["test"] {:data 1}) + cmd2 (sut/command-map-path ["test"] {:data 2}) + cmd3 (sut/command-map-path ["different"] {:data 1})] (is (= cmd1 cmd2) "Objects with same path are equal") (is (= (hash cmd1) (hash cmd2)) "Equal objects have same hash") (is (not= cmd1 cmd3) "Objects with different paths are not equal") (is (not= (hash cmd1) (hash cmd3)) "Unequal objects have different hashes"))) (testing "Collection behavior edge cases - comprehensive set/map operations" - (let [cmd1 (sut/->CommandMapPath ["a"] {}) - cmd2 (sut/->CommandMapPath ["a"] {:different "data"}) - cmd3 (sut/->CommandMapPath ["b"] {})] + (let [cmd1 (sut/command-map-path ["a"] {}) + cmd2 (sut/command-map-path ["a"] {:different "data"}) + cmd3 (sut/command-map-path ["b"] {})] (is (= #{cmd1} (conj #{cmd1} cmd2)) "Set deduplication based on path equality, set treats equal-path objects as identical") (is (= {cmd1 :value} diff --git a/test/unit/commando/impl/dependency_test.cljc b/test/unit/commando/impl/dependency_test.cljc new file mode 100644 index 0000000..5f4fb3f --- /dev/null +++ b/test/unit/commando/impl/dependency_test.cljc @@ -0,0 +1,221 @@ +(ns commando.impl.dependency-test + (:require + #?(:cljs [cljs.test :refer [deftest is testing]] + :clj [clojure.test :refer [deftest is testing]]) + [commando.commands.builtin :as cmds-builtin] + [commando.core :as commando] + [commando.impl.command-map :as cm] + [commando.impl.pathtrie :as pathtrie] + [commando.impl.registry :as commando-registry])) + +(def test-add-id-command + {:type :test/add-id + :recognize-fn #(and (map? %) (contains? % :test/add-id)) + :apply (fn [_instruction _command-path-obj command-map] (assoc command-map :id :test-id)) + :dependencies {:mode :all-inside}}) + +(def registry + (-> + [cmds-builtin/command-from-spec + test-add-id-command] + (commando-registry/build) + (commando-registry/enrich-runtime-registry))) + +;; -- Command path objects -- + +(def parent-cmd (cm/command-map-path [:parent] test-add-id-command)) +(def child-cmd (cm/command-map-path [:parent :child] test-add-id-command)) +(def target-cmd (cm/command-map-path [:target] test-add-id-command)) +(def ref-cmd (cm/command-map-path [:ref] cmds-builtin/command-from-spec)) + +(def chain-cmd-a (cm/command-map-path [:a] cmds-builtin/command-from-spec)) +(def chain-cmd-b (cm/command-map-path [:b] cmds-builtin/command-from-spec)) +(def chain-cmd-c (cm/command-map-path [:c] test-add-id-command)) + +(def diamond-cmd-a (cm/command-map-path [:a] cmds-builtin/command-from-spec)) +(def diamond-cmd-b (cm/command-map-path [:b] cmds-builtin/command-from-spec)) +(def diamond-cmd-c (cm/command-map-path [:c] cmds-builtin/command-from-spec)) +(def diamond-cmd-d (cm/command-map-path [:d] test-add-id-command)) + +(def deep-shallow (cm/command-map-path [:deep :nested :cmd] cmds-builtin/command-from-spec)) +(def shallow-target (cm/command-map-path [:target] test-add-id-command)) + +(def sibling1 (cm/command-map-path [:container :sib1] cmds-builtin/command-from-spec)) +(def sibling2 (cm/command-map-path [:container :sib2] test-add-id-command)) + +;; -- Status maps -- + +(defn- status-map-with-trie [m] + (assoc m :internal/path-trie (pathtrie/build-path-trie (:internal/cm-list m)))) + +(def failed-status-map + (status-map-with-trie + {:status :failed + :instruction {} + :registry registry + :internal/cm-list []})) + +(def empty-ok-status-map + (status-map-with-trie + {:status :ok + :instruction {:a 1} + :registry registry + :internal/cm-list []})) + +(def all-inside-status-map + (status-map-with-trie + {:status :ok + :instruction {:parent {:test/add-id :fn + :child {:test/add-id :fn}}} + :registry registry + :internal/cm-list [parent-cmd child-cmd]})) + +(def point-deps-status-map + (status-map-with-trie + {:status :ok + :instruction {:target {:test/add-id :fn} + :ref {:commando/from [:target]}} + :registry registry + :internal/cm-list [target-cmd ref-cmd]})) + +(def chained-deps-map + (status-map-with-trie + {:status :ok + :instruction {:a {:commando/from [:b]} + :b {:commando/from [:c]} + :c {:test/add-id :fn}} + :registry registry + :internal/cm-list [chain-cmd-a chain-cmd-b chain-cmd-c]})) + +(def diamond-deps-map + (status-map-with-trie + {:status :ok + :instruction {:a {:commando/from [:b]} + :b {:commando/from [:d]} + :c {:commando/from [:d]} + :d {:test/add-id :fn}} + :registry registry + :internal/cm-list [diamond-cmd-a diamond-cmd-b diamond-cmd-c diamond-cmd-d]})) + +(def deep-cross-ref-map + (status-map-with-trie + {:status :ok + :instruction {:deep {:nested {:cmd {:commando/from [:target]}}} + :target {:test/add-id :fn}} + :registry registry + :internal/cm-list [deep-shallow shallow-target]})) + +(def sibling-deps-map + (status-map-with-trie + {:status :ok + :instruction {:container {:sib1 {:commando/from [:container :sib2]} + :sib2 {:test/add-id :fn}}} + :registry registry + :internal/cm-list [sibling1 sibling2]})) + +(defn cmd-by-path [path commands] (first (filter #(= (cm/command-path %) path) commands))) + +(deftest build-deps-tree + (testing "Status handling" + (is (commando/failed? (#'commando/build-deps-tree failed-status-map)) "Failed status is preserved") + (is (commando/failed? (#'commando/build-deps-tree + {:status :ok + :instruction {:ref {:commando/from [:nonexistent]}} + :registry registry + :internal/cm-list [(cm/command-map-path [:ref] cmds-builtin/command-from-spec)]})) + "Returns failed status for non-existent path references") + (is (commando/ok? (#'commando/build-deps-tree empty-ok-status-map)) "Success status with empty command list")) + (testing "Dependency patterns" + (let [deps (:internal/cm-dependency (#'commando/build-deps-tree all-inside-status-map))] + (is (contains? (get deps parent-cmd) child-cmd) "Parent depends on child (all-inside)")) + (is (contains? (get (:internal/cm-dependency (#'commando/build-deps-tree point-deps-status-map)) ref-cmd) + target-cmd) + "Ref depends on target (point)") + (let [deps (:internal/cm-dependency (#'commando/build-deps-tree chained-deps-map))] + (is (contains? (get deps chain-cmd-a) chain-cmd-b) "A depends on B") + (is (contains? (get deps chain-cmd-b) chain-cmd-c) "B depends on C") + (is (empty? (get deps chain-cmd-c)) "C has no dependencies")) + (let [deps (:internal/cm-dependency (#'commando/build-deps-tree diamond-deps-map))] + (is (contains? (get deps diamond-cmd-b) diamond-cmd-d) "B depends on D") + (is (contains? (get deps diamond-cmd-c) diamond-cmd-d) "C depends on D") + (is (empty? (get deps diamond-cmd-d)) "D has no dependencies")) + (let [deps (:internal/cm-dependency (#'commando/build-deps-tree deep-cross-ref-map))] + (is (contains? (get deps deep-shallow) shallow-target) "Deep nested depends on shallow target")) + (let [deps (:internal/cm-dependency (#'commando/build-deps-tree sibling-deps-map))] + (is (contains? (get deps sibling1) sibling2) "Sibling1 depends on sibling2"))) + (testing "Complex multi-level dependency resolution" + (let [large-instruction + {:config {:database {:test/add-id :database} + :cache {:test/add-id :cache}} + :users {:fetch {:commando/from [:config :database]} + :validate {:commando/from [:users :fetch]}} + :products {:load {:test/add-id :products + :items {:fetch {:commando/from [:config :database]}}} + :cache {:commando/from [:products :load]}} + :orders {:create {:commando/from [:users :validate]} + :prepare {:commando/from [:products :cache]}}} + found (#'commando/find-commands + {:status :ok :instruction large-instruction :registry registry}) + cmds (:internal/cm-list found) + result (#'commando/build-deps-tree found) + deps (:internal/cm-dependency result)] + (is (commando/ok? result) "Successfully processes large dependency tree") + (is (contains? (get deps (cmd-by-path [:users :fetch] cmds)) (cmd-by-path [:config :database] cmds)) + "users.fetch depends on config.database") + (is (contains? (get deps (cmd-by-path [:users :validate] cmds)) (cmd-by-path [:users :fetch] cmds)) + "users.validate depends on users.fetch") + (is (contains? (get deps (cmd-by-path [:orders :create] cmds)) (cmd-by-path [:users :validate] cmds)) + "orders.create depends on users.validate") + (is (contains? (get deps (cmd-by-path [:orders :prepare] cmds)) (cmd-by-path [:products :cache] cmds)) + "orders.prepare depends on products.cache"))) + (testing "Empty command list" + (let [result (#'commando/build-deps-tree + (status-map-with-trie + {:status :ok :instruction {} :registry registry :internal/cm-list []}))] + (is (commando/ok? result) "Handles empty command list") + (is (empty? (:internal/cm-dependency result)) "Dependency map is empty")))) + +(deftest dependency-modes-test + (testing ":all-inside mode" + (let [goal2-cmd (cm/command-map-path [:goal-2] test-add-id-command) + goal2-someval-cmd (cm/command-map-path [:goal-2 :some-val] test-add-id-command) + test-status-map (status-map-with-trie + {:status :ok + :instruction {:goal-2 {:test/add-id :fn + :some-val {:test/add-id :nested}}} + :registry registry + :internal/cm-list [goal2-cmd goal2-someval-cmd]}) + result (#'commando/build-deps-tree test-status-map) + deps (:internal/cm-dependency result)] + (is (commando/ok? result) "Successfully processes :all-inside dependency") + (is (contains? (get deps goal2-cmd) goal2-someval-cmd) + "goal-2 command depends on goal-2.some-val command (nested inside)"))) + (testing ":point mode" + (let [goal1-cmd (cm/command-map-path [:goal-1] test-add-id-command) + ref-cmd (cm/command-map-path [:ref] cmds-builtin/command-from-spec) + test-status-map (status-map-with-trie + {:status :ok + :instruction {:goal-1 {:test/add-id :fn} + :ref {:commando/from [:goal-1]}} + :registry registry + :internal/cm-list [goal1-cmd ref-cmd]}) + result (#'commando/build-deps-tree test-status-map) + deps (:internal/cm-dependency result)] + (is (commando/ok? result) "Successfully processes :point dependency") + (is (contains? (get deps ref-cmd) goal1-cmd) + "ref command depends on goal-1 command (parent path dependency)"))) + (testing ":none mode - no dependencies" + (let [none-command {:type :test/none + :recognize-fn #(and (map? %) (contains? % :test/none)) + :apply identity + :dependencies {:mode :none}} + none-cmd (cm/command-map-path [:standalone] none-command) + test-status-map (status-map-with-trie + {:status :ok + :instruction {:standalone {:test/none :independent}} + :registry (commando/registry-create [none-command]) + :internal/cm-list [none-cmd]}) + result (#'commando/build-deps-tree test-status-map) + deps (:internal/cm-dependency result)] + (is (commando/ok? result) "Successfully processes :none dependency") + (is (empty? (get deps none-cmd)) "Command with :none mode has no dependencies")))) diff --git a/test/unit/commando/impl/finding_commands_test.cljc b/test/unit/commando/impl/finding_commands_test.cljc new file mode 100644 index 0000000..219cc30 --- /dev/null +++ b/test/unit/commando/impl/finding_commands_test.cljc @@ -0,0 +1,104 @@ +(ns commando.impl.finding-commands-test + (:require + #?(:cljs [cljs.test :refer [deftest is testing]] + :clj [clojure.test :refer [deftest is testing]]) + [commando.commands.builtin :as cmds-builtin] + [commando.core :as commando] + [commando.impl.command-map :as cm] + [commando.impl.registry :as commando-registry])) + +(def test-add-id-command + {:type :test/add-id + :recognize-fn #(and (map? %) (contains? % :test/add-id)) + :apply (fn [_instruction _command-path-obj command-map] (assoc command-map :id :test-id)) + :dependencies {:mode :all-inside}}) + +(def registry + (-> + [cmds-builtin/command-from-spec + test-add-id-command] + (commando-registry/build) + (commando-registry/enrich-runtime-registry))) + +(deftest find-commands + (testing "Basic cases" + (is (= #{(cm/command-map-path [] #'commando-registry/default-command-map-spec)} + (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {} + :registry registry}))) + "Empty instruction return _map command") + (is (= #{(cm/command-map-path [] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:some-val] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:some-other] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:my-value] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:i] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:v] #'commando-registry/default-command-vec-spec) + (cm/command-map-path [:some-val :a] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:i :am] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:i :am :deep] #'commando-registry/default-command-value-spec)} + (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {:some-val {:a 2} + :some-other 3 + :my-value :is-here + :i {:am {:deep :nested}} + :v []} + :registry registry}))) + "Instruction return internal commands _map, _vec, _value.") + (is (= #{(cm/command-map-path [] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:set] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:list] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:primitive] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:java-obj] #'commando-registry/default-command-value-spec)} + (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {:set #{:commando/from [:target]} + :list (list {:commando/from [:target]}) + :primitive 42 + :java-obj #?(:clj (java.util.Date.) + :cljs (js/Date.))} + :registry registry}))) + "Any type that not Map,Vector(and registry not contain other commands) became a _value standart internal command") + (is (= #{(cm/command-map-path [] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:set] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:list] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:valid] #'commando-registry/default-command-vec-spec) + (cm/command-map-path [:target] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:valid 0] cmds-builtin/command-from-spec)} + (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {:set #{:not-found} + :list (list :not-found) + :valid [{:commando/from [:target]}] + :target 42} + :registry registry}))) + "commando/from find and returned with corresponding command-map-path object") + (is (= + #{(cm/command-map-path [] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:a] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:target] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:a "some"] #'commando-registry/default-command-map-spec) + (cm/command-map-path [:a "some" :c] #'commando-registry/default-command-vec-spec) + (cm/command-map-path [:a "some" :c 0] #'commando-registry/default-command-value-spec) + (cm/command-map-path [:a "some" :c 1] cmds-builtin/command-from-spec)} + (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {:a {"some" {:c [:some {:commando/from [:target]}]}} + :target 42} + :registry registry}))) + "Example of usage commando/from inside of deep map") + (is (= :failed + (:status (#'commando/find-commands {:status :failed}))) + "Failed status is preserved") + (is + (let [mixed-keys-result (:internal/cm-list (#'commando/find-commands + {:status :ok + :instruction {"string-key" {:commando/from [:a]} + :keyword-key {:commando/from [:a]} + 42 {:commando/from [:a]} + :a 1} + :registry registry}))] + (is (some #(= (cm/command-path %) ["string-key"]) mixed-keys-result) "Correctly handles string keys in paths") + (is (some #(= (cm/command-path %) [:keyword-key]) mixed-keys-result) "Correctly handles keyword keys in paths") + (is (some #(= (cm/command-path %) [42]) mixed-keys-result) "Correctly handles numeric keys in paths"))))) diff --git a/test/unit/commando/impl/graph_test.cljc b/test/unit/commando/impl/graph_test.cljc new file mode 100644 index 0000000..c9a1535 --- /dev/null +++ b/test/unit/commando/impl/graph_test.cljc @@ -0,0 +1,86 @@ +(ns commando.impl.graph-test + (:require + #?(:cljs [cljs.test :refer [deftest is testing]] + :clj [clojure.test :refer [deftest is testing]]) + [commando.commands.builtin :as cmds-builtin] + [commando.core :as commando] + [commando.impl.command-map :as cm] + [commando.impl.registry :as commando-registry])) + +(def test-add-id-command + {:type :test/add-id + :recognize-fn #(and (map? %) (contains? % :test/add-id)) + :apply (fn [_instruction _command-path-obj command-map] (assoc command-map :id :test-id)) + :dependencies {:mode :all-inside}}) + +(def registry + (-> + [cmds-builtin/command-from-spec + test-add-id-command] + (commando-registry/build) + (commando-registry/enrich-runtime-registry))) + +(def chain-cmd-a (cm/command-map-path [:a] cmds-builtin/command-from-spec)) +(def chain-cmd-b (cm/command-map-path [:b] cmds-builtin/command-from-spec)) +(def chain-cmd-c (cm/command-map-path [:c] test-add-id-command)) + +(def diamond-cmd-a (cm/command-map-path [:a] cmds-builtin/command-from-spec)) +(def diamond-cmd-b (cm/command-map-path [:b] cmds-builtin/command-from-spec)) +(def diamond-cmd-c (cm/command-map-path [:c] cmds-builtin/command-from-spec)) +(def diamond-cmd-d (cm/command-map-path [:d] test-add-id-command)) + +(def circular-cmd-a (cm/command-map-path [:a] cmds-builtin/command-from-spec)) +(def circular-cmd-b (cm/command-map-path [:b] cmds-builtin/command-from-spec)) + +(deftest sort-entities-by-deps + (testing "Status handling" + (is (commando/failed? (#'commando/sort-commands-by-deps + {:status :failed :instruction {} :registry registry :internal/cm-list []})) + "Failed status is preserved") + (is (commando/ok? (#'commando/sort-commands-by-deps + {:status :ok :instruction {:a 1} :registry registry :internal/cm-list []})) + "Success status with empty dependency map")) + (testing "Simple dependency chain ordering" + (let [deps-map {:status :ok + :instruction {:a {:commando/from [:b]} + :b {:commando/from [:c]} + :c {:test/add-id :fn}} + :registry registry + :internal/cm-dependency {chain-cmd-a #{chain-cmd-b} + chain-cmd-b #{chain-cmd-c} + chain-cmd-c #{}}} + result (#'commando/sort-commands-by-deps deps-map) + order (:internal/cm-running-order result)] + (is (commando/ok? result) "Successfully sorts linear dependency chain") + (is (= 3 (count order)) "Returns all commands in order") + (is (< (.indexOf order chain-cmd-c) (.indexOf order chain-cmd-b) (.indexOf order chain-cmd-a)) + "Commands ordered correctly: c before b before a"))) + (testing "Diamond dependency pattern" + (let [deps-map {:status :ok + :instruction {:a {:commando/from [:b :c]} + :b {:commando/from [:d]} + :c {:commando/from [:d]} + :d {:test/add-id :fn}} + :registry registry + :internal/cm-dependency {diamond-cmd-a #{diamond-cmd-b diamond-cmd-c} + diamond-cmd-b #{diamond-cmd-d} + diamond-cmd-c #{diamond-cmd-d} + diamond-cmd-d #{}}} + result (#'commando/sort-commands-by-deps deps-map) + order (:internal/cm-running-order result)] + (is (commando/ok? result) "Successfully sorts diamond dependency") + (is (= 4 (count order)) "Returns all commands in order") + (is (< (.indexOf order diamond-cmd-d) (.indexOf order diamond-cmd-b)) "D executes before B") + (is (< (.indexOf order diamond-cmd-d) (.indexOf order diamond-cmd-c)) "D executes before C") + (is (< (.indexOf order diamond-cmd-b) (.indexOf order diamond-cmd-a)) "B executes before A"))) + (testing "Circular dependency detection" + (let [deps-map {:status :ok + :instruction {:a {:commando/from [:b]} + :b {:commando/from [:a]}} + :registry registry + :internal/cm-dependency {circular-cmd-a #{circular-cmd-b} + circular-cmd-b #{circular-cmd-a}}} + result (#'commando/sort-commands-by-deps deps-map)] + (is (commando/failed? result) "Detects circular dependency and returns failed status") + (is (some #(re-find #"cyclic dependency" %) (map :message (:errors result))) + "Error message mentions cyclic dependency")))) diff --git a/test/unit/commando/impl/pathtrie_test.cljc b/test/unit/commando/impl/pathtrie_test.cljc new file mode 100644 index 0000000..cd29284 --- /dev/null +++ b/test/unit/commando/impl/pathtrie_test.cljc @@ -0,0 +1,26 @@ +(ns commando.impl.pathtrie-test + (:require + #?(:cljs [cljs.test :refer [deftest is testing]] + :clj [clojure.test :refer [deftest is testing]]) + [commando.impl.command-map :as cm] + [commando.impl.pathtrie :as pathtrie])) + +;; ----------- +;; Trie operations test +;; ----------- + +(deftest trie-operations-test + (testing "Incremental trie update matches full rebuild" + (let [cmd-a (cm/command-map-path [:a] {:type :test}) + cmd-b (cm/command-map-path [:b] {:type :test}) + cmd-c (cm/command-map-path [:c :d] {:type :test}) + cmd-e (cm/command-map-path [:a :x] {:type :test}) + original-cmds [cmd-a cmd-b cmd-c] + original-trie (pathtrie/build-path-trie original-cmds) + ;; Remove :a subtree, insert cmd-e + updated-trie (-> original-trie + (pathtrie/trie-remove-paths [[:a]]) + (pathtrie/trie-insert-commands [cmd-e])) + full-rebuild (pathtrie/build-path-trie [cmd-b cmd-c cmd-e])] + (is (= updated-trie full-rebuild) + "Incremental trie update produces same result as full rebuild")))) diff --git a/test/unit/commando/impl/registry_test.clj b/test/unit/commando/impl/registry_test.cljc similarity index 100% rename from test/unit/commando/impl/registry_test.clj rename to test/unit/commando/impl/registry_test.cljc diff --git a/test/unit/commando/impl/utils_test.cljc b/test/unit/commando/impl/utils_test.cljc index 4ec25d6..81dd5d7 100644 --- a/test/unit/commando/impl/utils_test.cljc +++ b/test/unit/commando/impl/utils_test.cljc @@ -84,8 +84,7 @@ :data "{:level \"1\"}"}))) (let [e (binding [sut/*execute-config* - {:debug-result false - :error-data-string false}] + (sut/execute-config-update {:error-data-string false})] (try (malli/assert :int "string") (catch Exception e @@ -148,8 +147,7 @@ :data "{}"}))) (let [e (binding [sut/*execute-config* - {:debug-result false - :error-data-string false}] + (sut/execute-config-update {:error-data-string false})] (try (malli/assert :int "string") (catch :default e