diff --git a/CHANGELOG.md b/CHANGELOG.md index 675e771..a763148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 1.1.1 + +ADDED `commando.debug` namespace — new dedicated module for debug visualization. Provides two main entry points: +- `execute-debug` — execute an instruction and visualize it in one of six display modes: `:tree`, `:table`, `:graph`, `:stats`, `:instr-before` / `:instr-after`. Supports combining multiple modes via a vector. +- `execute-trace` — trace all nested `commando/execute` calls. + +REMOVED **BREAKING** `print-stats`, `print-trace` (`print-deep-stats`) from `commando.impl.utils`. These functions have been replaced by the richer `commando.debug` namespace. + +FIXED `crop-final-status-map` in `commando.core` — internal keys (`:internal/cm-list`, `:internal/cm-dependency`, `:internal/cm-running-order`, `:registry`) are now properly stripped from the result when `:debug-result` is not enabled. + +FIXED `execute-command-impl` in `commando.impl.executing` — added guard for non-map `command-data` before calling `dissoc` on driver keys (`:=>`, `"=>`), preventing errors when command data is a non-map value. + +UPDATED documentation — restructured `README.md` with improved navigation, added "Managing the Registry" and "Debugging" sections. Moved doc files from `doc/` to `examples/` directory with richer, runnable code examples: `walkthrough.clj`, `integrant.clj`, `component.clj`, `json.clj`, `reitit.clj`, `reagent_front.cljs`. + +UPDATED tests — split monolithic `core_test.cljc` into focused test namespaces: `dependency_test.cljc`, `finding_commands_test.cljc`, `graph_test.cljc`. Added `debug_test.cljc` for the new debug module. Updated performance tests. + # 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..ba1094b 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.1.1) -**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.1.1"}} ;; leiningen -[org.clojars.funkcjonariusze/commando "1.1.0"] +[org.clojars.funkcjonariusze/commando "1.1.1"] ``` ## 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"} + + :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"}} +``` -As Commando is simply a graph-based resolver with easy configuration, it is not limited by any architectural constraints or specific framework. +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,6 +677,65 @@ 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 `:tree`): + +```clojure +(require '[commando.debug :as debug]) + +;; Default :table mode — shows data flow with values +(debug/execute-debug registry instruction) + +;; Other modes: :table, :tree, :graph, :stats, :instr-before, :instr-after +(debug/execute-debug registry instruction :table) + +;; 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. Wraps execution in a zero-argument function: + +```clojure +(debug/execute-trace + #(commando/execute registry instruction)) +``` + +Add `:__title` (or `"__title"`) to an instruction to label it in the trace output. + ## Status-Map and Internals @@ -720,6 +826,10 @@ The `commando.impl.utils/*execute-config*` dynamic variable allows for fine-grai - `:debug-result` (boolean) - `:error-data-string` (boolean) +- `: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`. #### `:debug-result` @@ -734,10 +844,10 @@ Here's an example of how to use `:debug-result`: (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"]}})) + [commands-builtin/command-from-spec] + {"1" 1 + "2" {:commando/from ["1"]} + "3" {:commando/from ["2"]}})) ;; RETURN => {:status :ok, @@ -868,13 +978,23 @@ To test the limits of the library, we benchmarked it with instructions containin -# 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/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..5df0bab --- /dev/null +++ b/examples/walkthrough.clj @@ -0,0 +1,472 @@ +;; ┌─────────────────────────────────────────────────────┐ +;; │ 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 + #(commando/execute + [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..efb9287 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ jar org.clojars.funkcjonariusze commando - 1.1.0 + 1.1.1 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.1.1 diff --git a/src/commando/commands/builtin.cljc b/src/commando/commands/builtin.cljc index a543a11..2216b5d 100644 --- a/src/commando/commands/builtin.cljc +++ b/src/commando/commands/builtin.cljc @@ -391,7 +391,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 +420,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 diff --git a/src/commando/core.cljc b/src/commando/core.cljc index 6152572..30f66a6 100644 --- a/src/commando/core.cljc +++ b/src/commando/core.cljc @@ -146,10 +146,13 @@ (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)))) + (if (:debug-result (utils/execute-config)) + status-map + (dissoc status-map + :internal/cm-list + :internal/cm-dependency + :internal/cm-running-order + :registry))) ;; -- Execute -- @@ -167,7 +170,8 @@ (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))))))) + (utils/hook-process (:hook-execute-end (utils/execute-config))) + (crop-final-status-map))))) (defn failed? [status-map] (smap/failed? status-map)) (defn ok? [status-map] (smap/ok? status-map)) diff --git a/src/commando/debug.cljc b/src/commando/debug.cljc new file mode 100644 index 0000000..f1e6b50 --- /dev/null +++ b/src/commando/debug.cljc @@ -0,0 +1,630 @@ +(ns commando.debug + "Debug visualization tools for commando instruction execution. + + Main entry points: + execute-debug — execute and visualize a single instruction + execute-trace — trace all nested execute calls with timing + + Display modes for execute-debug / pprint-debug: + :tree — enriched data flow tree with values (default) + :table — tabular execution map (order, deps, results) + :graph — compact dependency graph (structure only) + :stats — execution statistics (timing, counts, errors) + :instr-before — wide pprint of the original instruction + :instr-after — wide pprint of the executed instruction" + (: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? + "Is this command a top-level instruction key (path length = 1)? + Filters out nested/internal commands like [:ACCOUNT-1-1 :from]." + [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 (for pprint-debug modes) +;; ============================================================ + +(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 + "Given a command and its dependency map, resolve transitive deps + to only include top-level commands." + [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)})) + +;; ============================================================ +;; Mode: :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))) + +;; ============================================================ +;; Mode: :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))) + +;; ============================================================ +;; Mode: :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))) + +;; ============================================================ +;; Mode: :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))) + +;; ============================================================ +;; Mode: :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)) + +;; ============================================================ +;; pprint-debug / execute-debug +;; ============================================================ + +(defn ^:private pprint-single-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 pprint-debug mode: " mode) + {:mode mode + :available [:tree :table :graph :stats :instr-before :instr-after]})))) + +(defn ^:private pprint-debug + "Pretty-print execution debug info. + + Accepts a single mode keyword or a vector of modes to combine. + + Modes: + :table — tabular execution map (order, deps, results) (default) + :tree — enriched data flow tree with values + :graph — compact dependency graph (structure only) + :stats — execution statistics (timing, counts, errors) + :instr-before — wide pprint of the original instruction + :instr-after — wide pprint of the executed instruction + + Usage: + (pprint-debug result instruction) + (pprint-debug result instruction :table) + (pprint-debug result instruction [:instr-before :table :stats])" + ([result original-instruction] + (pprint-debug result original-instruction :tree)) + ([result original-instruction mode] + (if (vector? mode) + (doseq [m mode] + (pprint-single-mode m result original-instruction)) + (pprint-single-mode mode result original-instruction)))) + +(defn execute-debug + "Execute an instruction with debug enabled, print with pprint-debug. + Returns the execution result. + + Accepts a single mode keyword or a vector of modes. + + Usage: + (execute-debug registry instruction) + (execute-debug registry instruction :table) + (execute-debug registry instruction [:instr-before :tree :stats]) + + Example: + (require '[commando.commands.builtin :as builtin]) + + (execute-debug + [builtin/command-from-spec] + {:a 1 :b {:commando/from [:a] :=> [:fn inc]}} + :table)" + ([registry instruction] + (execute-debug registry instruction :table)) + ([registry instruction mode] + (let [result (binding [utils/*execute-config* + (assoc (utils/execute-config) :debug-result true)] + (commando/execute registry instruction))] + (pprint-debug result instruction mode) + result))) + +;; ============================================================ +;; execute-trace — trace nested execute calls +;; ============================================================ +;; +;; Wraps an execution function and prints a tree of all nested +;; commando/execute calls with timing stats, instruction keys, +;; and optional titles. +;; +;; ============================================================ + +(defn ^:private trace-extract-children + "Extract child execution entries from trace data. + Children are map-valued entries that are not known metadata keys." + [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 + "Format instruction keys for compact display." + [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 + "Format a compact one-line summary with only execute-commands! and execute times." + [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 + "Print a child execution node with tree connectors." + [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 + "Print the full execution trace tree." + [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. + + Takes a zero-argument function that calls commando/execute and + returns its result unchanged. 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 + #(commando/execute registry instruction))" + [execution-fn] + (let [stats-state (atom {}) + result + (binding [utils/*execute-config* + (assoc (utils/execute-config) + :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))))}))))))] + (execution-fn))] + (trace-print @stats-state) + result)) + diff --git a/src/commando/impl/executing.cljc b/src/commando/impl/executing.cljc index eb4f948..2e2d920 100644 --- a/src/commando/impl/executing.cljc +++ b/src/commando/impl/executing.cljc @@ -60,7 +60,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 diff --git a/src/commando/impl/utils.cljc b/src/commando/impl/utils.cljc index c896de8..8a63d80 100644 --- a/src/commando/impl/utils.cljc +++ b/src/commando/impl/utils.cljc @@ -313,181 +313,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 index 71d1f12..b11279f 100644 --- a/test/perf/commando/core_perf_test.clj +++ b/test/perf/commando/core_perf_test.clj @@ -4,6 +4,7 @@ [commando.commands.builtin] [commando.commands.query-dsl] [commando.core] + [commando.debug :as debug] [commando.impl.utils :as commando-utils])) ;; ======================================= @@ -32,8 +33,10 @@ `(let [results# (doall (for [_# (range ~n)] ~@body)) avg-stats# (calculate-average-stats results#)] - (print "Repeating instruction " ~n " times") - (commando-utils/print-stats avg-stats#))) + (println "Repeating instruction " ~n " times") + (#'debug/pprint-stats-mode + (assoc avg-stats# :status :ok :errors [] :internal/cm-running-order []) + nil))) (defn real-word-calculation-average-of-50 [] (println "\n=====================Benchmark=====================") @@ -102,13 +105,13 @@ :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)))]} + (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 @@ -116,10 +119,10 @@ :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)))]} + (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] @@ -134,27 +137,27 @@ :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))))]}} + (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 @@ -163,24 +166,24 @@ :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))}}))]}})) + (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))}}))]}}))) ;; ============================== @@ -246,7 +249,7 @@ (println "\n===================Benchmark=====================") (println "Run commando/execute in depth with using queryDSL") (println "=================================================") - (commando-utils/print-trace + (debug/execute-trace #(commando.core/execute [commando.commands.query-dsl/command-resolve-spec commando.commands.builtin/command-from-spec @@ -376,7 +379,10 @@ (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))) + (println (str "Dependency Counts: " dependecy-token)) + (#'debug/pprint-stats-mode + {:stats stats :status :ok :errors [] :internal/cm-running-order []} + nil)) (cljfreechart/save-chart-as-file (-> chart-data (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) @@ -405,7 +411,10 @@ (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))) + (println (str "Dependency Counts: " dependecy-token)) + (#'debug/pprint-stats-mode + {:stats stats :status :ok :errors [] :internal/cm-running-order []} + nil)) (cljfreechart/save-chart-as-file (-> chart-data (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) @@ -434,7 +443,10 @@ (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))) + (println (str "Dependency Counts: " dependecy-token)) + (#'debug/pprint-stats-mode + {:stats stats :status :ok :errors [] :internal/cm-running-order []} + nil)) (cljfreechart/save-chart-as-file (-> chart-data (cljfreechart/make-category-dataset {:group-key "dependecy-token"}) diff --git a/test/unit/commando/commands/builtin_test.cljc b/test/unit/commando/commands/builtin_test.cljc index 38165d4..d91bb8d 100644 --- a/test/unit/commando/commands/builtin_test.cljc +++ b/test/unit/commando/commands/builtin_test.cljc @@ -2,6 +2,7 @@ (: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] @@ -80,7 +81,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 @@ -285,7 +296,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 @@ -574,3 +597,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/core_test.cljc b/test/unit/commando/core_test.cljc index e0f7cae..0a70406 100644 --- a/test/unit/commando/core_test.cljc +++ b/test/unit/commando/core_test.cljc @@ -3,14 +3,11 @@ #?(: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] [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 +21,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,10 +53,12 @@ :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/->CommandMapPath ["cmd"] test-add-id-command)]}) (def from-command {:status :ok @@ -574,55 +81,11 @@ :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}}}) - -(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/->CommandMapPath ["nil-handler"] nil-handler-command)]}) (def bad-command-execution-map {:status :ok @@ -641,18 +104,6 @@ (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)]}) - (def deep-nested-execution-map {:status :ok :instruction {"level1" {"level2" {"level3" {"deep" {:test/add-id "deep-value"}}}}} @@ -667,219 +118,6 @@ :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 +162,67 @@ (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)))))) + diff --git a/test/unit/commando/debug_test.cljc b/test/unit/commando/debug_test.cljc new file mode 100644 index 0000000..48ea3ab --- /dev/null +++ b/test/unit/commando/debug_test.cljc @@ -0,0 +1,225 @@ +(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 + #(commando/execute + [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 + #(commando/execute + [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/dependency_test.cljc b/test/unit/commando/impl/dependency_test.cljc new file mode 100644 index 0000000..7a0451e --- /dev/null +++ b/test/unit/commando/impl/dependency_test.cljc @@ -0,0 +1,205 @@ +(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.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/->CommandMapPath [:parent] test-add-id-command)) +(def child-cmd (cm/->CommandMapPath [:parent :child] test-add-id-command)) +(def target-cmd (cm/->CommandMapPath [:target] test-add-id-command)) +(def ref-cmd (cm/->CommandMapPath [:ref] cmds-builtin/command-from-spec)) + +(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 deep-shallow (cm/->CommandMapPath [:deep :nested :cmd] cmds-builtin/command-from-spec)) +(def shallow-target (cm/->CommandMapPath [:target] 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)) + +;; -- Status maps -- + +(def failed-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 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 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 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 sibling-deps-map + {: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/->CommandMapPath [: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]}}} + cmds (:internal/cm-list (#'commando/find-commands + {:status :ok :instruction large-instruction :registry registry})) + result (#'commando/build-deps-tree + {:status :ok :instruction large-instruction :registry registry :internal/cm-list cmds}) + 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 :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/->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)"))) + (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")))) 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..cfd58d9 --- /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/->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"))))) diff --git a/test/unit/commando/impl/graph_test.cljc b/test/unit/commando/impl/graph_test.cljc new file mode 100644 index 0000000..c0e8f3e --- /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/->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)) + +(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"))))