├── VERSION ├── guardrails.edn ├── .clj-kondo ├── babashka │ └── fs │ │ └── config.edn ├── rewrite-clj │ └── rewrite-clj │ │ └── config.edn ├── promesa │ └── config.edn ├── com.wsscode │ ├── pathom3 │ │ └── config.edn │ └── async │ │ └── config.edn ├── com.fulcrologic │ └── guardrails │ │ ├── config.edn │ │ └── com │ │ └── fulcrologic │ │ └── guardrails │ │ └── clj_kondo_hooks.clj ├── funcool │ └── promesa │ │ └── config.edn ├── config.edn └── marick │ └── midje │ ├── marick │ └── midje.clj │ └── config.edn ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .lsp └── config.edn ├── repo-resources └── pathom-banner-padded.png ├── test ├── resources │ └── sample-config.edn └── com │ └── wsscode │ ├── pathom3 │ ├── path_test.cljc │ ├── cache_test.cljc │ ├── connect │ │ ├── operation │ │ │ └── transit_test.cljc │ │ ├── runner │ │ │ ├── stats_test.cljc │ │ │ ├── path_selection.cljc │ │ │ └── helpers.cljc │ │ └── built_in │ │ │ ├── resolvers_test.cljc │ │ │ └── plugins_test.cljc │ ├── test │ │ ├── helpers.cljc │ │ └── geometry_resolvers.cljc │ ├── placeholder_test.cljc │ ├── entity_tree_test.cljc │ ├── plugin_test.cljc │ ├── error_test.cljc │ ├── interface │ │ ├── async │ │ │ └── eql_test.clj │ │ ├── eql_test.cljc │ │ └── smart_map_test.cljc │ └── format │ │ └── eql_test.cljc │ └── promesa │ └── macros_test.clj ├── src ├── main │ └── com │ │ └── wsscode │ │ └── pathom3 │ │ ├── connect │ │ ├── operation │ │ │ ├── protocols.cljc │ │ │ └── transit.cljc │ │ ├── runner │ │ │ └── stats.cljc │ │ ├── foreign.cljc │ │ └── built_in │ │ │ ├── plugins.cljc │ │ │ └── resolvers.cljc │ │ ├── system.cljc │ │ ├── attribute.cljc │ │ ├── placeholder.cljc │ │ ├── path.cljc │ │ ├── entity_tree.cljc │ │ ├── cache.cljc │ │ ├── error.cljc │ │ ├── plugin.cljc │ │ ├── interface │ │ ├── async │ │ │ └── eql.cljc │ │ └── eql.cljc │ │ └── format │ │ └── shape_descriptor.cljc ├── promesa │ └── com │ │ └── wsscode │ │ └── promesa │ │ └── macros.cljc └── tasks │ └── tasks.clj ├── .vscode └── settings.json ├── .license-agreements ├── dehli.md ├── eneroth.md ├── rodolfo42.md ├── souenzzo.md ├── template.md ├── tommy-mor.md └── calebmacdonaldblack.md ├── resources └── clj-kondo.exports │ └── com.wsscode │ └── pathom3 │ └── config.edn ├── .gitignore ├── karma.conf.js ├── package.json ├── shadow-cljs.edn ├── README.md ├── bb.edn ├── deps.edn ├── pom.xml ├── .cljstyle └── CHANGELOG.md /VERSION: -------------------------------------------------------------------------------- 1 | 2025.01.16-alpha -------------------------------------------------------------------------------- /guardrails.edn: -------------------------------------------------------------------------------- 1 | {:async? true} 2 | -------------------------------------------------------------------------------- /.clj-kondo/babashka/fs/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {babashka.fs/with-temp-dir clojure.core/let}} 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: wsscode 4 | -------------------------------------------------------------------------------- /.lsp/config.edn: -------------------------------------------------------------------------------- 1 | {:linters {:unused-public-var {:level :off}} 2 | :source-aliases #{:provided :test-deps}} 3 | -------------------------------------------------------------------------------- /repo-resources/pathom-banner-padded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilkerlucio/pathom3/HEAD/repo-resources/pathom-banner-padded.png -------------------------------------------------------------------------------- /.clj-kondo/rewrite-clj/rewrite-clj/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as 2 | {rewrite-clj.zip/subedit-> clojure.core/-> 3 | rewrite-clj.zip/subedit->> clojure.core/->> 4 | rewrite-clj.zip/edit-> clojure.core/-> 5 | rewrite-clj.zip/edit->> clojure.core/->>}} 6 | -------------------------------------------------------------------------------- /test/resources/sample-config.edn: -------------------------------------------------------------------------------- 1 | {:my.system/port 2 | 1234 3 | 4 | :my.system/initial-path 5 | "/tmp/system" 6 | 7 | :my.system/generic-db 8 | ^{:com.wsscode.pathom3/entity-table :my.system/user-id} 9 | {4 {:my.system.user/name "Anne"} 10 | 2 {:my.system.user/name "Fred"}}} 11 | -------------------------------------------------------------------------------- /.clj-kondo/promesa/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {promesa.core/-> clojure.core/-> 2 | promesa.core/->> clojure.core/->> 3 | promesa.core/as-> clojure.core/as-> 4 | promesa.core/let clojure.core/let 5 | promesa.core/plet clojure.core/let 6 | promesa.core/loop clojure.core/loop}} 7 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/operation/protocols.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.operation.protocols) 2 | 3 | (defprotocol IOperation 4 | (-operation-config [this]) 5 | (-operation-type [this])) 6 | 7 | (defprotocol IResolver 8 | (-resolve [this env input])) 9 | 10 | (defprotocol IMutation 11 | (-mutate [this env params])) 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "calva.replConnectSequences": [ 3 | { 4 | "name": "REPL", 5 | "projectType": "deps.edn", 6 | "cljsType": "none", 7 | "menuSelections": { 8 | "cljAliases": [ 9 | "provided", "test-deps", "demos", "local/devtools", "local/+pathom" 10 | ] 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.clj-kondo/com.wsscode/pathom3/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {com.wsscode.pathom3.connect.operation/defmutation clojure.core/defn 2 | com.wsscode.pathom3.connect.operation/defresolver clojure.core/defn 3 | com.wsscode.pathom3.plugin/defplugin clojure.core/def 4 | com.wsscode.promesa.macros/clet clojure.core/let 5 | com.wsscode.promesa.macros/ctry clojure.core/try}} 6 | -------------------------------------------------------------------------------- /.license-agreements/dehli.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.license-agreements/eneroth.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.license-agreements/rodolfo42.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.license-agreements/souenzzo.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.license-agreements/template.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.license-agreements/tommy-mor.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /resources/clj-kondo.exports/com.wsscode/pathom3/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {com.wsscode.pathom3.connect.operation/defmutation clojure.core/defn 2 | com.wsscode.pathom3.connect.operation/defresolver clojure.core/defn 3 | com.wsscode.pathom3.plugin/defplugin clojure.core/def 4 | com.wsscode.promesa.macros/clet clojure.core/let 5 | com.wsscode.promesa.macros/ctry clojure.core/try}} 6 | -------------------------------------------------------------------------------- /.license-agreements/calebmacdonaldblack.md: -------------------------------------------------------------------------------- 1 | By placing this file I hereby grant Wilker Lucio and his successors, assigns, and sub-licenses, a non-exclusive license to copy, reproduce, print, publish, distribute, translate, and otherwise use all work, code, or works of authorship produced and contributed to this project in all versions and editions of the Work, and in any works based on or incorporating the Work, in any and all media, in perpetuity and throughout the world. 2 | -------------------------------------------------------------------------------- /.clj-kondo/com.fulcrologic/guardrails/config.edn: -------------------------------------------------------------------------------- 1 | {:hooks {:analyze-call {com.fulcrologic.guardrails.core/>defn 2 | com.fulcrologic.guardrails.clj-kondo-hooks/>defn 3 | com.fulcrologic.guardrails.core/>defn- 4 | com.fulcrologic.guardrails.clj-kondo-hooks/>defn}} 5 | :linters {:clj-kondo.fulcro.>defn/invalid-gspec {:level :error}} 6 | :lint-as {com.fulcrologic.guardrails.core/>def clojure.spec.alpha/def}} 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Clojure template 3 | pom.xml.asc 4 | *.jar 5 | *.class 6 | /lib/ 7 | /classes/ 8 | /target/ 9 | /checkouts/ 10 | .lein-deps-sum 11 | .lein-repl-history 12 | .lein-plugins/ 13 | .lein-failures 14 | .nrepl-port 15 | .cpcache/ 16 | .nrepl-port 17 | node_modules/ 18 | src/demos 19 | .clj-kondo/.cache 20 | .idea 21 | .shadow-cljs 22 | web/public/js 23 | sqlite.db 24 | .calva/output-window/output.calva-repl 25 | -------------------------------------------------------------------------------- /.clj-kondo/funcool/promesa/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {promesa.core/-> clojure.core/-> 2 | promesa.core/->> clojure.core/->> 3 | promesa.core/as-> clojure.core/as-> 4 | promesa.core/let clojure.core/let 5 | promesa.core/plet clojure.core/let 6 | promesa.core/loop clojure.core/loop 7 | promesa.core/recur clojure.core/recur 8 | promesa.core/with-redefs clojure.core/with-redefs}} 9 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/system.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.system 2 | (:require 3 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]])) 4 | 5 | (>def :pathom/lenient-mode? 6 | "Lenient mode indicates to Pathom that fails should be tolerated. This means Pathom will 7 | catch all errors and return any data it can in the process. This is in contrast with 8 | the default strict mode, which fails if any part of the request is unsuccessful." 9 | boolean?) 10 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | browsers: ['ChromeHeadless'], 4 | // The directory where the output file lives 5 | basePath: 'target', 6 | // The file itself 7 | files: ['ci.js'], 8 | frameworks: ['cljs-test'], 9 | plugins: ['karma-cljs-test', 'karma-chrome-launcher'], 10 | colors: true, 11 | logLevel: config.LOG_INFO, 12 | client: { 13 | args: ["shadow.test.karma.init"], 14 | singleRun: true 15 | } 16 | }) 17 | }; 18 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/attribute.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.attribute 2 | "Core specs of Pathom" 3 | (:require 4 | [clojure.spec.alpha :as s] 5 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]])) 6 | 7 | (>def ::attribute keyword?) 8 | (>def ::parameterized-attribute (s/and seq? (s/cat :attr ::attribute :params (s/? ::params)))) 9 | (>def ::attribute-maybe-parameterized (s/or :plain-attr ::attribute :parameterized ::parameterized-attribute)) 10 | 11 | (>def ::attributes-set (s/coll-of ::attribute :kind set?)) 12 | -------------------------------------------------------------------------------- /.clj-kondo/com.wsscode/async/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {com.wsscode.async.async-clj/deftest-async clojure.test/deftest 2 | com.wsscode.async.async-clj/go-try-stream clojure.core/let 3 | com.wsscode.async.async-clj/let-chan clojure.core/let 4 | com.wsscode.async.async-clj/let-chan* clojure.core/let 5 | com.wsscode.async.async-cljs/deftest-async clojure.test/deftest 6 | com.wsscode.async.async-cljs/go-try-stream clojure.core/let 7 | com.wsscode.async.async-cljs/let-chan clojure.core/let 8 | com.wsscode.async.async-cljs/let-chan* clojure.core/let}} 9 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/path_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.path-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.path :as p.path])) 5 | 6 | (deftest append-path-test 7 | (is (= (p.path/append-path {::p.path/path []} :foo) 8 | {::p.path/path [:foo]})) 9 | 10 | (is (= (p.path/append-path {::p.path/path [:one]} :foo) 11 | {::p.path/path [:one :foo]}))) 12 | 13 | (deftest root?-test 14 | (is (= (p.path/root? {}) true)) 15 | (is (= (p.path/root? {::p.path/path nil}) true)) 16 | (is (= (p.path/root? {::p.path/path []}) true)) 17 | (is (= (p.path/root? {::p.path/path [:foo]}) false))) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pathom3", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/wilkerlucio/pathom3.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/wilkerlucio/pathom3/issues" 20 | }, 21 | "homepage": "https://github.com/wilkerlucio/pathom3#readme", 22 | "dependencies": { 23 | "karma": "^5.1.1", 24 | "karma-chrome-launcher": "^3.1.0", 25 | "karma-cljs-test": "^0.1.0", 26 | "shadow-cljs": "^2.10.21" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/cache_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.cache-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.cache :as p.cache])) 5 | 6 | (deftest cached-test 7 | (testing "no cache, always run" 8 | (is (= (p.cache/cached :foo {} :key #(-> "bar")) 9 | "bar"))) 10 | 11 | (testing "adds to the cache" 12 | (let [cache* (atom {})] 13 | (is (= (p.cache/cached :foo {:foo cache*} :key #(-> "bar")) 14 | "bar")) 15 | (is (= @cache* {:key "bar"})))) 16 | 17 | (testing "gets from cache" 18 | (let [cache* (atom {:key "other"})] 19 | (is (= (p.cache/cached :foo {:foo cache*} :key #(-> "bar")) 20 | "other")) 21 | (is (= @cache* {:key "other"}))))) 22 | -------------------------------------------------------------------------------- /test/com/wsscode/promesa/macros_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.promesa.macros-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [com.wsscode.promesa.macros :refer [clet ctry]] 5 | [promesa.core :as p])) 6 | 7 | (deftest ctry-test 8 | (is (= :error 9 | (ctry 10 | (throw (ex-info "err" {})) 11 | (catch Throwable _ :error)))) 12 | (is (= :error 13 | @(ctry 14 | (p/rejected (ex-info "err" {})) 15 | (catch Throwable _ :error)))) 16 | (is (= :error 17 | (ctry 18 | (clet [foo (throw (ex-info "err" {}))] 19 | foo) 20 | (catch Throwable _ :error)))) 21 | (is (= :error 22 | @(ctry 23 | (clet [foo (p/rejected (ex-info "err" {}))] 24 | foo) 25 | (catch Throwable _ :error))))) 26 | 27 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps {:aliases [:provided :test-deps :test-cljs]} 2 | :nrepl {:port 59121} 3 | :builds {:test {:target :browser-test 4 | :test-dir "web/public/js/test" 5 | :ns-regexp "-test$" 6 | :compiler-options {:static-fns false 7 | :external-config {:guardrails {:throw? false :emit-spec? true}}} 8 | :devtools {:http-port 9158 9 | :http-resource-root "public" 10 | :http-root "web/public/js/test"}} 11 | 12 | :ci {:target :karma 13 | :compiler-options {:output-feature-set :es6} 14 | :ns-regexp "-test$" 15 | :output-to "target/ci.js"}}} 16 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/operation/transit.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.operation.transit 2 | (:require 3 | [cognitect.transit :as t] 4 | [com.wsscode.pathom3.connect.operation 5 | #?@(:cljs [:refer [Resolver Mutation]]) 6 | :as pco]) 7 | #?(:clj 8 | (:import 9 | (com.wsscode.pathom3.connect.operation 10 | Mutation 11 | Resolver)))) 12 | 13 | (defn restored-handler [_ _] 14 | (throw (ex-info "This operation came serialized via transit, it doesn't have an implementation." {}))) 15 | 16 | (def read-handlers 17 | {"pathom3/Resolver" (t/read-handler #(-> % (assoc ::pco/resolve restored-handler) pco/resolver)) 18 | "pathom3/Mutation" (t/read-handler #(-> % (assoc ::pco/mutate restored-handler) pco/mutation))}) 19 | 20 | (def write-handlers 21 | {Resolver (t/write-handler (fn [_] "pathom3/Resolver") pco/operation-config) 22 | Mutation (t/write-handler (fn [_] "pathom3/Mutation") pco/operation-config)}) 23 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/placeholder.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.placeholder 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.pathom3.path :as p.path])) 6 | 7 | (>def ::placeholder-prefixes (s/coll-of string? :kind set?)) 8 | 9 | (>defn placeholder-key? 10 | "Check if a given key is a placeholder." 11 | [{::p.path/keys [placeholder-prefixes]} k] 12 | [(s/keys :opt [::placeholder-prefixes]) any? 13 | => boolean?] 14 | (let [placeholder-prefixes (or placeholder-prefixes #{">"})] 15 | (and (keyword? k) 16 | (contains? placeholder-prefixes (namespace k))))) 17 | 18 | (>defn find-closest-non-placeholder-parent-join-key 19 | "Find the closest parent key that's not a placeholder key." 20 | [{::p.path/keys [path] :as env}] 21 | [(s/keys :opt [::p.path/path]) 22 | => (? ::p.path/path-entry)] 23 | (->> (or path []) rseq (drop 1) (remove #(placeholder-key? env %)) first)) 24 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/path.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.path 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.misc.coll :as coll] 6 | [com.wsscode.pathom3.attribute :as p.attr] 7 | [edn-query-language.core :as eql])) 8 | 9 | (>def ::path-entry 10 | (s/or :attr ::p.attr/attribute 11 | :ident ::eql/ident 12 | :index nat-int? 13 | :call symbol?)) 14 | 15 | (>def ::path (s/nilable (s/coll-of ::path-entry :kind vector?))) 16 | 17 | (>defn append-path 18 | [env path-entry] 19 | [(s/keys :req [::path]) ::path-entry 20 | => map?] 21 | (update env ::path coll/vconj path-entry)) 22 | 23 | (>defn root? 24 | "Check if current path is the root, meaning a blank path." 25 | [{::keys [path]}] 26 | [(s/keys :opt [::path]) => boolean?] 27 | (empty? path)) 28 | 29 | (>defn at-path-string 30 | [{::keys [path]}] 31 | [(s/keys :opt [::path]) => (? string?)] 32 | (if (seq path) 33 | (str " at path " (pr-str path)))) 34 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/operation/transit_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.operation.transit-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [com.wsscode.pathom3.connect.operation :as pco] 5 | [com.wsscode.pathom3.connect.operation.transit :as pcot] 6 | [com.wsscode.transito :as transito])) 7 | 8 | (defn read-transit-str [^String s] 9 | (transito/read-str s {:handlers pcot/read-handlers})) 10 | 11 | (defn write-transit-str [o] 12 | (transito/write-str o {:handlers pcot/write-handlers})) 13 | 14 | (deftest encode-decode-resolvers-test 15 | (is (= (-> (pco/resolver 'r {::pco/output [:a]} (fn [_ _])) 16 | (write-transit-str) 17 | (read-transit-str) 18 | (pco/operation-config)) 19 | '{::pco/input [], 20 | ::pco/provides {:a {}}, 21 | ::pco/output [:a], 22 | ::pco/op-name r, 23 | ::pco/requires {}})) 24 | 25 | (is (= (-> (pco/mutation 'm {::pco/params [:a]} (fn [_ _])) 26 | (write-transit-str) 27 | (read-transit-str) 28 | (pco/operation-config)) 29 | '{::pco/params [:a], 30 | ::pco/op-name m}))) 31 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/runner/stats_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.runner.stats-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.connect.runner :as pcr] 5 | [com.wsscode.pathom3.connect.runner.stats :as pcrs] 6 | [matcher-combinators.test])) 7 | 8 | (deftest resolver-accumulated-duration-test 9 | (is (= (pcrs/resolver-accumulated-duration 10 | {::pcr/node-run-stats 11 | {1 {::pcr/resolver-run-start-ms 0 12 | ::pcr/resolver-run-finish-ms 1} 13 | 2 {::pcr/resolver-run-start-ms 0 14 | ::pcr/resolver-run-finish-ms 10} 15 | 3 {::pcr/resolver-run-start-ms 0 16 | ::pcr/resolver-run-finish-ms 100}}} 17 | {}) 18 | {::pcrs/resolver-accumulated-duration-ms 111}))) 19 | 20 | (deftest overhead-duration-test 21 | (is (= (pcrs/overhead-duration {::pcr/graph-run-duration-ms 100 22 | ::pcrs/resolver-accumulated-duration-ms 90}) 23 | {::pcrs/overhead-duration-ms 10}))) 24 | 25 | (deftest overhead-pct-test 26 | (is (= (pcrs/overhead-pct {::pcr/graph-run-duration-ms 100 27 | ::pcrs/overhead-duration-ms 20}) 28 | {::pcrs/overhead-duration-percentage 0.2}))) 29 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/test/helpers.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.test.helpers 2 | (:require 3 | [clojure.walk :as walk]) 4 | #?(:cljs 5 | (:require-macros 6 | [com.wsscode.pathom3.test.helpers]))) 7 | 8 | (defn spy [{:keys [return]}] 9 | (let [calls (atom [])] 10 | (with-meta 11 | (fn [& args] 12 | (swap! calls conj args) 13 | return) 14 | {:calls calls}))) 15 | 16 | (defn spy-fn [f] 17 | (let [calls (atom [])] 18 | (with-meta 19 | (fn [& args] 20 | (swap! calls conj args) 21 | (apply f args)) 22 | {:calls calls}))) 23 | 24 | (defn match-error [error-msg-regex] 25 | (fn [value] 26 | (re-find error-msg-regex (ex-message value)))) 27 | 28 | (defn expose-meta [x] 29 | (walk/postwalk 30 | (fn [x] 31 | (if (and (map? x) 32 | (seq (meta x))) 33 | (assoc x ::meta (meta x)) 34 | x)) 35 | x)) 36 | 37 | (defn error->data [error] 38 | {:ex/message (ex-message error) 39 | :ex/data (ex-data error)}) 40 | 41 | #?(:clj 42 | (defmacro catch-exception [& body] 43 | (if (:ns &env) 44 | `(try 45 | ~@body 46 | (catch :default e# 47 | (error->data e#))) 48 | `(try 49 | ~@body 50 | (catch Throwable e# 51 | (error->data e#)))))) 52 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/runner/path_selection.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.runner.path-selection 2 | (:require 3 | [clojure.test :refer [deftest]] 4 | [com.wsscode.pathom3.connect.indexes :as pci] 5 | [com.wsscode.pathom3.connect.operation :as pco] 6 | [com.wsscode.pathom3.connect.runner.helpers :as rr :refer [check-all-runners]])) 7 | 8 | (deftest run-graph-input-size-sort 9 | (let [env (pci/register 10 | [(pco/resolver 'resolve-full-name-by-first-name 11 | {::pco/input [:person/first-name] 12 | ::pco/output [:person/full-name]} 13 | (fn [_ {:person/keys [first-name]}] 14 | {:person/full-name first-name})) 15 | 16 | (pco/resolver 'resolve-full-name-with-first-and-last-name 17 | {::pco/input [:person/first-name :person/last-name] 18 | ::pco/output [:person/full-name]} 19 | (fn [_ {:person/keys [first-name last-name]}] 20 | {:person/full-name (str first-name " " last-name)}))])] 21 | (check-all-runners 22 | env 23 | {:person/first-name "Björn", :person/last-name "Ebbinghaus"} 24 | [:person/full-name] 25 | {:person/full-name "Björn Ebbinghaus"}) 26 | 27 | (check-all-runners 28 | env 29 | {:person/first-name "Björn"} 30 | [:person/full-name] 31 | {:person/full-name "Björn"}))) 32 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/placeholder_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.placeholder-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.path :as p.path] 5 | [com.wsscode.pathom3.placeholder :as p.ph])) 6 | 7 | (deftest find-closest-non-placeholder-parent-join-key-test 8 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key {}) 9 | nil)) 10 | 11 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key 12 | {::p.path/path []}) 13 | nil)) 14 | 15 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key 16 | {::p.ph/placeholder-prefixes #{">"} 17 | ::p.path/path [:>/placeholder]}) 18 | nil)) 19 | 20 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key 21 | {::p.ph/placeholder-prefixes #{">"} 22 | ::p.path/path [:parent :>/placeholder]}) 23 | :parent)) 24 | 25 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key 26 | {::p.ph/placeholder-prefixes #{">"} 27 | ::p.path/path [:deeper :parent :>/placeholder]}) 28 | :parent)) 29 | 30 | (is (= (p.ph/find-closest-non-placeholder-parent-join-key 31 | {::p.ph/placeholder-prefixes #{">"} 32 | ::p.path/path [:deeper [:ident "thing"] :>/placeholder :>/other-placeholder]}) 33 | [:ident "thing"]))) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pathom 3 [![Clojars Project](https://img.shields.io/clojars/v/com.wsscode/pathom3.svg)](https://clojars.org/com.wsscode/pathom3) ![Test](https://github.com/wilkerlucio/pathom3/workflows/Test/badge.svg) [![cljdoc badge](https://cljdoc.org/badge/com.wsscode/pathom3)](https://cljdoc.org/d/com.wsscode/pathom3) [![bb compatible](https://raw.githubusercontent.com/babashka/babashka/master/logo/badge.svg)](https://book.babashka.org#badges) 2 | 3 | ![Pathom Logo](repo-resources/pathom-banner-padded.png) 4 | 5 | Logic engine for attribute processing for Clojure and Clojurescript. 6 | 7 | Pathom3 is a redesign of Pathom, but it is a new library and uses different namespaces. 8 | 9 | ## Status 10 | 11 | Alpha. Changes and breakages may occur. Recommended for enthusiasts and people looking to help, chase bugs, and help improve the development of Pathom. 12 | 13 | ## Install 14 | 15 | ```clojure 16 | com.wsscode/pathom3 {:mvn/version "VERSION"} 17 | ``` 18 | 19 | ## Documentation 20 | 21 | https://pathom3.wsscode.com/ 22 | 23 | ## Run Tests 24 | 25 | Pathom 3 uses [Babashka](https://github.com/babashka/babashka) for task scripts, please install it before proceeding. 26 | 27 | ### Clojure 28 | 29 | ```shell script 30 | bb test 31 | ``` 32 | 33 | ### ClojureScript 34 | 35 | To run once 36 | 37 | ```shell script 38 | bb test-cljs-once 39 | ``` 40 | 41 | Or to start shadow watch and test in the browser: 42 | 43 | ```shell script 44 | bb test-cljs 45 | ``` 46 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/entity_tree_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.entity-tree-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.entity-tree :as p.e])) 5 | 6 | (deftest merge-entity-data-test 7 | (is (= (p.e/merge-entity-data 8 | {:foo "bar" :a 1} 9 | {:buz "baz" :a 2 :b 3}) 10 | {:foo "bar", :a 2, :buz "baz", :b 3}))) 11 | 12 | (deftest with-entity-test 13 | (is (= @(::p.e/entity-tree* (p.e/with-entity {} {:foo "bar"})) 14 | {:foo "bar"}))) 15 | 16 | (deftest swap-entity!-test 17 | (let [tree* (atom {})] 18 | (is (= (p.e/swap-entity! {::p.e/entity-tree* tree*} 19 | assoc :foo "bar") 20 | {:foo "bar"} 21 | @tree*))) 22 | 23 | (let [tree* (atom {:a 1})] 24 | (is (= (p.e/swap-entity! {::p.e/entity-tree* tree*} assoc :b 2) 25 | {:a 1, :b 2} 26 | @tree*))) 27 | 28 | (let [tree* (atom {:a 1})] 29 | (is (= (p.e/swap-entity! {::p.e/entity-tree* tree*} assoc :b 2 :c 3 :d 4 :e 5 :f 6) 30 | {:a 1, :b 2 :c 3 :d 4 :e 5 :f 6} 31 | @tree*)))) 32 | 33 | (deftest vswap-entity!-test 34 | (let [tree* (volatile! {})] 35 | (is (= (p.e/vswap-entity! {::p.e/entity-tree* tree*} 36 | assoc :foo "bar") 37 | {:foo "bar"} 38 | @tree*))) 39 | 40 | (let [tree* (volatile! {:a 1})] 41 | (is (= (p.e/vswap-entity! {::p.e/entity-tree* tree*} assoc :b 2) 42 | {:a 1, :b 2} 43 | @tree*)))) 44 | -------------------------------------------------------------------------------- /src/promesa/com/wsscode/promesa/macros.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.promesa.macros 2 | #?@(:bb 3 | [] 4 | :default 5 | [(:require [promesa.core :as p])]) 6 | #?(:cljs 7 | (:require-macros 8 | [com.wsscode.promesa.macros]))) 9 | 10 | #?(:bb 11 | (defmacro clet 12 | "On Babashka this macro just does the same as let." 13 | [bindings & body] 14 | (assert (even? (count bindings))) 15 | `(let ~bindings 16 | ~@body)) 17 | 18 | :clj 19 | (defmacro clet 20 | "This is similar to promesa let. But this only returns a promise if some of 21 | the bindings is a promise. Otherwise returns values as-is. This function is 22 | intended to use in places that you want to be compatible with both sync 23 | and async processes." 24 | [bindings & body] 25 | (assert (even? (count bindings))) 26 | (let [binds (reverse (partition 2 bindings))] 27 | (reduce 28 | (fn [acc [l r]] 29 | `(let [r# ~r] 30 | (if (p/promise? r#) 31 | (p/then r# (fn [~l] ~acc)) 32 | (let [~l r#] 33 | ~acc)))) 34 | `(do ~@body) 35 | binds)))) 36 | 37 | #?(:bb 38 | (defmacro ctry 39 | "On Babashka this macro does the same as try." 40 | [& body] 41 | `(try ~@body)) 42 | 43 | :clj 44 | (defmacro ctry 45 | "This is a helper to enable catching of both sync and async exceptions. 46 | 47 | (ctry 48 | (clet [foo (maybe-async-op)] 49 | (handle-result foo)) 50 | (catch Throwable e 51 | :error))" 52 | [& body] 53 | (let [[_ ex-kind ex-sym & ex-body] (last body) 54 | body (butlast body)] 55 | `(try 56 | (let [res# (do ~@body)] 57 | (if (p/promise? res#) 58 | (p/catch res# (fn [~ex-sym] ~@ex-body)) 59 | res#)) 60 | (catch ~ex-kind ~ex-sym ~@ex-body))))) 61 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:skip-comments true 2 | :lint-as {clojure.test.check.clojure-test/defspec clojure.core/def 3 | clojure.test.check.properties/for-all clojure.core/let 4 | potemkin.collections/def-map-type clojure.core/deftype} 5 | :linters {:unsorted-required-namespaces {:level :off} 6 | :invalid-arity {:level :error} 7 | :missing-else-branch {:level :off} 8 | :unresolved-symbol {:exclude [match? thrown-match?]} 9 | :consistent-alias {:level :warning 10 | :aliases {com.wsscode.async.processing wap 11 | clojure.test.check.generators gen 12 | clojure.test.check.properties prop}} 13 | :unused-namespace {:level :warning 14 | :exclude [com.fulcrologic.guardrails.core 15 | com.wsscode.async.async-clj 16 | com.wsscode.async.async-cljs 17 | promesa.core]} 18 | :unused-referred-var {:level :warning 19 | :exclude {com.wsscode.async.async-clj [let-chan let-chan* 20 | go go-catch go-promise 21 | def >defn >fdef => | <- ?] 26 | clojure.test [deftest is are run-tests testing] 27 | cljs.test [deftest is are run-tests testing]}}}} 28 | -------------------------------------------------------------------------------- /.clj-kondo/marick/midje/marick/midje.clj: -------------------------------------------------------------------------------- 1 | (ns marick.midje 2 | (:require 3 | [clj-kondo.hooks-api :as hooks] 4 | [clojure.string :as string])) 5 | 6 | (def arrows 7 | '#{=> 8 | =not=> 9 | =deny=> 10 | =expands-to=> 11 | =future=> 12 | =contains=> 13 | =streams=> 14 | =throws=> 15 | =test=> 16 | =throw-parse-exception=>}) 17 | 18 | (defn ^:private let-form [body bindings] 19 | (let [new-bindings (vec (reduce (fn [acc i] 20 | (concat acc [i (hooks/token-node 'identity)])) 21 | [] bindings))] 22 | (hooks/list-node 23 | [(hooks/token-node 'let) 24 | (hooks/vector-node new-bindings) 25 | body]))) 26 | 27 | (defn ^:private do-form [forms] 28 | (hooks/list-node 29 | (concat [(hooks/token-node 'do)] 30 | forms))) 31 | 32 | (defn ^:private table-variable? [node] 33 | (let [sexpr (hooks/sexpr node)] 34 | (and (symbol? sexpr) 35 | (string/starts-with? (str sexpr) "?")))) 36 | 37 | (defn ^:private tabular-node [first-bindings bindings body] 38 | (if (hooks/vector-node? first-bindings) 39 | {:node (->> (hooks/sexpr first-bindings) 40 | (map hooks/token-node) 41 | (let-form body))} 42 | {:node (->> bindings 43 | (filter table-variable?) 44 | (let-form body))})) 45 | 46 | (defn ^:private handle-fact-outside-tabular [children arrow] 47 | (let [body (do-form children) 48 | bindings (->> children 49 | (drop-while #(not (= arrow %))) 50 | rest 51 | (drop 1))] 52 | (tabular-node (first bindings) bindings body))) 53 | 54 | (defn fact-tabular [fact vec-bindings bindings] 55 | (let [body (do-form (cons fact bindings))] 56 | (tabular-node vec-bindings (cons vec-bindings bindings) body))) 57 | 58 | (defn ^:private handle-fact-inside-tabular [children] 59 | (if (hooks/string-node? (first children)) 60 | (let [[_name fact vec-bindings & bindings] children] 61 | (fact-tabular fact vec-bindings bindings)) 62 | (let [[fact vec-bindings & bindings] children] 63 | (fact-tabular fact vec-bindings bindings)))) 64 | 65 | (defn tabular [{:keys [node]}] 66 | (let [children (rest (:children node)) 67 | fact-outside (first (filter #(contains? arrows (hooks/sexpr %)) children))] 68 | (if fact-outside 69 | (handle-fact-outside-tabular children fact-outside) 70 | (handle-fact-inside-tabular children)))) 71 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:paths 2 | ["src/tasks"] 3 | 4 | :tasks 5 | {:requires 6 | ([babashka.fs :as fs] 7 | [cheshire.core :as json] 8 | [tasks]) 9 | 10 | :init 11 | (do 12 | (def source-paths ["src" "test"])) 13 | 14 | -lib:artifact 15 | (tasks/artifact-path) 16 | 17 | lib:build 18 | {:depends [-lib:artifact] 19 | :task (clojure (str "-X:jar :jar " -lib:artifact " :version '\"" (tasks/current-version) "\"'"))} 20 | 21 | lib:install 22 | {:depends [lib:build -lib:artifact] 23 | :task (shell (str "mvn install:install-file -Dfile=" -lib:artifact " -DpomFile=pom.xml"))} 24 | 25 | lib:deploy 26 | {:task (if (tasks/released?) 27 | (println "Version" (tasks/current-version) "is already released (tag for it exists). Bump with bb version:bump and try again.") 28 | (do 29 | (run 'test) 30 | (tasks/artifact-build) 31 | (tasks/artifact-deploy) 32 | (tasks/create-tag! (tasks/version-tag)) 33 | (tasks/push-with-tags) 34 | (println "Deployed version" (tasks/current-version) "to Clojars!")))} 35 | 36 | ; region snapshot 37 | 38 | -lib:snapshot-version 39 | (str (tasks/current-version) "-SNAPSHOT") 40 | 41 | -lib:snapshot 42 | {:depends [-lib:snapshot-version] 43 | :task (str "target/pathom3-" -lib:snapshot-version ".jar")} 44 | 45 | lib:snapshot:build 46 | {:depends [-lib:snapshot -lib:snapshot-version] 47 | :task (clojure (str "-X:jar :jar " -lib:snapshot " :version '\"" -lib:snapshot-version "\"'"))} 48 | 49 | lib:snapshot:deploy 50 | {:depends [lib:snapshot:build -lib:snapshot] 51 | :task (clojure (str "-X:deploy :artifact '\"" -lib:snapshot "\"'"))} 52 | 53 | ; endregion 54 | 55 | format-check 56 | {:task (apply tasks/cljstyle "check" source-paths)} 57 | 58 | format-fix 59 | {:task (apply tasks/cljstyle "fix" source-paths)} 60 | 61 | lint 62 | {:task (tasks/clj-kondo-lint source-paths)} 63 | 64 | version:bump 65 | {:doc "Bump version to current date." 66 | :task (let [version (tasks/bump!)] 67 | (shell (str "git commit -m '" (tasks/version-tag) "' -- CHANGELOG.md VERSION")) 68 | (println "Bumped to" version))} 69 | 70 | setup-hooks 71 | tasks/setup-git-hooks 72 | 73 | test 74 | (shell "clojure" "-A:test:test-deps") 75 | 76 | test-gr 77 | (shell "clojure" "-A:test:test-deps" "-J-Dguardrails.enabled") 78 | 79 | test-cljs 80 | (shell "shadow-cljs" "watch" "test" "-A:test-deps:test-cljs") 81 | 82 | test-cljs-once 83 | (do 84 | (shell "shadow-cljs" "compile" "ci") 85 | (shell "karma" "start" "--single-run"))}} 86 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/entity_tree.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.entity-tree 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.misc.refs :as refs])) 6 | 7 | (>def ::entity-tree map?) 8 | (>def ::entity-tree* refs/atom?) 9 | 10 | (>defn entity 11 | "Returns the entity tree value from env" 12 | [{::keys [entity-tree*]}] 13 | [(s/keys :opt [::entity-tree*]) => (? map?)] 14 | (some-> entity-tree* deref)) 15 | 16 | (>defn create-entity [x] 17 | [::entity-tree => ::entity-tree*] 18 | (atom x)) 19 | 20 | (>defn with-entity 21 | "Set the entity in the environment. Note in this function you must send the cache-tree 22 | as a map, not as an atom." 23 | [env entity-tree] 24 | [map? map? => map?] 25 | (assoc env ::entity-tree* (atom entity-tree))) 26 | 27 | (>defn reset-entity! 28 | [{::keys [entity-tree*]} entity-tree] 29 | [(s/keys :req [::entity-tree*]) ::entity-tree => ::entity-tree] 30 | (reset! entity-tree* entity-tree)) 31 | 32 | (>defn swap-entity! 33 | "Swap cache-tree at the current path. Returns the updated whole cache-tree." 34 | ([{::keys [entity-tree*]} f] 35 | [(s/keys :req [::entity-tree*]) fn? 36 | => map?] 37 | (swap! entity-tree* f)) 38 | ([{::keys [entity-tree*]} f x] 39 | [(s/keys :req [::entity-tree*]) fn? any? 40 | => map?] 41 | (swap! entity-tree* f x)) 42 | ([{::keys [entity-tree*]} f x y] 43 | [(s/keys :req [::entity-tree*]) fn? any? any? 44 | => map?] 45 | (swap! entity-tree* f x y)) 46 | ([{::keys [entity-tree*]} f x y & args] 47 | [(s/keys :req [::entity-tree*]) fn? any? any? (s/+ any?) 48 | => map?] 49 | (apply swap! entity-tree* f x y args))) 50 | 51 | (>defn vswap-entity! 52 | "Swap cache-tree at the current path. Returns the updated whole cache-tree." 53 | ([{::keys [entity-tree*]} f] 54 | [(s/keys :req [::entity-tree*]) fn? 55 | => map?] 56 | (vswap! entity-tree* f)) 57 | ([{::keys [entity-tree*]} f x] 58 | [(s/keys :req [::entity-tree*]) fn? any? 59 | => map?] 60 | (vswap! entity-tree* f x)) 61 | ([{::keys [entity-tree*]} f x y] 62 | [(s/keys :req [::entity-tree*]) fn? any? any? 63 | => map?] 64 | (vswap! entity-tree* f x y)) 65 | ([{::keys [entity-tree*]} f x y & args] 66 | [(s/keys :req [::entity-tree*]) fn? any? any? (s/+ any?) 67 | => map?] 68 | (vreset! entity-tree* (apply f @entity-tree* x y args)))) 69 | 70 | (>defn merge-entity-data 71 | "Specialized merge versions that work on entity data." 72 | [entity new-data] 73 | [::entity-tree ::entity-tree => ::entity-tree] 74 | (reduce-kv 75 | assoc 76 | entity 77 | new-data)) 78 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths 2 | ["src/main" "src/promesa" "resources"] 3 | 4 | :deps 5 | {com.fulcrologic/guardrails {:mvn/version "1.1.11"} 6 | org.clojure/core.async {:mvn/version "1.3.610"} 7 | com.wsscode/cljc-misc {:mvn/version "2024.12.18"} 8 | com.wsscode/log {:git/url "https://github.com/wilkerlucio/log" 9 | :sha "325675d8d2871e74e789e836713ea769139e9c61"} 10 | funcool/promesa {:mvn/version "8.0.450"} 11 | edn-query-language/eql {:mvn/version "2021.02.28"} 12 | potemkin/potemkin {:mvn/version "0.4.5"} 13 | com.cognitect/transit-clj {:mvn/version "1.0.324"} 14 | com.cognitect/transit-cljs {:mvn/version "0.8.269"}} 15 | 16 | :aliases 17 | {:provided 18 | {:extra-deps {org.clojure/clojure {:mvn/version "1.11.0-alpha2"} 19 | org.clojure/clojurescript {:mvn/version "1.10.891"} 20 | org.clojure/core.async {:mvn/version "1.5.640"}}} 21 | 22 | :demos 23 | {:extra-paths ["src/demos"] 24 | :extra-deps {meander/epsilon {:mvn/version "0.0.602"} 25 | cheshire/cheshire {:mvn/version "5.11.0"}}} 26 | 27 | :test-deps 28 | {:extra-paths ["test"] 29 | :extra-deps {nubank/matcher-combinators {:mvn/version "3.3.1"} 30 | check/check {:mvn/version "0.2.2-SNAPSHOT"} 31 | meander/epsilon {:mvn/version "0.0.650"} 32 | tortue/spy {:mvn/version "2.9.0"} 33 | org.clojure/test.check {:mvn/version "1.1.0"} 34 | com.wsscode/transito {:git/url "https://github.com/wilkerlucio/transito" 35 | :sha "7119312488e8e8eac0262b86b0c74f6d940de78e"}}} 36 | 37 | :tasks 38 | {:extra-paths ["src/tasks"] 39 | :extra-deps {babashka/babashka {:mvn/version "0.6.5"}}} 40 | 41 | :test 42 | {:extra-paths ["test"] 43 | :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" 44 | :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}} 45 | :main-opts ["-m" "cognitect.test-runner"]} 46 | 47 | :test-cljs 48 | {:extra-paths ["test"] 49 | :extra-deps {thheller/shadow-cljs {:mvn/version "2.16.7"}}} 50 | 51 | :jar 52 | {:replace-deps {com.github.seancorfield/depstar {:mvn/version "2.1.250"}} 53 | :exec-fn hf.depstar/jar 54 | :exec-args {:group-id com.wsscode 55 | :artifact-id pathom3 56 | :sync-pom true}} 57 | 58 | :deploy 59 | {:replace-deps {slipset/deps-deploy {:mvn/version "0.2.2"}} 60 | :exec-fn deps-deploy.deps-deploy/deploy 61 | :exec-args {:installer :remote 62 | :sign-releases? true}}}} 63 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/cache.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.cache 2 | (:require 3 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]]) 4 | #?(:clj 5 | (:import 6 | (clojure.lang 7 | Atom 8 | Volatile)))) 9 | 10 | (defprotocol CacheStore 11 | (-cache-lookup-or-miss [this cache-key f] 12 | "Implement the main functionality of a cache, this receives a cache key and a function 13 | that computes the value in case its uncached. When cache misses, the implementation 14 | should run f to compute the result and cache it. This method should always return a 15 | value (reading from cache or calling f). A cache store can also support async, in such 16 | cases it's ok for the cache store to return a promise.") 17 | (-cache-find [this cache-key] 18 | "Implement a way to read a cache key from the cache. If there is a hit, you must 19 | return a map entry for the result, otherwise return nil. The map-entry can make 20 | the distinction between a miss (nil return) vs a value with a miss (a map-entry with 21 | a value of nil)")) 22 | 23 | (extend-protocol CacheStore 24 | Atom 25 | (-cache-lookup-or-miss [this cache-key f] 26 | (let [cache @this] 27 | (if-let [entry (find cache cache-key)] 28 | (val entry) 29 | (let [res (f)] 30 | (swap! this assoc cache-key res) 31 | res)))) 32 | 33 | (-cache-find [this cache-key] 34 | (find @this cache-key)) 35 | 36 | Volatile 37 | (-cache-lookup-or-miss [this cache-key f] 38 | (let [cache @this] 39 | (if-let [entry (find cache cache-key)] 40 | (val entry) 41 | (let [res (f)] 42 | (vswap! this assoc cache-key res) 43 | res)))) 44 | 45 | (-cache-find [this cache-key] 46 | (find @this cache-key))) 47 | 48 | (defn cache-store? [x] (satisfies? CacheStore x)) 49 | 50 | (>defn cached 51 | "Try to read some value from a cache, otherwise run and cache it. 52 | 53 | cache-container is a keyword for the cache container name, consider that the environment 54 | has multiple cache atoms. If the cache-container key is not present in the env, the 55 | cache will be ignored and will always run f. 56 | 57 | cache-key is how you decide, in that cache container, what key should be used for 58 | this cache try. 59 | 60 | f needs to be a function of zero arguments. 61 | 62 | Example: 63 | 64 | (cached ::my-cache {::my-cache (atom {})} [3 :foo] 65 | (fn [] (run-expensive-operation)))" 66 | [cache-container env cache-key f] 67 | [keyword? map? any? fn? => any?] 68 | (if-let [cache* (get env cache-container)] 69 | (-cache-lookup-or-miss cache* cache-key f) 70 | (f))) 71 | 72 | (>defn cache-find 73 | "Read from cache, without trying to set." 74 | [cache cache-key] 75 | [cache-store? any? => (? map-entry?)] 76 | (-cache-find cache cache-key)) 77 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | jar 5 | com.wsscode 6 | pathom3 7 | 2024.11.23-alpha 8 | pathom3 9 | A library to unify data sources via attribute modeling in a seamless graph. 10 | https://github.com/wilkerlucio/pathom3 11 | 12 | 13 | EPL-2.0 14 | https://github.com/wilkerlucio/pathom3/blob/master/LICENSE 15 | 16 | 17 | 18 | https://github.com/wilkerlucio/pathom3 19 | scm:git:git://github.com/wilkerlucio/pathom3.git 20 | scm:git:ssh://git@github.com/wilkerlucio/pathom3.git 21 | v2024.11.23-alpha 22 | 23 | 24 | 25 | org.clojure 26 | clojure 27 | 1.10.3 28 | 29 | 30 | com.fulcrologic 31 | guardrails 32 | 1.1.11 33 | 34 | 35 | edn-query-language 36 | eql 37 | 2021.02.28 38 | 39 | 40 | com.cognitect 41 | transit-cljs 42 | 0.8.269 43 | 44 | 45 | com.cognitect 46 | transit-clj 47 | 1.0.324 48 | 49 | 50 | funcool 51 | promesa 52 | 8.0.450 53 | 54 | 55 | com.wsscode 56 | cljc-misc 57 | 2022.03.07 58 | 59 | 60 | potemkin 61 | potemkin 62 | 0.4.5 63 | 64 | 65 | org.clojure 66 | core.async 67 | 1.3.610 68 | 69 | 70 | 71 | src/main 72 | 73 | 74 | 75 | clojars 76 | https://repo.clojars.org/ 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push,pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: DeLaGuardo/setup-clj-kondo@master 10 | with: 11 | version: '2022.02.09' 12 | 13 | - uses: DeLaGuardo/setup-clojure@master 14 | with: 15 | tools-deps: '1.10.1.469' 16 | 17 | - uses: actions/checkout@v1 18 | 19 | - name: Cache dependencies lint 20 | run: echo "$(clj-kondo --lint \"$(clojure -Spath -A:provided)\") --parallel" 21 | 22 | - name: Run linter 23 | run: clj-kondo --lint src test 24 | 25 | format: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: DeLaGuardo/setup-clojure@master 29 | with: 30 | tools-deps: '1.10.1.469' 31 | 32 | - name: Setup Babashka 33 | uses: turtlequeue/setup-babashka@v1.2.1 34 | with: 35 | babashka-version: 1.0.168 36 | 37 | - uses: actions/checkout@v1 38 | 39 | # - run: bb run format-check 40 | - run: bb -m tasks/cljstyle "check" "src" "test" 41 | 42 | test-clj: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Check out Git repository 46 | uses: actions/checkout@v1 47 | 48 | - uses: DeLaGuardo/setup-clojure@master 49 | with: 50 | tools-deps: '1.10.1.469' 51 | 52 | # - name: Setup Babashka 53 | # uses: turtlequeue/setup-babashka@v1.2.1 54 | # with: 55 | # babashka-version: 0.3.1 56 | 57 | - name: Cache maven 58 | uses: actions/cache@v4 59 | with: 60 | path: ~/.m2 61 | key: m2-${{ hashFiles('deps.edn') }} 62 | restore-keys: | 63 | m2- 64 | 65 | # - run: bb run test 66 | - run: PATHOM_TEST=true clojure -A:provided:test:test-deps 67 | 68 | test-cljs: 69 | runs-on: ubuntu-latest 70 | steps: 71 | - name: Check out Git repository 72 | uses: actions/checkout@v1 73 | 74 | - uses: DeLaGuardo/setup-clojure@master 75 | with: 76 | tools-deps: '1.10.1.469' 77 | 78 | - name: Cache maven 79 | uses: actions/cache@v4 80 | with: 81 | path: ~/.m2 82 | key: m2-${{ hashFiles('deps.edn') }} 83 | restore-keys: | 84 | m2- 85 | 86 | - name: Cache node_modules 87 | uses: actions/cache@v4 88 | with: 89 | path: node_modules 90 | key: node_modules-${{ hashFiles('package-lock.json') }} 91 | restore-keys: | 92 | node_modules- 93 | 94 | - name: Use Node.js 12.x 95 | uses: actions/setup-node@v1 96 | with: 97 | node-version: '12.x' 98 | 99 | - name: NPM Install 100 | run: npm install 101 | 102 | - name: Compile CLJS tests 103 | run: npx shadow-cljs compile ci 104 | 105 | - name: Run CLJS tests 106 | run: npx karma start --single-run 107 | -------------------------------------------------------------------------------- /.clj-kondo/marick/midje/config.edn: -------------------------------------------------------------------------------- 1 | {:hooks {:analyze-call {midje.sweet/tabular marick.midje/tabular}} 2 | :lint-as {midje.checking.checkers.defining/defchecker clojure.core/defn} 3 | :linters {:unresolved-symbol {:exclude [(midje.sweet/fact 4 | [throws 5 | contains 6 | as-checker 7 | exactly 8 | has 9 | has-sufix 10 | has-prefix 11 | just 12 | one-of 13 | two-of 14 | roughly 15 | truthy 16 | falsey 17 | irrelevant 18 | anything 19 | => 20 | =not=> 21 | =deny=> 22 | =expands-to=> 23 | =future=> 24 | =contains=> 25 | =streams=> 26 | =throws=> 27 | =test=> 28 | =throw-parse-exception=>]) 29 | (midje.sweet/facts 30 | [throws 31 | contains 32 | exactly 33 | has 34 | has-sufix 35 | has-prefix 36 | just 37 | one-of 38 | two-of 39 | roughly 40 | as-checker 41 | truthy 42 | falsey 43 | irrelevant 44 | anything 45 | => 46 | =not=> 47 | =deny=> 48 | =expands-to=> 49 | =future=> 50 | =contains=> 51 | =streams=> 52 | =throws=> 53 | =test=> 54 | =throw-parse-exception=>])]}}} 55 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/runner/helpers.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.runner.helpers 2 | (:require 3 | [check.core :refer [#_ :clj-kondo/ignore => check]] 4 | [clojure.spec.alpha :as s] 5 | [clojure.test :refer [testing]] 6 | [com.wsscode.pathom3.connect.runner :as pcr] 7 | [com.wsscode.pathom3.connect.runner.async :as pcra] 8 | [com.wsscode.pathom3.connect.runner.parallel :as pcrc] 9 | [com.wsscode.pathom3.entity-tree :as p.ent] 10 | [com.wsscode.pathom3.format.eql :as pf.eql] 11 | [edn-query-language.core :as eql] 12 | [matcher-combinators.test] 13 | [promesa.core :as p]) 14 | #?(:cljs 15 | (:require-macros 16 | [com.wsscode.pathom3.connect.runner.helpers]))) 17 | 18 | (defn match-keys? [ks] 19 | (fn [m] 20 | (reduce 21 | (fn [_ k] 22 | (if-let [v (find m k)] 23 | (if (s/valid? k (val v)) 24 | true 25 | (reduced false)) 26 | (reduced false))) 27 | true 28 | ks))) 29 | 30 | (defn run-graph 31 | ([{::keys [map-select?] :as env} tree query] 32 | (let [ast (eql/query->ast query) 33 | res (pcr/run-graph! env ast (p.ent/create-entity tree))] 34 | (if map-select? 35 | (pf.eql/map-select env res query) 36 | res))) 37 | ([env tree query _] (run-graph env tree query))) 38 | 39 | (defn run-graph-async 40 | ([env tree query] 41 | (let [ast (eql/query->ast query)] 42 | (pcra/run-graph! env ast (p.ent/create-entity tree)))) 43 | ([env tree query _expected] 44 | (run-graph-async env tree query))) 45 | 46 | (defn run-graph-parallel [env tree query] 47 | (let [ast (eql/query->ast query)] 48 | (p/timeout 49 | (pcrc/run-graph! env ast (p.ent/create-entity tree)) 50 | 3000))) 51 | 52 | (def all-runners [run-graph #?@(:clj [run-graph-async run-graph-parallel])]) 53 | 54 | #?(:clj 55 | (defmacro check-serial [env entity tx expected] 56 | `(check 57 | (run-graph ~env ~entity ~tx) 58 | ~'=> ~expected)) 59 | 60 | :cljs 61 | (defn check-serial [env entity tx expected] 62 | (check 63 | (run-graph env entity tx) 64 | => expected))) 65 | 66 | #?(:clj 67 | (defmacro check-parallel [env entity tx expected] 68 | `(check 69 | @(run-graph-parallel ~env ~entity ~tx) 70 | ~'=> ~expected)) 71 | 72 | :cljs 73 | (defn check-parallel [env entity tx expected] 74 | (check 75 | @(run-graph-parallel env entity tx) 76 | => expected))) 77 | 78 | #?(:clj 79 | (defmacro check-all-runners [env entity tx expected] 80 | `(doseq [runner# all-runners] 81 | (testing (str runner#) 82 | (check 83 | (let [res# (runner# ~env ~entity ~tx)] 84 | (if (p/promise? res#) 85 | @res# res#)) 86 | ~'=> ~expected)))) 87 | 88 | :cljs 89 | (defn check-all-runners [env entity tx expected] 90 | (doseq [runner all-runners] 91 | (testing (str runner) 92 | (check 93 | (let [res (runner env entity tx)] 94 | (if (p/promise? res) 95 | @res res)) 96 | => expected))))) 97 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/plugin_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.plugin-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.plugin :as p.plugin])) 5 | 6 | (p.plugin/defplugin op-plugin 7 | {::wrap-operation 8 | (fn [op] (fn [x] (conj (op (conj x :e1)) :x1)))}) 9 | 10 | (p.plugin/defplugin op-plugin2 11 | "With some docs" 12 | {::wrap-operation 13 | (fn [op] (fn [x] (conj (op (conj x :e2)) :x2)))}) 14 | 15 | (p.plugin/defplugin op-plugin3 16 | {::wrap-operation 17 | (fn [op] (fn [x] (conj (op (conj x :e3)) :x3)))}) 18 | 19 | (def plugin-env 20 | (p.plugin/register-plugin 21 | op-plugin)) 22 | 23 | (deftest register-plugin-test 24 | (let [plugin (assoc op-plugin ::p.plugin/id 'foo)] 25 | (is (= (p.plugin/register-plugin 26 | plugin) 27 | {:com.wsscode.pathom3.plugin/index-plugins 28 | {'foo plugin}, 29 | :com.wsscode.pathom3.plugin/plugin-order 30 | [{::p.plugin/id 'foo}], 31 | :com.wsscode.pathom3.plugin/plugin-actions 32 | {:com.wsscode.pathom3.plugin-test/wrap-operation 33 | [(::wrap-operation plugin)]}}))) 34 | 35 | (testing "throw error on duplicated name" 36 | (is (thrown-with-msg? 37 | #?(:clj AssertionError :cljs js/Error) 38 | #"Tried to add duplicated plugin: com.wsscode.pathom3.plugin-test/op-plugin" 39 | (p.plugin/register [op-plugin op-plugin])))) 40 | 41 | (testing "throw error on missing id" 42 | (is (thrown-with-msg? 43 | #?(:clj Throwable :cljs js/Error) 44 | #"Invalid plugin, make sure you set the ::p.plugin/id on it." 45 | (p.plugin/register {}))))) 46 | 47 | (deftest run-with-plugins-test 48 | (is (= (p.plugin/run-with-plugins plugin-env 49 | ::unavailable (fn [x] (conj x "S")) ["O"]) 50 | ["O" "S"])) 51 | 52 | (is (= (p.plugin/run-with-plugins plugin-env 53 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 54 | ["O" :e1 "S" :x1])) 55 | 56 | (is (= (p.plugin/run-with-plugins (p.plugin/register plugin-env op-plugin2) 57 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 58 | ["O" :e1 :e2 "S" :x2 :x1])) 59 | 60 | (is (= (p.plugin/run-with-plugins (-> plugin-env 61 | (p.plugin/register [op-plugin2 62 | [op-plugin3]])) 63 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 64 | ["O" :e1 :e2 :e3 "S" :x3 :x2 :x1])) 65 | 66 | (testing "add in order" 67 | (is (= (p.plugin/run-with-plugins (-> plugin-env 68 | (p.plugin/register op-plugin2) 69 | (p.plugin/register-before `op-plugin op-plugin3)) 70 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 71 | ["O" :e3 :e1 :e2 "S" :x2 :x1 :x3])) 72 | 73 | (is (= (p.plugin/run-with-plugins (-> plugin-env 74 | (p.plugin/register op-plugin2) 75 | (p.plugin/register-after `op-plugin op-plugin3)) 76 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 77 | ["O" :e1 :e3 :e2 "S" :x2 :x3 :x1]))) 78 | 79 | (testing "remove plugin" 80 | (is (= (p.plugin/run-with-plugins (-> plugin-env 81 | (p.plugin/register op-plugin2) 82 | (p.plugin/remove-plugin `op-plugin)) 83 | ::wrap-operation (fn [x] (conj x "S")) ["O"]) 84 | ["O" :e2 "S" :x2])))) 85 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/runner/stats.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.runner.stats 2 | (:require 3 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 4 | [com.wsscode.pathom3.attribute :as p.attr] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.indexes :as pci] 7 | [com.wsscode.pathom3.connect.operation :as pco] 8 | [com.wsscode.pathom3.connect.planner :as pcp] 9 | [com.wsscode.pathom3.connect.runner :as pcr])) 10 | 11 | (>def ::node-error-id ::pcp/node-id) 12 | 13 | (>def ::node-error-type #{::node-error-type-direct 14 | ::node-error-type-ancestor}) 15 | 16 | ; region performance 17 | 18 | (defn duration-resolver [attr] 19 | (let [op-name (symbol (str (pbir/attr-munge attr) "-duration")) 20 | start-kw (keyword (namespace attr) (str (name attr) "-start-ms")) 21 | finish-kw (keyword (namespace attr) (str (name attr) "-finish-ms")) 22 | duration-kw (keyword (namespace attr) (str (name attr) "-duration-ms"))] 23 | (pco/resolver op-name 24 | {::pco/input [start-kw finish-kw] 25 | ::pco/output [duration-kw]} 26 | (fn [_ input] 27 | {duration-kw (- (finish-kw input) (start-kw input))})))) 28 | 29 | (pco/defresolver resolver-accumulated-duration 30 | [{::pcr/keys [node-run-stats]} _] 31 | {::resolver-accumulated-duration-ms 32 | (transduce (map #(- (::pcr/resolver-run-finish-ms %) 33 | (::pcr/resolver-run-start-ms %))) + 0 (vals node-run-stats))}) 34 | 35 | (pco/defresolver overhead-duration 36 | [{::pcr/keys [graph-run-duration-ms] 37 | ::keys [resolver-accumulated-duration-ms]}] 38 | {::overhead-duration-ms 39 | (- graph-run-duration-ms resolver-accumulated-duration-ms)}) 40 | 41 | (pco/defresolver overhead-pct 42 | [{::pcr/keys [graph-run-duration-ms] 43 | ::keys [overhead-duration-ms]}] 44 | {::overhead-duration-percentage 45 | (double (/ overhead-duration-ms graph-run-duration-ms))}) 46 | 47 | ; endregion 48 | 49 | (def stats-registry 50 | [resolver-accumulated-duration 51 | overhead-duration 52 | overhead-pct 53 | (pbir/alias-resolver ::pcr/compute-plan-run-start-ms ::pcr/process-run-start-ms) 54 | (pbir/alias-resolver ::pcr/graph-run-finish-ms ::pcr/process-run-finish-ms) 55 | (duration-resolver ::pcr/process-run) 56 | (duration-resolver ::pcr/node-run) 57 | (duration-resolver ::pcr/resolver-run) 58 | (duration-resolver ::pcr/batch-run) 59 | (duration-resolver ::pcr/graph-run) 60 | (duration-resolver ::pcr/compute-plan-run) 61 | (duration-resolver ::pcr/mutation-run) 62 | (pbir/single-attr-with-env-resolver ::p.attr/attribute ::pcp/node-id 63 | #(get (::pcp/index-attrs %) %2 ::pco/unknown-value)) 64 | (pbir/env-table-resolver ::pcp/nodes ::pcp/node-id 65 | [::pco/op-name 66 | ::pcp/expects 67 | ::pcp/input 68 | ::pcp/run-and 69 | ::pcp/run-or 70 | ::pcp/run-next 71 | ::pcp/foreign-ast 72 | ::pcp/source-for-attrs 73 | ::pcp/node-parents]) 74 | (pbir/env-table-resolver ::pcr/node-run-stats ::pcp/node-id 75 | [::pcr/resolver-run-start-ms 76 | ::pcr/resolver-run-finish-ms 77 | ::pcr/batch-run-start-ms 78 | ::pcr/batch-run-finish-ms 79 | ::pcp/nested-process 80 | ::pcr/node-run-start-ms 81 | ::pcr/node-run-finish-ms 82 | ::pcr/node-resolver-input 83 | ::pcr/node-resolver-input-shape 84 | ::pcr/node-resolver-output 85 | ::pcr/node-resolver-output-shape 86 | ::pcr/node-error 87 | ::pcr/taken-paths 88 | ::pcr/success-path])]) 89 | 90 | (def stats-index (pci/register stats-registry)) 91 | 92 | (defn run-stats-env [stats] 93 | (-> stats 94 | (pci/register stats-index))) 95 | -------------------------------------------------------------------------------- /.clj-kondo/com.fulcrologic/guardrails/com/fulcrologic/guardrails/clj_kondo_hooks.clj: -------------------------------------------------------------------------------- 1 | (ns com.fulcrologic.guardrails.clj-kondo-hooks 2 | (:require 3 | [clj-kondo.hooks-api :as api])) 4 | 5 | (def =>? #{'=> :ret}) 6 | (def |? #{'| :st}) 7 | (def known-sym? #{'=> '| '<-}) 8 | 9 | (defn args+gspec+body [nodes] 10 | (let [argv (first nodes) 11 | gspec (second nodes) 12 | body (nnext nodes) 13 | gspec' (->> gspec 14 | (:children) 15 | (filterv #(-> % :value known-sym? not)) 16 | (api/vector-node)) 17 | new-nodes (list* argv gspec' body)] 18 | ;; gspec: [arg-specs* (| arg-preds+)? => ret-spec (| fn-preds+)? (<- generator-fn)?] 19 | (if (not= 1 (count (filter =>? (api/sexpr gspec)))) 20 | (api/reg-finding! (merge (meta gspec) 21 | {:message (str "Gspec requires exactly one `=>` or `:ret`") 22 | :type :clj-kondo.fulcro.>defn/invalid-gspec})) 23 | (let [p (partition-by (comp not =>? api/sexpr) (:children gspec)) 24 | [arg [=>] [ret-spec & _output]] (if (-> p ffirst api/sexpr =>?) 25 | (cons [] p) ; arg-specs might be empty 26 | p) 27 | [arg-specs [| & arg-preds]] (split-with (comp not |? api/sexpr) arg)] 28 | 29 | (when-not ret-spec 30 | (println =>) 31 | (api/reg-finding! (merge (meta =>) 32 | {:message "Missing return spec." 33 | :type :clj-kondo.fulcro.>defn/invalid-gspec}))) 34 | 35 | ;; (| arg-preds+)? 36 | (when (and | (empty? arg-preds)) 37 | (api/reg-finding! (merge (meta |) 38 | {:message "Missing argument predicates after |." 39 | :type :clj-kondo.fulcro.>defn/invalid-gspec}))) 40 | 41 | 42 | (let [len-argv (count (remove #{'&} (api/sexpr argv))) ; [a & more] => 2 arguments 43 | arg-difference (- (count arg-specs) len-argv)] 44 | (when (not (zero? arg-difference)) 45 | (let [too-many-specs? (pos? arg-difference)] 46 | (api/reg-finding! (merge 47 | (meta (if too-many-specs? 48 | (nth arg-specs (+ len-argv arg-difference -1)) ; first excess spec 49 | gspec)) ; The gspec is wrong, not the surplus argument. 50 | {:message (str "Guardrail spec does not match function signature. " 51 | "Too " (if too-many-specs? "many" "few") " specs.") 52 | :type :clj-kondo.fulcro.>defn/invalid-gspec}))))))) 53 | new-nodes)) 54 | 55 | (defn >defn 56 | [{:keys [node]}] 57 | (let [args (rest (:children node)) 58 | fn-name (first args) 59 | ?docstring (when (some-> (second args) api/sexpr string?) 60 | (second args)) 61 | args (if ?docstring 62 | (nnext args) 63 | (next args)) 64 | post-docs (if (every? #(-> % api/sexpr list?) args) 65 | (mapv #(-> % :children args+gspec+body api/list-node) args) 66 | (args+gspec+body args)) 67 | post-name (if ?docstring 68 | (list* ?docstring post-docs) 69 | post-docs) 70 | new-node (api/list-node 71 | (list* 72 | (api/token-node 'defn) 73 | fn-name 74 | post-name))] 75 | {:node new-node})) 76 | -------------------------------------------------------------------------------- /.cljstyle: -------------------------------------------------------------------------------- 1 | {:max-blank-lines 2 | 1 3 | :rules 4 | {:indentation 5 | {:indents 6 | {.then [[:inner 0]] 7 | >def [[:inner 0]] 8 | >defn [[:inner 0]] 9 | >defn- [[:inner 0]] 10 | add-watch [[:inner 0]] 11 | apply [[:inner 0]] 12 | as-> [[:inner 0]] 13 | assert [[:inner 0]] 14 | assoc [[:inner 0]] 15 | assoc-in [[:inner 0]] 16 | async/pipeline-async [[:inner 0]] 17 | async/put! [[:inner 0]] 18 | check-all-runners [[:inner 0]] 19 | clet [[:inner 0]] 20 | compute-attribute-graph [[:inner 0]] 21 | compute-root-branch [[:inner 0]] 22 | compute-run-graph* [[:inner 0]] 23 | cond-> [[:inner 0]] 24 | conj [[:inner 0]] 25 | defrecord [[:inner 0]] 26 | deftype [[:inner 0]] 27 | every? [[:inner 0]] 28 | fp/defsc [[:inner 0]] 29 | graph-response? [[:inner 0]] 30 | let-chan [[:inner 0]] 31 | let-chan* [[:inner 0]] 32 | mapv [[:inner 0]] 33 | merge-indexes [[:inner 0]] 34 | merge-mutation-stats! [[:inner 0]] 35 | merge-node-stats! [[:inner 0]] 36 | p.cache/cached [[:inner 0]] 37 | p.ent/swap-entity! [[:inner 0]] 38 | p.plugin/defplugin [[:inner 0]] 39 | p.plugin/run-with-plugins [[:inner 0]] 40 | p/cached-async [[:inner 0]] 41 | pbir/env-table-resolver [[:inner 0]] 42 | pbir/single-attr-with-env-resolver [[:inner 0]] 43 | pbir/static-attribute-map-resolver [[:inner 0]] 44 | pc/defmutation [[:inner 0]] 45 | pc/defresolver [[:inner 0]] 46 | pc/mutation [[:inner 0]] 47 | pc/resolver [[:inner 0]] 48 | pcg/parser-item [[:inner 0]] 49 | pco/defresolver [[:inner 0]] 50 | pco/mutation [[:inner 0]] 51 | pco/resolver [[:inner 0]] 52 | pcr/all-requires-ready? [[:inner 0]] 53 | pcr/merge-mutation-stats! [[:inner 0]] 54 | pcr/merge-node-stats! [[:inner 0]] 55 | prop/for-all [[:inner 0]] 56 | psm/smart-map [[:inner 0]] 57 | pt/trace-leave [[:inner 0]] 58 | pt/tracing [[:inner 0]] 59 | recur [[:inner 0]] 60 | reduce [[:inner 0]] 61 | s/and [[:inner 0]] 62 | s/conform [[:inner 0]] 63 | s/fdef [[:inner 0]] 64 | s/fspec [[:inner 0]] 65 | s/spec [[:inner 0]] 66 | smart-map [[:inner 0]] 67 | some [[:inner 0]] 68 | swap! [[:inner 0]] 69 | update [[:inner 0]] 70 | update-in [[:inner 0]]}} 71 | 72 | :comments 73 | {:enabled? false} 74 | 75 | :blank-lines 76 | {:padding-lines 1} 77 | 78 | :functions 79 | {:enabled? false} 80 | 81 | :types 82 | {:enabled? false}}} 83 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/test/geometry_resolvers.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.test.geometry-resolvers 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.operation :as pco])) 7 | 8 | (>def ::position-unit number?) 9 | (>def ::left ::position-unit) 10 | (>def ::right ::position-unit) 11 | (>def ::top ::position-unit) 12 | (>def ::bottom ::position-unit) 13 | (>def ::x ::left) 14 | (>def ::y ::top) 15 | (>def ::width ::position-unit) 16 | (>def ::half-width ::width) 17 | (>def ::height ::position-unit) 18 | (>def ::half-height ::height) 19 | (>def ::center-x ::position-unit) 20 | (>def ::center-y ::position-unit) 21 | (>def ::diagonal ::position-unit) 22 | (>def ::point (s/keys :req [(or (and ::left ::top) (and ::right ::bottom))])) 23 | (>def ::midpoint ::point) 24 | (>def ::turn-point ::point) 25 | 26 | (>defn sqrt [n] 27 | [number? => number?] 28 | #?(:clj (Math/sqrt n) 29 | :cljs (js/Math.sqrt n))) 30 | 31 | (pco/defresolver left [{::keys [right width]}] 32 | {::left (- right width)}) 33 | 34 | (pco/defresolver right [{::keys [left width]}] 35 | {::right (+ left width)}) 36 | 37 | (pco/defresolver top [{::keys [bottom height]}] 38 | {::top (- bottom height)}) 39 | 40 | (pco/defresolver bottom [{::keys [top height]}] 41 | {::bottom (+ top height)}) 42 | 43 | (pco/defresolver width [{::keys [left right]}] 44 | {::width (- right left)}) 45 | 46 | (pco/defresolver height [{::keys [top bottom]}] 47 | {::height (- bottom top)}) 48 | 49 | (pco/defresolver half-width [{::keys [width]}] 50 | {::half-width (/ width 2)}) 51 | 52 | (pco/defresolver half-width-rev [{::keys [half-width]}] 53 | {::width (* half-width 2)}) 54 | 55 | (pco/defresolver half-height [{::keys [height]}] 56 | {::half-height (/ height 2)}) 57 | 58 | (pco/defresolver center-x [{::keys [left half-width]}] 59 | {::center-x (+ left half-width)}) 60 | 61 | (pco/defresolver center-y [{::keys [top half-height]}] 62 | {::center-y (+ top half-height)}) 63 | 64 | (pco/defresolver midpoint [{::keys [center-x center-y]}] 65 | {::midpoint {::left center-x 66 | ::top center-y}}) 67 | 68 | (pco/defresolver turn-point [{::keys [left top]}] 69 | {::turn-point {::right left 70 | ::bottom top}}) 71 | 72 | (pco/defresolver turn-point2 [{::keys [right bottom]}] 73 | {::turn-point {::left right 74 | ::top bottom}}) 75 | 76 | (pco/defresolver diagonal [{::keys [width height]}] 77 | {::diagonal (sqrt (+ (* width width) (* height height)))}) 78 | 79 | (def registry 80 | [left 81 | right 82 | top 83 | bottom 84 | width 85 | height 86 | half-width 87 | half-width-rev 88 | half-height 89 | center-x 90 | center-y 91 | midpoint 92 | turn-point 93 | turn-point2 94 | diagonal 95 | (pbir/equivalence-resolver ::x ::left) 96 | (pbir/equivalence-resolver ::y ::top)]) 97 | 98 | (def geo->svg-registry 99 | [(pbir/equivalence-resolver :left ::left) 100 | (pbir/equivalence-resolver :right ::right) 101 | (pbir/equivalence-resolver :top ::top) 102 | (pbir/equivalence-resolver :bottom ::bottom) 103 | (pbir/equivalence-resolver :x ::x) 104 | (pbir/equivalence-resolver :y ::y) 105 | (pbir/equivalence-resolver :width ::width) 106 | (pbir/equivalence-resolver :height ::height) 107 | (pbir/equivalence-resolver :cx ::center-x) 108 | (pbir/equivalence-resolver :cy ::center-y) 109 | (pbir/equivalence-resolver :rx ::half-width) 110 | (pbir/equivalence-resolver :ry ::half-height) 111 | (pbir/alias-resolver :rx :r) 112 | (pbir/alias-resolver :ry :r)]) 113 | 114 | (def full-registry [registry geo->svg-registry]) 115 | 116 | (comment 117 | (map (comp ::pco/op-name pco/operation-config) (flatten geo->svg-registry)) 118 | 119 | (pbir/alias-resolver :foo/id :foo-other/id)) 120 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/foreign.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.foreign 2 | (:require 3 | [com.wsscode.misc.coll :as coll] 4 | [com.wsscode.pathom3.connect.indexes :as pci] 5 | [com.wsscode.pathom3.connect.operation :as pco] 6 | [com.wsscode.pathom3.connect.planner :as pcp] 7 | [com.wsscode.pathom3.connect.runner :as pcr] 8 | [com.wsscode.promesa.macros :refer [clet]])) 9 | 10 | (def index-query 11 | [::pci/indexes]) 12 | 13 | (defn foreign-indexed-key [i] 14 | (keyword "com.wsscode.pathom3.connect.foreign" (str "foreign-" i))) 15 | 16 | (defn compute-foreign-request 17 | [inputs] 18 | (let [ph-requests (into [] 19 | (map-indexed 20 | (fn [i {::pcp/keys [foreign-ast]}] 21 | (let [k (foreign-indexed-key i)] 22 | {:type :join 23 | :key k 24 | :dispatch-key k 25 | :children (:children foreign-ast)}))) 26 | inputs) 27 | entity (into {} 28 | (map-indexed 29 | (fn [i {::pcr/keys [node-resolver-input]}] 30 | (let [k (foreign-indexed-key i)] 31 | (coll/make-map-entry k (or node-resolver-input {}))))) 32 | inputs) 33 | ast {:type :root 34 | :children ph-requests}] 35 | {:pathom/ast ast 36 | :pathom/entity entity})) 37 | 38 | (defn compute-foreign-mutation 39 | [{::pcp/keys [node]}] 40 | {:pathom/ast (::pcp/foreign-ast node)}) 41 | 42 | (defn call-foreign-mutation [foreign {::pcp/keys [foreign-ast]}] 43 | (foreign {:pathom/ast foreign-ast})) 44 | 45 | (defn call-foreign-query [foreign inputs] 46 | (clet [foreign-call (compute-foreign-request inputs) 47 | result (foreign foreign-call)] 48 | (into 49 | [] 50 | (map #(get result (foreign-indexed-key %))) 51 | (range (count inputs))))) 52 | 53 | (defn call-foreign [foreign inputs] 54 | (if (-> inputs first ::pcp/foreign-ast :children first :type (= :call)) 55 | (call-foreign-mutation foreign (first inputs)) 56 | (call-foreign-query foreign inputs))) 57 | 58 | (pco/defresolver foreign-indexes-resolver [env _] 59 | {::pci/indexes 60 | (select-keys env 61 | [::pci/index-attributes 62 | ::pci/index-oir 63 | ::pci/index-io 64 | ::pci/index-resolvers 65 | ::pci/index-mutations 66 | ::pci/transient-attrs 67 | ::pci/index-source-id])}) 68 | 69 | (defn remove-foreign-indexes [indexes] 70 | (-> indexes 71 | (update ::pci/index-resolvers dissoc `foreign-indexes-resolver) 72 | (update ::pci/index-mutations dissoc 'com.wsscode.pathom.viz.ws-connector.pathom3/request-snapshots) 73 | (update ::pci/index-attributes dissoc ::pci/indexes) 74 | (update ::pci/index-oir dissoc ::pci/indexes) 75 | (update-in [::pci/index-io #{}] dissoc ::pci/indexes))) 76 | 77 | (defn internalize-foreign-indexes 78 | "Introduce a new dynamic resolver and make all the resolvers in the index point to 79 | it." 80 | [{::pci/keys [index-source-id] :as indexes} foreign] 81 | (let [index-source-id (or index-source-id (gensym "foreign-pathom-"))] 82 | (-> indexes 83 | (remove-foreign-indexes) 84 | (update ::pci/index-mutations 85 | (fn [mutations] 86 | (coll/map-vals 87 | #(pco/update-config % assoc ::pco/dynamic-name index-source-id) 88 | mutations))) 89 | (update ::pci/index-resolvers 90 | (fn [resolvers] 91 | (coll/map-vals 92 | #(pco/update-config % assoc ::pco/dynamic-name index-source-id) 93 | resolvers))) 94 | (assoc-in [::pci/index-resolvers index-source-id] 95 | (pco/resolver index-source-id 96 | {::pco/cache? false 97 | ::pco/batch? true 98 | ::pco/dynamic-resolver? true} 99 | (fn [_env inputs] 100 | (call-foreign foreign inputs)))) 101 | (dissoc ::pci/index-source-id) 102 | (assoc-in [::foreign-indexes index-source-id] indexes)))) 103 | 104 | (defn foreign-register 105 | "Load foreign indexes and incorporate it as an external data source. This will make 106 | every resolver from the remote to point to a single one, enabling data delegation 107 | to the foreign node. 108 | 109 | The return of this function is the indexes, you can use pci/register to add them 110 | into your environment." 111 | [foreign] 112 | (clet [{::pci/keys [indexes]} (foreign index-query)] 113 | (internalize-foreign-indexes indexes foreign))) 114 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/error.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.error 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.misc.coll :as coll] 6 | [com.wsscode.pathom3.attribute :as p.attr] 7 | [com.wsscode.pathom3.connect.planner :as pcp] 8 | [com.wsscode.pathom3.plugin :as p.plugin] 9 | #?(:cljs [goog.object :as gobj])) 10 | #?(:clj 11 | (:import 12 | (java.io 13 | PrintWriter 14 | StringWriter)))) 15 | 16 | (>def ::phase keyword?) 17 | 18 | (>def ::cause 19 | (s/and keyword? 20 | #{::attribute-unreachable 21 | ::attribute-not-requested 22 | ::attribute-missing 23 | 24 | ::ancestor-error 25 | 26 | ::node-errors 27 | ::node-exception 28 | 29 | ::plugin-missing-id})) 30 | 31 | (>def ::lenient-mode? boolean?) 32 | 33 | (defn- optional? [index-ast attribute] 34 | (get-in index-ast [attribute :params :com.wsscode.pathom3.connect.operation/optional?])) 35 | 36 | (defn attribute-node-error 37 | [{:com.wsscode.pathom3.connect.runner/keys [node-run-stats] 38 | ::p.attr/keys [attribute] 39 | ::pcp/keys [index-ast] 40 | :as graph} node-id] 41 | (let [{:com.wsscode.pathom3.connect.runner/keys [node-error node-run-finish-ms]} (get node-run-stats node-id)] 42 | (cond 43 | node-error 44 | (coll/make-map-entry 45 | node-id 46 | {::cause ::node-exception 47 | ::exception node-error}) 48 | 49 | node-run-finish-ms 50 | (if-not (optional? index-ast attribute) 51 | (coll/make-map-entry 52 | node-id 53 | {::cause ::attribute-missing})) 54 | 55 | :else 56 | (if-let [[node-id' error] (->> (pcp/node-ancestors graph node-id) 57 | (keep (fn [node-id] 58 | (if-let [error (get-in node-run-stats [node-id :com.wsscode.pathom3.connect.runner/node-error])] 59 | [node-id error]))) 60 | first)] 61 | (coll/make-map-entry 62 | node-id 63 | {::cause ::ancestor-error 64 | ::error-ancestor-id node-id' 65 | ::exception error}))))) 66 | 67 | (defn attribute-error 68 | "Return the attribute error, in case it failed." 69 | [response attribute] 70 | (if (contains? response attribute) 71 | nil 72 | (let [{:com.wsscode.pathom3.connect.planner/keys [index-ast index-attrs] :as run-stats} 73 | (-> response meta :com.wsscode.pathom3.connect.runner/run-stats)] 74 | (if (contains? index-ast attribute) 75 | (if-let [nodes (get index-attrs attribute)] 76 | (let [run-stats (assoc run-stats ::p.attr/attribute attribute) 77 | errors (into {} (keep #(attribute-node-error run-stats %)) nodes)] 78 | (if (seq errors) 79 | {::cause ::node-errors 80 | ::node-error-details errors})) 81 | (if-not (optional? index-ast attribute) 82 | {::cause ::attribute-unreachable})) 83 | {::cause ::attribute-not-requested})))) 84 | 85 | (defn scan-for-errors? [response] 86 | ; some node error? 87 | ; something unreachable? 88 | 89 | ; is there a way to know if there wasn't any error without checking each attribute? 90 | 91 | ; check if map has meta 92 | (some-> response meta (contains? :com.wsscode.pathom3.connect.runner/run-stats))) 93 | 94 | (defn process-entity-errors [env entity] 95 | (if (scan-for-errors? entity) 96 | (let [ast (-> entity meta 97 | :com.wsscode.pathom3.connect.runner/run-stats 98 | :com.wsscode.pathom3.connect.planner/index-ast) 99 | errors (into {} 100 | (keep (fn [k] 101 | (if-let [error (p.plugin/run-with-plugins env ::wrap-attribute-error 102 | attribute-error entity k)] 103 | (coll/make-map-entry k error)))) 104 | (keys ast))] 105 | (cond-> entity 106 | (seq errors) 107 | (assoc :com.wsscode.pathom3.connect.runner/attribute-errors errors))) 108 | entity)) 109 | 110 | #?(:clj 111 | (defn error-stack [^Throwable err] 112 | (let [sw (StringWriter.) 113 | pw (PrintWriter. sw)] 114 | (.printStackTrace err pw) 115 | (.toString sw))) 116 | 117 | :cljs 118 | (defn error-stack [err] 119 | (gobj/get err "stack"))) 120 | 121 | (defn datafy-processor-error [^Throwable err] 122 | {::error-message (ex-message err) 123 | ::error-data (ex-data err) 124 | ::error-stack (error-stack err)}) 125 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/error_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.error-test 2 | (:require 3 | [check.core :refer [check =>]] 4 | [clojure.test :refer [deftest is are run-tests testing]] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.indexes :as pci] 7 | [com.wsscode.pathom3.connect.operation :as pco] 8 | [com.wsscode.pathom3.error :as p.error] 9 | [com.wsscode.pathom3.interface.eql :as p.eql] 10 | [matcher-combinators.standalone :as mcs] 11 | [matcher-combinators.test])) 12 | 13 | (defn match-error [msg] 14 | #(-> % ex-message (= msg))) 15 | 16 | (deftest test-attribute-error 17 | (testing "success" 18 | (is (= (p.error/attribute-error {:foo "value"} :foo) 19 | nil))) 20 | 21 | (testing "attribute not requested" 22 | (is (= (p.error/attribute-error {} :foo) 23 | {::p.error/cause ::p.error/attribute-not-requested}))) 24 | 25 | (testing "unreachable from plan" 26 | (is (= (let [data (p.eql/process 27 | (pci/register 28 | {::p.error/lenient-mode? true} 29 | (pbir/single-attr-resolver :a :b str)) 30 | [:b])] 31 | (p.error/attribute-error data :b)) 32 | {::p.error/cause ::p.error/attribute-unreachable}))) 33 | 34 | (testing "direct node error" 35 | (is (mcs/match? 36 | {::p.error/cause ::p.error/node-errors 37 | ::p.error/node-error-details {1 {::p.error/cause ::p.error/node-exception 38 | ::p.error/exception {:com.wsscode.pathom3.error/error-message "Error"}}}} 39 | (let [data (p.eql/process 40 | (pci/register 41 | {::p.error/lenient-mode? true} 42 | (pbir/constantly-fn-resolver :a (fn [_] (throw (ex-info "Error" {}))))) 43 | [:a])] 44 | (p.error/attribute-error data :a))))) 45 | 46 | (testing "attribute missing on output" 47 | (is (= (let [data (p.eql/process 48 | (pci/register 49 | {::p.error/lenient-mode? true} 50 | (pco/resolver 'a 51 | {::pco/output [:a]} 52 | (fn [_ _] {}))) 53 | [:a])] 54 | (p.error/attribute-error data :a)) 55 | {::p.error/cause ::p.error/node-errors 56 | ::p.error/node-error-details {1 {::p.error/cause ::p.error/attribute-missing}}})) 57 | 58 | (testing "not a problem if attribute is optional" 59 | (is (= (let [data (p.eql/process 60 | (pci/register 61 | {::p.error/lenient-mode? true} 62 | (pco/resolver 'a 63 | {::pco/output [:a]} 64 | (fn [_ _] {}))) 65 | [(pco/? :a)])] 66 | (p.error/attribute-error data :a)) 67 | nil)))) 68 | 69 | (testing "ancestor error" 70 | (is (mcs/match? 71 | {::p.error/cause ::p.error/node-errors 72 | ::p.error/node-error-details {1 {::p.error/cause ::p.error/ancestor-error 73 | ::p.error/error-ancestor-id 2 74 | ::p.error/exception {:com.wsscode.pathom3.error/error-message "Error"}}}} 75 | (let [data (p.eql/process 76 | (pci/register 77 | {::p.error/lenient-mode? true} 78 | [(pbir/constantly-fn-resolver :a (fn [_] (throw (ex-info "Error" {})))) 79 | (pbir/single-attr-resolver :a :b str)]) 80 | [:b])] 81 | (p.error/attribute-error data :b))))) 82 | 83 | (testing "ancestor error missing" 84 | (check 85 | (=> {::p.error/cause ::p.error/node-errors 86 | ::p.error/node-error-details {1 {::p.error/cause ::p.error/attribute-missing}}} 87 | (let [data (p.eql/process 88 | (pci/register 89 | {::p.error/lenient-mode? true} 90 | [(pco/resolver 'a 91 | {::pco/output [:a]} 92 | (fn [_ _] {})) 93 | (pbir/single-attr-resolver :a :b str)]) 94 | [:b])] 95 | (p.error/attribute-error data :b))))) 96 | 97 | (testing "multiple errors" 98 | (is (mcs/match? 99 | {} 100 | (let [response (p.eql/process 101 | (pci/register 102 | {::p.error/lenient-mode? true} 103 | [(pco/resolver 'err1 104 | {::pco/output [:error-demo]} 105 | (fn [_ _] (throw (ex-info "One Error" {})))) 106 | (pco/resolver 'err2 107 | {::pco/output [:error-demo]} 108 | (fn [_ _] (throw (ex-info "Other Error" {}))))]) 109 | [:error-demo])] 110 | (p.error/attribute-error response :error-demo)))))) 111 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/built_in/resolvers_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.built-in.resolvers-test 2 | (:require 3 | [clojure.test :refer [deftest is are run-tests testing]] 4 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 5 | [com.wsscode.pathom3.connect.indexes :as pci] 6 | [com.wsscode.pathom3.connect.operation :as pco] 7 | [com.wsscode.pathom3.interface.smart-map :as psm])) 8 | 9 | (deftest alias-resolver-test 10 | (is (= ((pbir/alias-resolver :foo :bar) {} {:foo 3}) 11 | {:bar 3}))) 12 | 13 | (deftest constantly-resolver-test 14 | (is (= ((pbir/constantly-resolver :foo "bar")) 15 | {:foo "bar"})) 16 | 17 | (testing "output inference" 18 | (is (= (-> (pco/operation-config (pbir/constantly-resolver :foo {:bar "baz"})) 19 | ::pco/output) 20 | [{:foo [:bar]}])) 21 | 22 | (is (= (-> (pco/operation-config (pbir/constantly-resolver :foo [{:bar "baz"} 23 | {:other "ble"}])) 24 | ::pco/output) 25 | [{:foo [:bar :other]}])))) 26 | 27 | (deftest constantly-fn-resolver-test 28 | (is (= ((pbir/constantly-fn-resolver :foo (fn [_] "bar"))) 29 | {:foo "bar"}))) 30 | 31 | (deftest single-attr-resolver-test 32 | (is (= ((pbir/single-attr-resolver :n :x inc) {:n 10}) 33 | {:x 11}))) 34 | 35 | (deftest single-attr-with-env-resolver-test 36 | (is (= ((pbir/single-attr-with-env-resolver :n :x #(+ (:add %1) %2)) 37 | {:add 5} 38 | {:n 10}) 39 | {:x 15}))) 40 | 41 | (deftest map-table-resolver-test 42 | (let [resolver (pbir/static-table-resolver ::id {1 {::color "Gray"} 43 | 2 {::color "Purple"}}) 44 | config (pco/operation-config resolver)] 45 | (is (= (resolver {::id 2}) 46 | {::color "Purple"})) 47 | (is (= (resolver {::id 3}) nil)) 48 | (is (= (::pco/input config) 49 | [::id])) 50 | (is (= (::pco/output config) 51 | [::color])))) 52 | 53 | (deftest attribute-map-resolver-test 54 | (let [resolver (pbir/static-attribute-map-resolver ::id ::color 55 | {1 "Gray" 56 | 2 "Purple"}) 57 | config (pco/operation-config resolver)] 58 | (is (= (resolver {::id 2}) 59 | {::color "Purple"})) 60 | (is (= (resolver {::id 3}) nil)) 61 | (is (= (::pco/input config) 62 | [::id])) 63 | (is (= (::pco/output config) 64 | [::color])))) 65 | 66 | (deftest attribute-table-resolver-test 67 | (let [resolver (pbir/attribute-table-resolver ::colors ::id [::color]) 68 | config (pco/operation-config resolver)] 69 | (is (= (resolver {::colors {1 {::color "Gray"} 70 | 2 {::color "Purple"}} 71 | ::id 2}) 72 | {::color "Purple"})) 73 | (is (= (::pco/input config) 74 | [::id ::colors])) 75 | (is (= (::pco/output config) 76 | [::color])))) 77 | 78 | (deftest env-table-resolver-test 79 | (let [resolver (pbir/env-table-resolver ::colors ::id [::color]) 80 | config (pco/operation-config resolver)] 81 | (is (= (resolver 82 | {::colors {1 {::color "Gray"} 83 | 2 {::color "Purple"}}} 84 | {::id 2}) 85 | {::color "Purple"})) 86 | (is (= (::pco/input config) 87 | [::id])) 88 | (is (= (::pco/output config) 89 | [::color])))) 90 | 91 | (deftest edn-file-resolver-test 92 | (let [[resolver :as resolvers] (pbir/edn-file-resolver "test/resources/sample-config.edn")] 93 | (is (= (::pco/output (pco/operation-config resolver)) 94 | [:my.system/generic-db :my.system/initial-path :my.system/port])) 95 | 96 | (is (= (resolver {} {}) 97 | #:my.system{:initial-path "/tmp/system" 98 | :port 1234 99 | :generic-db {4 {:my.system.user/name "Anne"} 100 | 2 {:my.system.user/name "Fred"}}})) 101 | 102 | (let [sm (psm/smart-map (pci/register resolvers) {:my.system/user-id 4})] 103 | (is (= (:my.system.user/name sm) "Anne"))))) 104 | 105 | (deftest global-data-resolver-test 106 | (let [[resolver :as resolvers] (pbir/global-data-resolver 107 | {:my.system/port 108 | 1234 109 | 110 | :my.system/initial-path 111 | "/tmp/system" 112 | 113 | :my.system/generic-db 114 | ^{:com.wsscode.pathom3/entity-table :my.system/user-id} 115 | {4 {:my.system.user/name "Anne"} 116 | 2 {:my.system.user/name "Fred"}}})] 117 | (is (= (::pco/output (pco/operation-config resolver)) 118 | [:my.system/generic-db :my.system/initial-path :my.system/port])) 119 | 120 | (is (= (resolver {} {}) 121 | #:my.system{:initial-path "/tmp/system" 122 | :port 1234 123 | :generic-db {4 {:my.system.user/name "Anne"} 124 | 2 {:my.system.user/name "Fred"}}})) 125 | 126 | (let [sm (psm/smart-map (pci/register resolvers) {:my.system/user-id 4})] 127 | (is (= (:my.system.user/name sm) "Anne"))))) 128 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/connect/built_in/plugins_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.built-in.plugins-test 2 | (:require 3 | [check.core :refer [check =>]] 4 | [clojure.test :refer [deftest is are run-tests testing]] 5 | [com.wsscode.log :as l] 6 | [com.wsscode.pathom3.connect.built-in.plugins :as pbip] 7 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 8 | [com.wsscode.pathom3.connect.indexes :as pci] 9 | [com.wsscode.pathom3.connect.operation :as pco] 10 | [com.wsscode.pathom3.interface.eql :as p.eql] 11 | [com.wsscode.pathom3.plugin :as p.plugin] 12 | [spy.core :as spy])) 13 | 14 | (deftest mutation-resolve-params-test 15 | (is (= (p.eql/process 16 | (-> (pci/register 17 | [(pbir/single-attr-resolver :a :b inc) 18 | (pco/mutation 'foo 19 | {::pco/params [:b]} 20 | (fn [_ {:keys [b]}] {:res b}))]) 21 | (p.plugin/register pbip/mutation-resolve-params)) 22 | ['(foo {:a 1})]) 23 | {'foo {:res 2}}))) 24 | 25 | (deftest filtered-sequence-items-plugin-test 26 | (is (= {:items [{:x "b", :y "y"} {:x "c", :y "y2"}]} 27 | (-> (p.eql/process 28 | (-> (pci/register 29 | [(pbir/global-data-resolver {:items [{:x "a"} 30 | {:x "b" 31 | :y "y"} 32 | {:y "xx"} 33 | {:x "c" 34 | :y "y2"}]})]) 35 | (p.plugin/register (pbip/filtered-sequence-items-plugin))) 36 | [^::pbip/remove-error-items {:items [:x :y]}])))) 37 | 38 | (is (thrown? 39 | #?(:clj Throwable :cljs :default) 40 | (-> (p.eql/process 41 | (-> (pci/register 42 | [(pbir/global-data-resolver {:items [{:x "a"} 43 | {:x "b" 44 | :y "y"} 45 | {:y "xx"} 46 | {:x "c" 47 | :y "y2"}]})]) 48 | (p.plugin/register (pbip/filtered-sequence-items-plugin))) 49 | [{:items [:x :y]}])))) 50 | 51 | (is (= {:items [{:x "b", :y "y"} {:x "c", :y "y2"}]} 52 | (-> (p.eql/process 53 | (-> (pci/register 54 | [(pbir/global-data-resolver {:items [{:x "a"} 55 | {:x "b" 56 | :y "y"} 57 | {:y "xx"} 58 | {:x "c" 59 | :y "y2"}]})]) 60 | (p.plugin/register (pbip/filtered-sequence-items-plugin {::pbip/apply-everywhere? true}))) 61 | [{:items [:x :y]}]))))) 62 | 63 | (deftest env-wrap-plugin-test 64 | (is (= (-> (pci/register 65 | (pco/resolver 'env-data 66 | {::pco/output [:env-value]} 67 | (fn [{:keys [data]} _] 68 | {:env-value data}))) 69 | (p.plugin/register (pbip/env-wrap-plugin #(assoc % :data "bar"))) 70 | (p.eql/process [:env-value])) 71 | {:env-value "bar"}))) 72 | 73 | (deftest dev-linter-test 74 | (testing "simple extra key" 75 | (binding [l/*active-logger* (spy/spy)] 76 | (p.eql/process 77 | (-> (pci/register 78 | [(pco/resolver 'x 79 | {::pco/output [:x]} 80 | (fn [_ _] {:x 10 :y 20}))]) 81 | (p.plugin/register (pbip/dev-linter))) 82 | [:x]) 83 | (check 84 | (=> [[{:com.wsscode.pathom3.connect.operation/provides {:x {}}, 85 | :com.wsscode.log/event :com.wsscode.pathom3.connect.built-in.plugins/undeclared-output, 86 | :com.wsscode.log/timestamp inst?, 87 | :com.wsscode.pathom3.connect.operation/op-name 'x, 88 | :com.wsscode.log/level :com.wsscode.log/level-warn, 89 | ::pbip/unexpected-shape {:y {}}}]] 90 | (spy/calls l/*active-logger*))))) 91 | 92 | (testing "on nested inputs" 93 | (binding [l/*active-logger* (spy/spy)] 94 | (p.eql/process 95 | (-> (pci/register 96 | [(pco/resolver 'x 97 | {::pco/output [{:x [:y]}]} 98 | (fn [_ _] {:x {:y 20 :z 30}}))]) 99 | (p.plugin/register (pbip/dev-linter))) 100 | [:x]) 101 | (check 102 | (=> [[{:com.wsscode.pathom3.connect.operation/provides {:x {}}, 103 | :com.wsscode.log/event :com.wsscode.pathom3.connect.built-in.plugins/undeclared-output, 104 | :com.wsscode.log/timestamp inst?, 105 | :com.wsscode.pathom3.connect.operation/op-name 'x, 106 | :com.wsscode.log/level :com.wsscode.log/level-warn, 107 | ::pbip/unexpected-shape {:x {:z {}}}}]] 108 | (spy/calls l/*active-logger*)))))) 109 | 110 | (deftest placeholder-data-test) 111 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/interface/async/eql_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.interface.async.eql-test 2 | (:require 3 | [check.core :refer [=> check]] 4 | [clojure.test :refer [deftest is testing]] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.indexes :as pci] 7 | [com.wsscode.pathom3.connect.operation :as pco] 8 | [com.wsscode.pathom3.connect.planner :as pcp] 9 | [com.wsscode.pathom3.connect.runner :as pcr] 10 | [com.wsscode.pathom3.interface.async.eql :as p.a.eql] 11 | [com.wsscode.pathom3.test.geometry-resolvers :as geo] 12 | [promesa.core :as p])) 13 | 14 | (def registry 15 | [geo/full-registry 16 | (pbir/constantly-resolver :simple "value") 17 | (pbir/constantly-fn-resolver :foo ::foo) 18 | (pbir/constantly-resolver :false false)]) 19 | 20 | (defn run-boundary-interface [env request] 21 | (let [fi (p.a.eql/boundary-interface env)] 22 | @(fi request))) 23 | 24 | (deftest process-error-test 25 | (is (thrown-with-msg? 26 | Throwable 27 | #"Error while processing request \[:a] for entity \{}" 28 | @(p.a.eql/process 29 | (pci/register 30 | (pco/resolver 'a 31 | {::pco/output [:a]} 32 | (fn [_ _] {}))) 33 | [:a])))) 34 | 35 | (deftest boundary-interface-test 36 | (let [fi (p.a.eql/boundary-interface (pci/register registry))] 37 | (testing "call with just tx" 38 | (is (= @(fi [:simple]) 39 | {:simple "value"}))) 40 | 41 | (testing "call with entity and tx" 42 | (is (= @(fi {:pathom/entity {:left 10} 43 | :pathom/eql [:x]}) 44 | {:x 10}))) 45 | 46 | (testing "merge env" 47 | (is (= @(fi [:foo]) 48 | {:foo nil})) 49 | 50 | (is (= @(fi {::foo "bar"} [:foo]) 51 | {:foo "bar"}))) 52 | 53 | (testing "modify env" 54 | (is (= @(fi #(pci/register % (pbir/constantly-resolver :new "value")) [:new]) 55 | {:new "value"})))) 56 | 57 | (testing "async env" 58 | (let [fi (p.a.eql/boundary-interface (p/promise (pci/register registry)))] 59 | (is (= @(fi [:simple]) 60 | {:simple "value"})) 61 | 62 | (testing "providing extra async env" 63 | (is (= @(fi (p/promise {::foo "bar"}) [:foo]) 64 | {:foo "bar"})))))) 65 | 66 | (deftest boundary-interface-include-stats-test 67 | (testing "omit stats by default" 68 | (is (nil? 69 | (-> (run-boundary-interface 70 | (pci/register 71 | [(pbir/constantly-resolver :a 10)]) 72 | {:pathom/eql [:a]}) 73 | meta 74 | ::pcr/run-stats)))) 75 | 76 | (testing "include when requested" 77 | (is (some? 78 | (-> (run-boundary-interface 79 | (pci/register 80 | [(pbir/constantly-resolver :a 10)]) 81 | {:pathom/eql [:a] 82 | :pathom/include-stats? true}) 83 | meta 84 | ::pcr/run-stats))))) 85 | 86 | (deftest process-one-test 87 | (is (= @(p.a.eql/process-one (pci/register registry) {:left 10 :right 30} :width) 88 | 20)) 89 | 90 | (is (= @(p.a.eql/process-one (pci/register geo/full-registry) 91 | {:left 10 :top 5} 92 | {::geo/turn-point [:right]}) 93 | {:right 10})) 94 | 95 | (testing "keeps meta" 96 | (let [response @(p.a.eql/process-one 97 | (pci/register 98 | [(pbir/constantly-resolver :items [{:a 1}])]) 99 | :items)] 100 | (is (= response [{:a 1}])) 101 | (check 102 | (meta response) 103 | => {::pcr/run-stats 104 | {::pcp/available-data {} 105 | ::pcp/index-ast {} 106 | ::pcp/index-attrs {} 107 | ::pcp/index-resolver->nodes {} 108 | ::pcp/nodes {} 109 | ::pcp/root number? 110 | ::pcp/source-ast {} 111 | ::pcp/user-request-shape {} 112 | 113 | ::pcr/node-run-stats {} 114 | ::pcr/transient-stats {}}})) 115 | 116 | (testing "don't change data when its already there" 117 | (let [response @(p.a.eql/process-one 118 | (pci/register 119 | [(pbir/constantly-resolver :items {:a 1}) 120 | (pbir/alias-resolver :a :b)]) 121 | {:items [:b]})] 122 | (is (= response {:b 1})) 123 | (check 124 | (meta response) 125 | => {::pcr/run-stats 126 | {::pcp/available-data 127 | {:a {}}}}))) 128 | 129 | (testing "returns false" 130 | (is (= @(p.a.eql/process-one (pci/register registry) :false) 131 | false))))) 132 | 133 | (deftest avoid-huge-ex-message 134 | (let [env (pci/register (pco/resolver `a {::pco/output [:a]} 135 | (fn [_ _] 136 | (throw (ex-info "hello" {:world 42}))))) 137 | ex (try 138 | @(p.a.eql/process env [:a]) 139 | (catch Throwable ex 140 | ex)) 141 | msg (ex-message ex)] 142 | (testing 143 | "Not a huge size" 144 | (is (< (count msg) 145 | 1e3))) 146 | (testing 147 | "uses same error message" 148 | (is (= msg "clojure.lang.ExceptionInfo: Error while processing request [:a] for entity {} {:entity {}, :tx [:a]}"))))) 149 | -------------------------------------------------------------------------------- /src/tasks/tasks.clj: -------------------------------------------------------------------------------- 1 | (ns tasks 2 | (:require 3 | [babashka.deps :as deps] 4 | [babashka.fs :as fs] 5 | [babashka.process :as p] 6 | [clojure.data.xml :as xml] 7 | [clojure.string :as str]) 8 | (:import 9 | (java.time 10 | LocalDate) 11 | (java.time.format 12 | DateTimeFormatter))) 13 | 14 | ; region helpers 15 | 16 | (defn check [p] 17 | (try 18 | (p/check p) 19 | (catch Throwable _ 20 | (System/exit 1)))) 21 | 22 | (defn- sh-dispatch [args opts] 23 | (let [ps (if (= (first args) :clojure) 24 | (deps/clojure (map name (rest args)) opts) 25 | (p/process (map name args) opts))] 26 | ps)) 27 | 28 | (defn- sh [& args] 29 | (println "=>" (str/join " " (map name args))) 30 | (check (sh-dispatch args {:inherit true})) 31 | nil) 32 | 33 | (defn- sh-silent [& args] 34 | (sh-dispatch args {})) 35 | 36 | (defn- sh-out [& args] 37 | (-> (sh-dispatch args {:out :string}) 38 | check 39 | :out)) 40 | 41 | (def clojure (partial sh :clojure)) 42 | 43 | ; endregion 44 | 45 | ; region cljstyle 46 | 47 | (defn native-cljstyle? [] 48 | (-> (sh-silent :which "cljstyle") deref :exit zero?)) 49 | 50 | (def cljstyle-native (partial sh :cljstyle)) 51 | 52 | (defn cljstyle [& args] 53 | (if (native-cljstyle?) 54 | (apply cljstyle-native args) 55 | (apply clojure "-Sdeps" "{:deps {mvxcvi/cljstyle {:mvn/version \"0.15.0\"}}}" "-m" "cljstyle.main" args))) 56 | 57 | ; endregion 58 | 59 | ; region clj-kondo 60 | 61 | (def clj-kondo (partial sh :clj-kondo)) 62 | 63 | (defn clj-kondo-lint [paths] 64 | (apply clj-kondo "--lint" paths)) 65 | 66 | ; endregion 67 | 68 | ; region git 69 | 70 | (defn modified-files 71 | "Return a list of the files that are part of the current commit. 72 | Each item is a string with the file path." 73 | [] 74 | (-> (sh-out "git" "diff" "--cached" "--name-only" "--diff-filter=ACMR") 75 | str/split-lines)) 76 | 77 | (defn update-file-index 78 | "Add unstaged modifications to git, so they get to be part of the current commit." 79 | [path] 80 | (let [hash (sh-out "git" "hash-object" "-w" path)] 81 | (sh-silent :git "update-index" "--add" "--cacheinfo" "100644" hash path))) 82 | 83 | ; endregion 84 | 85 | ; region pre commit action to validate and format 86 | 87 | (defn clojure-source? [path] 88 | (re-find #"\.(clj|cljs|cljc)$" path)) 89 | 90 | (defn setup-git-hooks 91 | "Create a small script at git pre-commit path to trigger the pre-commit function inside the tasks namespace." 92 | [] 93 | (spit ".git/hooks/pre-commit" "#!/usr/bin/env bb -m tasks/pre-commit\n") 94 | (sh :chmod "+x" ".git/hooks/pre-commit") 95 | (println "Setup .git/hooks/pre-commit done.")) 96 | 97 | (defn pre-commit 98 | "Script to run at Git pre-commit phase." 99 | [& _args] 100 | (let [paths (->> (modified-files) 101 | (filter clojure-source?))] 102 | (when (seq paths) 103 | ; fix format 104 | (apply cljstyle "fix" paths) 105 | 106 | (doseq [path paths] 107 | (update-file-index path)) 108 | 109 | (clj-kondo-lint paths)))) 110 | 111 | ; endregion 112 | 113 | ; region git 114 | 115 | (defn git-tags [] 116 | (into #{} (str/split-lines (sh-out "git" "tag" "-l")))) 117 | 118 | (defn create-tag! [version] 119 | (sh "git" "tag" "-a" version "-m" version)) 120 | 121 | (defn push-with-tags [] 122 | (sh "git" "push" "--follow-tags")) 123 | 124 | ; endregion 125 | 126 | ; region pom.xml 127 | 128 | (defn update-child-tag [element tag f] 129 | (update element :content 130 | #(mapv 131 | (fn [element] 132 | (if (= tag (:tag element)) 133 | (f element) 134 | element)) 135 | %))) 136 | 137 | (defn update-pom-scm-tag [new-tag] 138 | (-> (xml/parse-str (slurp "pom.xml")) 139 | (update-child-tag :xmlns.http%3A%2F%2Fmaven.apache.org%2FPOM%2F4.0.0/scm 140 | (fn [scm-el] 141 | (update-child-tag scm-el :xmlns.http%3A%2F%2Fmaven.apache.org%2FPOM%2F4.0.0/tag 142 | #(assoc % :content [new-tag])))) 143 | (xml/emit-str) 144 | (->> (spit "pom.xml")))) 145 | 146 | ; endregion 147 | 148 | ; region artifact 149 | 150 | (defn current-version [] 151 | (if (fs/exists? "VERSION") 152 | (str/trim (slurp "VERSION")))) 153 | 154 | (defn artifact-path [] 155 | (str "target/pathom3-" (tasks/current-version) ".jar")) 156 | 157 | (defn version-tag 158 | ([] (version-tag (current-version))) 159 | ([version] (str "v" version))) 160 | 161 | (defn- current-date [] 162 | (str/replace 163 | (.format (LocalDate/now) DateTimeFormatter/ISO_LOCAL_DATE) 164 | "-" 165 | ".")) 166 | 167 | (defn next-version 168 | ([] (next-version (current-version))) 169 | ([current-version] 170 | (let [today (current-date) 171 | [_ date iteration] (re-find #"(\d{4}\.\d{2}\.\d{2})(?:-(\d+))?" (or current-version ""))] 172 | (str (if (= date today) 173 | (str date "-" (or (some-> iteration Integer/parseInt inc) 1)) 174 | today) 175 | "-alpha")))) 176 | 177 | (defn released? 178 | ([] (released? (current-version))) 179 | ([version] 180 | (contains? (git-tags) (version-tag version)))) 181 | 182 | (defn str-arg [s] 183 | (pr-str (str s))) 184 | 185 | (defn artifact-build 186 | ([] 187 | (clojure "-X:jar" ":jar" (artifact-path) ":version" (str-arg (current-version))))) 188 | 189 | (defn artifact-deploy 190 | ([] (artifact-deploy (artifact-path))) 191 | ([artifact] 192 | (clojure "-X:deploy" ":artifact" (str-arg artifact)))) 193 | 194 | (defn bump! [] 195 | (let [version (next-version) 196 | changelog (slurp "CHANGELOG.md")] 197 | (if (re-find #"\[NEXT]" changelog) 198 | (let [changelog' (str/replace changelog #"\[NEXT]" (str "[" version "]"))] 199 | (spit "VERSION" version) 200 | (spit "CHANGELOG.md" changelog')) 201 | (throw (ex-info "CHANGELOG.md must have a [NEXT] mark." {}))) 202 | version)) 203 | 204 | ; endregion 205 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/plugin.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.plugin 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.misc.coll :as coll] 6 | #?(:clj [com.wsscode.misc.macros :as macros])) 7 | #?(:cljs 8 | (:require-macros 9 | [com.wsscode.pathom3.plugin]))) 10 | 11 | (>def ::id "Plugin ID" symbol?) 12 | (>def ::index-plugins (s/map-of ::id (s/keys :req [::id]))) 13 | 14 | (>def ::plugin (s/keys :req [::id])) 15 | (>def ::plugins (s/coll-of ::plugin)) 16 | 17 | (>def ::plugin-or-plugins 18 | (s/or :one ::plugin :many 19 | (s/coll-of ::plugin-or-plugins))) 20 | 21 | (>def ::plugin-actions "Compiled list of actions for a given plugin type" 22 | (s/map-of keyword? (s/coll-of fn? :kind vector?))) 23 | 24 | (>def ::plugin-order (s/coll-of ::plugin :kind vector?)) 25 | 26 | (defn compile-extensions 27 | "Given a function and a list of extension wrappers, call then in order to create 28 | a composed functions of them." 29 | [f extension-wrappers] 30 | (reduce 31 | (fn [f wrapper] 32 | (wrapper f)) 33 | f 34 | extension-wrappers)) 35 | 36 | (defn compile-env-extensions 37 | [env plugin-type f] 38 | (if-let [plugins (get-in env [::plugin-actions plugin-type])] 39 | (compile-extensions f plugins) 40 | f)) 41 | 42 | (defn build-plugin-actions [{::keys [plugin-order index-plugins] :as env} k] 43 | (assoc-in env [::plugin-actions k] 44 | (into 45 | [] 46 | (keep 47 | (fn [{::keys [id]}] 48 | (get-in index-plugins [id k]))) 49 | (rseq plugin-order)))) 50 | 51 | (defn add-plugin-at-order 52 | [{::keys [plugin-order] :as env} {::keys [id add-before add-after]}] 53 | (assert (or (not (or add-before add-after)) 54 | (not (and add-before add-after))) 55 | "You can provide add-before or add-after, but not both at the same time.") 56 | (let [ref-id (or add-before add-after) 57 | ref-position (coll/index-of plugin-order {::id ref-id})] 58 | (cond-> env 59 | add-before 60 | (update-in [::plugin-order] coll/conj-at-index ref-position {::id id}) 61 | 62 | add-after 63 | (update-in [::plugin-order] coll/conj-at-index (inc ref-position) {::id id}) 64 | 65 | (not (or add-before add-after)) 66 | (update-in [::plugin-order] coll/vconj {::id id})))) 67 | 68 | (defn plugin-extensions [plugin] 69 | (keys (coll/filter-vals fn? plugin))) 70 | 71 | (defn refresh-actions-from-plugin [env plugin] 72 | (reduce 73 | build-plugin-actions 74 | env 75 | (plugin-extensions plugin))) 76 | 77 | (>defn register-plugin 78 | "Add a new plugin to the end. This will create the appropriated structures to optimize 79 | the plugin call speed." 80 | ([plugin] 81 | [::plugin => map?] (register-plugin {} plugin)) 82 | ([env {::keys [id] :as plugin}] 83 | [map? ::plugin => map?] 84 | (assert (nil? (get-in env [::index-plugins id])) 85 | (str "Tried to add duplicated plugin: " id)) 86 | (let [env' (-> env 87 | (assoc-in [::index-plugins id] plugin) 88 | (add-plugin-at-order plugin))] 89 | (refresh-actions-from-plugin env' plugin)))) 90 | 91 | (>defn register-before 92 | ([env ref-id plugin] 93 | [map? ::id ::plugin => map?] 94 | (register-plugin env (assoc plugin ::add-before ref-id)))) 95 | 96 | (>defn register-after 97 | ([env ref-id plugin] 98 | [map? ::id ::plugin => map?] 99 | (register-plugin env (assoc plugin ::add-after ref-id)))) 100 | 101 | (>defn register 102 | "Add one or many plugins." 103 | ([plugins] [::plugin-or-plugins => map?] (register {} plugins)) 104 | ([env plugins] 105 | [map? ::plugin-or-plugins => map?] 106 | (cond 107 | (::id plugins) 108 | (register-plugin env plugins) 109 | 110 | (sequential? plugins) 111 | (reduce 112 | register 113 | env 114 | plugins) 115 | 116 | :else 117 | (throw 118 | (ex-info "Invalid plugin, make sure you set the ::p.plugin/id on it." 119 | {:plugin plugins}))))) 120 | 121 | (>defn remove-plugin 122 | "Remove a plugin." 123 | [env plugin-id] 124 | [map? ::id => map?] 125 | (if-let [{::keys [id] :as plugin} (get-in env [::index-plugins plugin-id])] 126 | (let [env' (-> env 127 | (update ::index-plugins dissoc id) 128 | (update ::plugin-order #(into [] (remove #{{::id id}}) %)))] 129 | (refresh-actions-from-plugin env' plugin)) 130 | env)) 131 | 132 | (defn run-with-plugins 133 | "Run some operation f wrapping it with the plugins of a given plugin-type installed 134 | in the environment." 135 | ([env plugin-type f] 136 | (let [augmented-v (compile-env-extensions env plugin-type f)] 137 | (augmented-v))) 138 | ([env plugin-type f a1] 139 | (let [augmented-v (compile-env-extensions env plugin-type f)] 140 | (augmented-v a1))) 141 | ([env plugin-type f a1 a2] 142 | (let [augmented-v (compile-env-extensions env plugin-type f)] 143 | (augmented-v a1 a2))) 144 | ([env plugin-type f a1 a2 a3] 145 | (let [augmented-v (compile-env-extensions env plugin-type f)] 146 | (augmented-v a1 a2 a3))) 147 | ([env plugin-type f a1 a2 a3 a4] 148 | (let [augmented-v (compile-env-extensions env plugin-type f)] 149 | (augmented-v a1 a2 a3 a4))) 150 | ([env plugin-type f a1 a2 a3 a4 a5] 151 | (let [augmented-v (compile-env-extensions env plugin-type f)] 152 | (augmented-v a1 a2 a3 a4 a5))) 153 | ([env plugin-type f a1 a2 a3 a4 a5 a6] 154 | (let [augmented-v (compile-env-extensions env plugin-type f)] 155 | (augmented-v a1 a2 a3 a4 a5 a6))) 156 | ([env plugin-type f a1 a2 a3 a4 a5 a6 a7] 157 | (let [augmented-v (compile-env-extensions env plugin-type f)] 158 | (augmented-v a1 a2 a3 a4 a5 a6 a7))) 159 | ([env plugin-type f a1 a2 a3 a4 a5 a6 a7 a8] 160 | (let [augmented-v (compile-env-extensions env plugin-type f)] 161 | (augmented-v a1 a2 a3 a4 a5 a6 a7 a8))) 162 | ([env plugin-type f a1 a2 a3 a4 a5 a6 a7 a8 & args] 163 | (apply (compile-env-extensions env plugin-type f) a1 a2 a3 a4 a5 a6 a7 a8 args))) 164 | 165 | #?(:clj 166 | (defmacro defplugin 167 | ([id doc options] 168 | (let [fqsym (macros/full-symbol id (str *ns*))] 169 | `(def ~id ~doc (merge {::id '~fqsym} ~options)))) 170 | ([id options] 171 | (let [fqsym (macros/full-symbol id (str *ns*))] 172 | `(def ~id (merge {::id '~fqsym} ~options)))))) 173 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/interface/async/eql.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.interface.async.eql 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [=> >defn]] 5 | [com.wsscode.misc.coll :as coll] 6 | [com.wsscode.pathom3.connect.foreign :as pcf] 7 | [com.wsscode.pathom3.connect.indexes :as pci] 8 | [com.wsscode.pathom3.connect.runner :as pcr] 9 | [com.wsscode.pathom3.connect.runner.async :as pcra] 10 | [com.wsscode.pathom3.connect.runner.parallel :as pcrc] 11 | [com.wsscode.pathom3.entity-tree :as p.ent] 12 | [com.wsscode.pathom3.error :as p.error] 13 | [com.wsscode.pathom3.format.eql :as pf.eql] 14 | [com.wsscode.pathom3.interface.eql :as p.eql] 15 | [com.wsscode.pathom3.plugin :as p.plugin] 16 | [edn-query-language.core :as eql] 17 | [promesa.core :as p])) 18 | 19 | (defn process-ast* [env ast] 20 | (p/let [ent-tree* (get env ::p.ent/entity-tree* (p.ent/create-entity {})) 21 | result (if (::parallel? env) 22 | (pcrc/run-graph! env ast ent-tree*) 23 | (pcra/run-graph! env ast ent-tree*))] 24 | (as-> result <> 25 | (pf.eql/map-select-ast (p.eql/select-ast-env env) <> ast)))) 26 | 27 | (>defn process-ast 28 | [env ast] 29 | [::pcra/env :edn-query-language.ast/node => p/promise?] 30 | (let [source-entity (or (p.ent/entity env) {})] 31 | (-> (p/let [env env] 32 | (p.plugin/run-with-plugins env ::p.eql/wrap-process-ast 33 | process-ast* env ast)) 34 | (p/catch 35 | (fn [e] 36 | (throw (p.eql/process-error env ast source-entity e))))))) 37 | 38 | (>defn process 39 | "Evaluate EQL expression using async runner. 40 | 41 | This interface allows you to request a specific data shape to Pathom and get 42 | the response as a map with all data combined. 43 | 44 | This is efficient for large queries, given Pathom can make a plan considering 45 | the whole request at once (different from Smart Map, which always plans for one 46 | attribute at a time). 47 | 48 | At minimum you need to build an index to use this. 49 | 50 | (p.eql/process (pci/register some-resolvers) 51 | [:eql :request]) 52 | 53 | By default, processing will start with a blank entity tree. You can override this by 54 | sending an entity tree as the second argument in the 3-arity version of this fn: 55 | 56 | (p.eql/process (pci/register some-resolvers) 57 | {:eql \"initial data\"} 58 | [:eql :request]) 59 | 60 | For more options around processing check the docs on the connect runner." 61 | ([env tx] 62 | [::pcra/env ::eql/query => p/promise?] 63 | (p/let [env env] 64 | (process-ast (assoc env ::pcr/root-query tx) (eql/query->ast tx)))) 65 | ([env entity tx] 66 | [::pcra/env map? ::eql/query => p/promise?] 67 | (assert (map? entity) "Entity data must be a map.") 68 | (p/let [env env] 69 | (process-ast (-> env 70 | (assoc ::pcr/root-query tx) 71 | (p.ent/with-entity entity)) 72 | (eql/query->ast tx))))) 73 | 74 | (>defn process-one 75 | "Similar to process, but returns a single value instead of a map. 76 | 77 | This is a convenience method to read a single attribute. 78 | 79 | Simplest usage: 80 | ```clojure 81 | (p.eql/process-one env :foo) 82 | ``` 83 | 84 | Same as process, you can send initial data: 85 | ```clojure 86 | (p.eql/process-one env {:data \"here\"} :foo) 87 | ``` 88 | 89 | You can also use joins and param expressions: 90 | ```clojure 91 | (p.eql/process-one env {:join [:sub-query]}) 92 | (p.eql/process-one env '(:param {:expr \"sion\"})) 93 | ``` 94 | " 95 | ([env attr] 96 | [(s/keys) 97 | (s/or :prop ::eql/property 98 | :join ::eql/join 99 | :param ::eql/param-expr) 100 | => any?] 101 | (process-one env {} attr)) 102 | ([env entity attr] 103 | [(s/keys) 104 | map? 105 | (s/or :prop ::eql/property 106 | :join ::eql/join 107 | :param ::eql/param-expr) 108 | => any?] 109 | (p/let [response (process env entity [attr])] 110 | (if-some [val (some-> response first val)] 111 | (cond-> val 112 | (coll? val) 113 | (vary-meta coll/merge-defaults {::pcr/run-stats (-> response meta ::pcr/run-stats)})))))) 114 | 115 | (>defn boundary-interface 116 | "Returns a function that wraps the environment. When exposing Pathom to some external 117 | system, this is the recommended way to do it. The format here makes your API compatible 118 | with Pathom Foreign process, which allows the integration of distributed environments. 119 | 120 | When calling the remote interface the user can send a query or a map containing the 121 | query and the initial entity data. This map is open and you can use as a way to extend 122 | the API. 123 | 124 | Boundary interface: 125 | 126 | ([env-ext request]) 127 | ([request]) 128 | 129 | Request is one of: 130 | 131 | 1. An EQL request 132 | 2. A map, supported keys: 133 | :pathom/eql 134 | :pathom/ast 135 | :pathom/entity 136 | :pathom/include-stats? 137 | :pathom/lenient-mode? 138 | 139 | Env ext can be either a map to merge in the original env, or a function that transforms 140 | the env." 141 | [env] 142 | [::pcra/env => fn?] 143 | (let [env' (p/let [env env] (pci/register env pcf/foreign-indexes-resolver))] 144 | (fn boundary-interface-internal 145 | ([env-extension input] 146 | (-> (p/let [env env 147 | {:pathom/keys [eql entity ast include-stats?] :as request} 148 | (p.eql/normalize-input env input) 149 | ; ensure if it's a promise it gets resolved 150 | env' env' 151 | env-extension env-extension 152 | env' (-> env' 153 | (p.eql/boundary-env input) 154 | (p.eql/extend-env env-extension) 155 | (assoc 156 | ::source-request request 157 | ::pcr/omit-run-stats? (not include-stats?))) 158 | entity' (or entity {})] 159 | 160 | (if ast 161 | (process-ast (p.ent/with-entity env' entity') ast) 162 | (process env' entity' (or eql (:pathom/tx request))))) 163 | (p/catch p.error/datafy-processor-error))) 164 | ([input] 165 | (boundary-interface-internal nil input))))) 166 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/built_in/plugins.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.built-in.plugins 2 | (:require 3 | [com.fulcrologic.guardrails.core :refer [>def]] 4 | [com.wsscode.log :as l] 5 | [com.wsscode.misc.coll :as coll] 6 | [com.wsscode.misc.time :as time] 7 | [com.wsscode.pathom3.connect.indexes :as pci] 8 | [com.wsscode.pathom3.connect.operation :as pco] 9 | [com.wsscode.pathom3.connect.planner :as pcp] 10 | [com.wsscode.pathom3.connect.runner :as pcr] 11 | [com.wsscode.pathom3.connect.runner.async :as pcra] 12 | [com.wsscode.pathom3.format.shape-descriptor :as pfsd] 13 | [com.wsscode.pathom3.interface.async.eql :as p.a.eql] 14 | [com.wsscode.pathom3.interface.eql :as p.eql] 15 | [com.wsscode.pathom3.plugin :as p.plugin] 16 | [com.wsscode.promesa.macros :refer [clet ctry]])) 17 | 18 | (p.plugin/defplugin mutation-resolve-params 19 | "Remove the run stats from the result meta. Use this in production to avoid sending 20 | the stats. This is important for performance and security. 21 | 22 | TODO: error story is not complete, still up to decide what to do when params can't 23 | get fulfilled." 24 | {::pcr/wrap-mutate 25 | (fn mutation-resolve-params-external [mutate] 26 | (fn mutation-resolve-params-internal [env {:keys [key] :as ast}] 27 | (let [{::pco/keys [params]} (pci/mutation-config env key)] 28 | (clet [params' (if params 29 | (if (::pcra/async-runner? env) 30 | (p.a.eql/process env (:params ast) params) 31 | (p.eql/process env (:params ast) params)) 32 | (:params ast))] 33 | (mutate env (assoc ast :params params'))))))}) 34 | 35 | (>def ::apply-everywhere? boolean?) 36 | 37 | (defn resolver-weight-tracker 38 | "Starts an atom to track the weight of a resolver. The weight is calculated by measuring 39 | the time a resolver takes to run. The time is added to last known time (or 1 in case of 40 | no previous data) and divided by two to get the new weight. 41 | 42 | You should use this plugin to enable weight sorting." 43 | [] 44 | (let [weights* (atom {})] 45 | {::p.plugin/id 46 | `resolver-weight-tracker 47 | 48 | ::pcr/wrap-root-run-graph! 49 | (fn [process] 50 | (fn [env ast entity*] 51 | (process (coll/merge-defaults env {::pcr/resolver-weights* weights*}) ast entity*))) 52 | 53 | ::pcr/wrap-resolve 54 | (fn [resolve] 55 | (fn [{::pcr/keys [resolver-weights*] 56 | ::pcp/keys [node] 57 | :as env} input] 58 | (let [{::pco/keys [op-name]} node] 59 | (ctry 60 | (clet [start (time/now-ms) 61 | result (resolve env input) 62 | elapsed (- (time/now-ms) start)] 63 | (swap! resolver-weights* update op-name #(/ (+ (or % 1) elapsed) 2)) 64 | result) 65 | (catch #?(:clj Throwable :cljs :default) e 66 | (swap! resolver-weights* update op-name #(* (or % 10) 2)) 67 | (throw e))))))})) 68 | 69 | (defn filtered-sequence-items-plugin 70 | "By default, in Pathom strict mode, when an error occurs with one item in a sequence 71 | that will stop the process completly. This plugin provides a way to add some tolerance 72 | there. The way it does it is by filtering out of the output sequence any items that 73 | are unable to process fully. 74 | 75 | In is default form, this plugin will only apply to cases where the use sets the meta 76 | `::pbip/remove-error-items` in the query join, eg: 77 | 78 | (p.eql/process 79 | (p.plugin/register env (pbip/filtered-sequence-items-plugin)) 80 | [^::pbip/remove-error-items {:some-join [:x :y]}]) 81 | 82 | Now the items that can't conform from inside :some-join will get filtered out. 83 | 84 | You can also make this applies everywhere by starting the plugin with the `apply 85 | everywhere` configuration: 86 | 87 | (p.eql/process 88 | (p.plugin/register env (pbip/filtered-sequence-items-plugin {::pbip/apply-everywhere? true})) 89 | [^::pbip/remove-error-items {:some-join [:x :y]}])" 90 | ([] (filtered-sequence-items-plugin {})) 91 | ([{::keys [apply-everywhere?]}] 92 | {::p.plugin/id 93 | `filtered-sequence-items-plugin 94 | 95 | ::pcr/wrap-process-sequence-item 96 | (fn [map-subquery] 97 | (fn [env ast m] 98 | (ctry 99 | (map-subquery env ast m) 100 | (catch #?(:clj Throwable :cljs :default) e 101 | (if (or apply-everywhere? 102 | (-> ast :meta ::remove-error-items)) 103 | nil 104 | (throw e))))))})) 105 | 106 | (defn env-wrap-plugin 107 | "Plugin to help extend the environment with something dynamic. This will run once 108 | around the whole request. 109 | 110 | (p.plugin/register env (pbip/env-modify-plugin #(assoc % :data \"bar\")))" 111 | [env-modifier] 112 | {::p.plugin/id 113 | `env-wrap-plugin 114 | 115 | ::pcr/wrap-root-run-graph! 116 | (fn track-request-root-run-external [process] 117 | (fn track-request-root-run-internal [env ast entity*] 118 | (process 119 | (env-modifier env) 120 | ast 121 | entity*)))}) 122 | 123 | (defn dev-linter 124 | "This plugin adds linting features to help developers find sources of issues while 125 | Pathom runs its system. 126 | 127 | Checks done: 128 | 129 | - Verify if all the output that comes out of the resolver is declared in the resolver 130 | output. This means the user missed some attribute declaration in the resolver output 131 | and that may cause inconsistent behavior on planning/running." 132 | [] 133 | {::p.plugin/id 134 | `dev-linter 135 | 136 | ::pcr/wrap-resolve 137 | (fn [resolve] 138 | (fn [env input] 139 | (clet [{::pco/keys [provides op-name]} (pci/resolver-config env (-> env ::pcp/node ::pco/op-name)) 140 | res (resolve env input) 141 | unexpected-shape (pfsd/difference (pfsd/data->shape-descriptor res) provides)] 142 | (if (seq unexpected-shape) 143 | (l/warn ::undeclared-output 144 | {::pco/op-name op-name 145 | ::pco/provides provides 146 | ::unexpected-shape unexpected-shape})) 147 | res)))}) 148 | 149 | (defn placeholder-data-params 150 | "This plugin will make placeholder params change data from the entity they point to. 151 | This behavior used to happen by default in the past, but it's now provided in the form 152 | of this plugin. 153 | 154 | (p.plugin/register env (pbip/placeholder-data-params)) 155 | 156 | Then you can do: 157 | 158 | (p.eql/process env [{'(:>/foo {:some-data \"value\"}) [:some-data]}] 159 | => {:some-data \"value\"}" 160 | [] 161 | {::p.plugin/id 162 | `placeholder-data-params 163 | 164 | ::pcr/wrap-placeholder-merge-entity 165 | (fn placeholder-data-external [_] 166 | (fn placeholder-data-internal 167 | [{::pcp/keys [graph] ::pcr/keys [source-entity]}] 168 | (reduce 169 | (fn [out ph] 170 | (let [data (:params (pcp/entry-ast graph ph))] 171 | (assoc out ph (merge source-entity data)))) 172 | {} 173 | (::pcp/placeholders graph))))}) 174 | 175 | (defn ^:deprecated attribute-errors-plugin 176 | "DEPRECATED: attribute errors are now built-in, you can just remove it 177 | from your setup. 178 | 179 | This plugin makes attributes errors visible in the data." 180 | [] 181 | {::p.plugin/id 182 | `attribute-errors-plugin}) 183 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/interface/eql.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.interface.eql 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [=> >def >defn]] 5 | [com.wsscode.misc.coll :as coll] 6 | [com.wsscode.pathom3.connect.foreign :as pcf] 7 | [com.wsscode.pathom3.connect.indexes :as pci] 8 | [com.wsscode.pathom3.connect.runner :as pcr] 9 | [com.wsscode.pathom3.entity-tree :as p.ent] 10 | [com.wsscode.pathom3.error :as p.error] 11 | [com.wsscode.pathom3.format.eql :as pf.eql] 12 | [com.wsscode.pathom3.plugin :as p.plugin] 13 | [edn-query-language.core :as eql])) 14 | 15 | (>def :pathom/eql ::eql/query) 16 | (>def :pathom/ast :edn-query-language.ast/node) 17 | (>def :pathom/entity map?) 18 | (>def :pathom/lenient-mode? ::p.error/lenient-mode?) 19 | 20 | (defn select-ast-env [{::p.error/keys [lenient-mode?] :as env}] 21 | (cond-> env lenient-mode? (update ::pf.eql/map-select-include coll/sconj ::pcr/attribute-errors))) 22 | 23 | (defn process-ast* [env ast] 24 | (let [ent-tree* (get env ::p.ent/entity-tree* (p.ent/create-entity {})) 25 | result (pcr/run-graph! env ast ent-tree*)] 26 | (as-> result <> 27 | (pf.eql/map-select-ast (select-ast-env env) <> ast)))) 28 | 29 | (defn- string-cap [s max-size] 30 | (if (> (count s) max-size) 31 | (str (subs s 0 (- max-size 3)) "...") 32 | s)) 33 | 34 | (defn process-error [env ast source-entity error] 35 | (let [entity (or (p.ent/entity env) {}) 36 | tx (eql/ast->query ast)] 37 | (ex-info 38 | (str "Error while processing request " 39 | (string-cap (pr-str tx) 40) 40 | " for entity " 41 | (string-cap (pr-str source-entity) 40)) 42 | {:entity entity 43 | :tx tx} 44 | error))) 45 | 46 | (>defn process-ast 47 | [env ast] 48 | [(s/keys) :edn-query-language.ast/node => map?] 49 | (let [source-entity (or (p.ent/entity env) {})] 50 | (try 51 | (p.plugin/run-with-plugins env ::wrap-process-ast 52 | process-ast* env ast) 53 | (catch #?(:clj Throwable :cljs :default) e 54 | (throw (process-error env ast source-entity e)))))) 55 | 56 | (>defn process 57 | "Evaluate EQL expression. 58 | 59 | This interface allows you to request a specific data shape to Pathom and get 60 | the response as a map with all data combined. 61 | 62 | This is efficient for large queries, given Pathom can make a plan considering 63 | the whole request at once (different from Smart Map, which always plans for one 64 | attribute at a time). 65 | 66 | At minimum, you need to build an index to use this. 67 | 68 | (p.eql/process (pci/register some-resolvers) 69 | [:eql :request]) 70 | 71 | By default, processing will start with a blank entity tree. You can override this by 72 | sending an entity tree as the second argument in the 3-arity version of this fn: 73 | 74 | (p.eql/process (pci/register some-resolvers) 75 | {:eql \"initial data\"} 76 | [:eql :request]) 77 | 78 | For more options around processing, check the docs on the connect runner." 79 | ([env tx] 80 | [(s/keys) ::eql/query => map?] 81 | (process-ast (assoc env ::pcr/root-query tx) (eql/query->ast tx))) 82 | ([env entity tx] 83 | [(s/keys) map? ::eql/query => map?] 84 | (assert (map? entity) "Entity data must be a map.") 85 | (process-ast (-> env 86 | (assoc ::pcr/root-query tx) 87 | (p.ent/with-entity entity)) 88 | (eql/query->ast tx)))) 89 | 90 | (>defn process-one 91 | "Similar to `process`, but returns a single value instead of a map. 92 | 93 | This is a convenience method to read a single attribute. 94 | 95 | Simplest usage: 96 | ```clojure 97 | (p.eql/process-one env :foo) 98 | ``` 99 | 100 | Same as process, you can send initial data: 101 | ```clojure 102 | (p.eql/process-one env {:data \"here\"} :foo) 103 | ``` 104 | 105 | You can also use joins and param expressions: 106 | ```clojure 107 | (p.eql/process-one env {:join [:sub-query]}) 108 | (p.eql/process-one env '(:param {:expr \"sion\"})) 109 | ``` 110 | 111 | If the value returned supports meta, it will have the run stats meta from the root 112 | entity. 113 | " 114 | ([env attr] 115 | [(s/keys) 116 | (s/or :prop ::eql/property 117 | :join ::eql/join 118 | :param ::eql/param-expr) 119 | => any?] 120 | (process-one env {} attr)) 121 | ([env entity attr] 122 | [(s/keys) 123 | map? 124 | (s/or :prop ::eql/property 125 | :join ::eql/join 126 | :param ::eql/param-expr) 127 | => any?] 128 | (let [response (process env entity [attr])] 129 | (if-some [val (some-> response first val)] 130 | (cond-> val 131 | (coll? val) 132 | (vary-meta coll/merge-defaults {::pcr/run-stats (-> response meta ::pcr/run-stats)})))))) 133 | 134 | (>defn satisfy 135 | "Works like process, but none of the original entity data is filtered out." 136 | [env entity tx] 137 | [(s/keys) map? ::eql/query => map?] 138 | (merge 139 | entity 140 | (process env entity tx))) 141 | 142 | (>defn normalize-input 143 | "Normalize a remote interface input. In the case of vector, it makes a map. 144 | Otherwise, returns as is. 145 | 146 | IMPORTANT: `:pathom/tx` is deprecated, and it's going to be dropped, if you are using it, please 147 | replace it with `:pathom/eql` to avoid breakages in the future." 148 | [env input] 149 | [map? 150 | (s/or :query ::eql/query 151 | :config (s/keys :req [(or :pathom/tx :pathom/eql :pathom/ast)] :opt [:pathom/entity])) 152 | => (s/keys :req [(or :pathom/tx :pathom/eql :pathom/ast)] :opt [:pathom/entity])] 153 | (cond->> 154 | (if (vector? input) 155 | {:pathom/eql input 156 | :pathom/entity {}} 157 | input) 158 | 159 | (or (:pathom/lenient-mode? input) 160 | (::p.error/lenient-mode? env)) 161 | (merge {:pathom/include-stats? true}))) 162 | 163 | (>defn extend-env 164 | [source-env env-extension] 165 | [map? (s/or :fn fn? :map map? :nil nil?) => map?] 166 | (if (fn? env-extension) 167 | (env-extension source-env) 168 | (merge source-env env-extension))) 169 | 170 | (defn boundary-env [env request] 171 | (if-let [x (find request :pathom/lenient-mode?)] 172 | (assoc env ::p.error/lenient-mode? (val x)) 173 | env)) 174 | 175 | (>defn boundary-interface 176 | "Returns a function that wraps the environment. When exposing Pathom to some external 177 | system, this is the recommended way to do it. The format here makes your API compatible 178 | with a Pathom Foreign process, which allows the integration of distributed environments. 179 | 180 | When calling the remote interface, the user can send a query or a map containing the 181 | query and the initial entity data. This map is open, and you can use as a way to extend 182 | the API. 183 | 184 | Boundary interface: 185 | 186 | ([env-ext request]) 187 | ([request]) 188 | 189 | Request is one of: 190 | 191 | 1. EQL request 192 | 2. A map, supported keys: 193 | :pathom/eql 194 | :pathom/ast 195 | :pathom/entity 196 | :pathom/include-stats? 197 | :pathom/lenient-mode? 198 | 199 | Env ext can be either a map to merge in the original env, or a function that transforms 200 | the env. 201 | " 202 | [env] [map? => fn?] 203 | (let [env' (pci/register env pcf/foreign-indexes-resolver)] 204 | (fn boundary-interface-internal 205 | ([env-extension request] 206 | (let [{:pathom/keys [eql entity ast include-stats?] :as request'} 207 | (normalize-input env request) 208 | env' (-> env' 209 | (boundary-env request) 210 | (extend-env env-extension) 211 | (assoc 212 | ::source-request request' 213 | ::pcr/omit-run-stats? (not include-stats?))) 214 | entity' (or entity {})] 215 | 216 | (try 217 | (if ast 218 | (process-ast (p.ent/with-entity env' entity') ast) 219 | (process env' entity' (or eql (:pathom/tx request')))) 220 | (catch #?(:clj Throwable :cljs :default) err 221 | (p.error/datafy-processor-error err))))) 222 | ([request] 223 | (boundary-interface-internal nil request))))) 224 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2025.01.16-alpha] 4 | - Add `p.path/at-path-string` helper 5 | - BREAKING: Error from plan now envelops the graph value to avoid printing too much. You need to call the `:graph-fn` key to get the value now. 6 | - BREAKING: Removed usage of `processor-error`, now the runner will not wrap the errors, this will generate simpler errors. 7 | - Expose query and entity on errors during eql interface calls 8 | - Expose planner failure details on error message 9 | - Much richer error details for when some user required attribute is missing 10 | - Track `::pcp/expects` on AND nodes, it includes all expects from the node chain 11 | - BREAKING: removed `p->` and `p->>` helpers from `com.wsscode.promesa.macros`. These were old macros and currently Promesa has them in the library. 12 | - Bump `com.wsscode/cljc-misc` to `2024.12.18` 13 | - Expose missing attributes during planner error 14 | - During planning, while computing dependencies, removed short-circuit to allow the planner to have complete information about unreachable attributes 15 | 16 | ## [2024.11.23-alpha] 17 | - Fix placeholders using different parameters 18 | - Idents run in parallel when using parallel processor (issue #208) 19 | - Fix wrong input order on nested resolvers (issue #205) 20 | - Fix `pf.eql/map-select` case on map container at query 21 | - Fix spec for `pco/?` 22 | - Merge params when merging nodes on planner (issue #216) 23 | - Ensure all instances of the same resolver in the same graph have the same params (issue #211) 24 | - In case of priority draw, pick the node with the most number of inputs already available (issue #202) 25 | - Parameterized attributes on placeholders are not processed at during parent planning 26 | - Add `::pcp/wrap-compute-run-graph` plugin entry point 27 | 28 | ## [2023.08.22-alpha] 29 | - BREAKING: `::p.error/missing-output` is now converged to `::p.error/attribute-missing` (issue #149) 30 | - BREAKING: Placeholders won't override entity data via params by default (issue #187) 31 | - New `pbip/placeholder-data-params` plugin to get back the placeholder entity data behavior 32 | - Mutation not found error goes through `::pcr/wrap-mutate`, allowing the user to capture and transform the not found error case 33 | - Remove `Insufficient data calling resolver ...` error due to problematic behavior with optionals (issue #192) 34 | - Improve runner error messages, elide ` at path ` part of the message when at root 35 | - Fix `process-one` return value when its `false` (previously it would return `nil` instead of `false`) (issue #195) 36 | - Include failing key on smart map errors 37 | - Support `applyTo` on resolvers and mutations (issue #203) 38 | 39 | ## [2023.01.31-alpha] 40 | - Fix map container handling on runner (issue #176) 41 | - `pf.eql/data->shape` now takes `::pcr/map-container?` into account 42 | - Disregard ident values on cache keys for the planner (issue #182) 43 | - Fix stack overflow on planning nested attribute cycles on dynamic resolvers (issue #179) 44 | - Fix name reporting on invalid config for resolvers and mutations (issue #181) 45 | - Fix missing data on nested batches (issues #173 & #177) 46 | - Fix planning issue when optimizing `OR` subpaths (issue #170) 47 | - Index nested attributes (issue #167) 48 | 49 | ## [2023.01.24-alpha] 50 | - Fix multiple inclusions of attribute error resolver (issue #175) 51 | - Fix stack overflow on nested attribute cycles (issue #168) 52 | - Fix batch waiting execution order (issue #169) 53 | 54 | ## [2022.10.19-alpha] 55 | - Fix `:or` destructuring inference when mapping it to a symbol destructuring 56 | 57 | ## [2022.10.18-alpha] 58 | - Fix error when user requests `::pcr/attribute-errors` in lenient mode 59 | - In `process-one` helpers, when the value is collection it gets the run stats from the parent 60 | - Add support for `:or` keyword on `defresolver` and `defmutation` args, values in `:or` will be flagged as optional 61 | - Set `::pco/inferred-params` when params are inferred 62 | - Instead of overriding `::pco/input` or `::pco/params` when explicitly set, now Pathom will merge that with the inferred input/params 63 | - Issue 159 - Fix placeholder nodes on dynamic resolvers 64 | 65 | ## [2022.08.29-alpha] 66 | - Fix ident processing on serial runner 67 | 68 | ## [2022.08.26-alpha] 69 | - Fix planning optimization on merging sibling nodes with OR parent 70 | - New branch optimizations are now opt-in via `::pcp/experimental-branch-optimizations` flag at env 71 | - Fix `::pcr/wrap-mutation-error`, now working in all runners 72 | 73 | ## [2022.08.25-alpha] - BROKEN, DON'T USE 74 | - Add support for `::pcr/wrap-merge-attribute` hook in async and parallel runners 75 | - Add support for `::pcr/wrap-merge-attribute` hook on idents (all runners) 76 | - Fix issue #152 missing shape check when data value is nil 77 | - Optimize AND siblings with same branches 78 | - Optimize OR siblings with same branches 🎉 79 | - Optimize AND branch that has same branch structure as parent 80 | 81 | ## [2022.07.08-alpha] 82 | - Fix: Exceptions that occur inside `p.a.eql/process` in JVM won't include the entire stacktrace anymore. (issue #142) 83 | - Add `::p.error/error-data` to datafied error object 84 | - Optimize nested AND branches on planner 85 | 86 | ## [2022.06.01-alpha] 87 | - Fix nested optional key throws "All paths from an OR node failed" (issue #139) 88 | 89 | ## [2022.05.19-alpha] 90 | - Fix spec issues with mutations 91 | - Fix async environment on async boundary interface 92 | 93 | ## [2022.05.17-alpha] 94 | - Fix nested check on deep dynamic resolver requirements 95 | - Fix bad optimization when invalid sub-query isn't the first branch option of a path 96 | - Fix multiple calls to same mutation (issue #137) 97 | - BREAKING: Mutations are not part of `index-ast` anymore 98 | - BREAKING: `::pcp/mutations` now contains the full call AST instead of the mutation key 99 | - Fix Optional key throws "All paths from an OR node failed" (issue #138) 100 | 101 | ## [2022.04.20-alpha] 102 | - Fix unnecessary resolver calls due to merging OR branches optimization 103 | - Add check on OR nodes to test for expectation done before start running 104 | - Upgrade guardrails to have its own Kondo configuration 105 | 106 | ## [2022.03.17-alpha] 107 | - Fix boundary interface detection of lenient mode to output stats 108 | 109 | ## [2022.03.07-alpha] 110 | - Run stats now includes details for transient (implicit) dependencies as well 111 | - Improve parallelism with placeholders 112 | - Placeholders with params are deal as a new entity (instead of an extension of the parent) 113 | 114 | ## [2022.03.04-alpha] 115 | - Add support for `::pco/batch-chunk-size` to how many items to batch at once 116 | - Batch process retains input order when making batch calls 117 | 118 | ## [2022.02.25-alpha] 119 | - `::pcp/expects` now can figure implicit attributes on static resolver nested lookup 120 | - Planner now removes path options that can't fulfill the sub-query 121 | - New priority sort algorithm. Thanks to @mszabo! 122 | - Optimize nested or nodes, pulling nested branches into parent OR node 123 | - Add weight sort feature to load balance between OR branches 124 | - Fix issue #107 regarding batch + nested optional inputs 125 | 126 | ## [2022.02.24-alpha] 127 | - Add `pf.eql/seq-data->query` to find shape of a combined collection 128 | - Computation of `::pcp/expects` on static resolvers now includes data about nested expectations 129 | 130 | ## [2022.02.21-alpha] 131 | - Bumped Promesa to 6.1.436 132 | - INTERNAL BREAK: the internal `plan-and-run!` from all runners now return env instead of the graph plan 133 | - Runner exceptions now return a wrapped error that includes environment data 134 | - Boundary interface errors are always data 135 | - Boundary interface omit stats by default, open with the :pathom/include-stats? flag on the request 136 | - Add `pbip/env-wrap-plugin` 137 | 138 | ## [2022.02.01-1-alpha] 139 | - Remove debugging tap 140 | 141 | ## [2022.02.01-alpha] 142 | - Fix error trigger when a dependency of some optional attribute fails (bug #126) 143 | - Fix planning errors on optional nested inputs (bug #127) 144 | 145 | ## [2022.01.28-alpha] 146 | - Improve optimization for single item branch, now support moving run-next to the child's edge 147 | - On union process, in case the item doesn't match any branch its turned into nil (and removed in case of union sequences) 148 | - Fix bug when checking for entity required data, OR cases could trigger a false positive 149 | 150 | ## [2022.01.09-alpha] 151 | - Fix `Throwable` warning on CLJS in parallel parser 152 | - Fix `log` source to avoid `*file*` warning 153 | 154 | ## [2021.12.10-alpha] 155 | - Add `pbip/dev-linter` to help find errors in operation definitions 156 | - Improve performance on input shape check 157 | - Fix error handling for batch resolvers on parallel processor 158 | 159 | ## [2021.11.16-alpha] 160 | - Add extension point `::p.error/wrap-attribute-error` 161 | - Fix batch calls with distinct parameters 162 | - Add `pco/final-value` helper to mark a value as final 163 | 164 | ## [2021.10.20-alpha] 165 | - Add optimizations to OR branches that are sub-paths of each other 166 | - Support new `::pco/cache-key` setting for custom cache keys at resolver level 167 | 168 | ## [2021.10.19-alpha] 169 | - Fix foreign mutation on async runner 170 | - Remove viz request snapshots to allow multiple connectors on the same meshed graph 171 | - Fix the flow of params on foreign-ast 172 | - Fix nested dependency process 173 | - Infer output shape in constantly resolver 174 | - Parallel processor 🎉 175 | 176 | ## [2021.08.14-alpha] 177 | - Fix cache store specs 178 | - Add `::pcr/wrap-process-sequence-item` plugin entry point 179 | - Add `filtered-sequence-items-plugin` new built-in plugin 180 | - Batch support for dynamic resolvers 181 | - Batch support on foreign requests 182 | - BREAKING CHANGE: Dynamic resolvers always get rich inputs with input data and foreign ast 183 | - Dynamic mutations also go as input parts with params 184 | 185 | ## [2021.07.30-alpha] 186 | - Fix lenient mode optional inputs, it was marking errors when it shouldn't 187 | 188 | ## [2021.07.27-alpha] 189 | - Support disable input destructuring validation on `pco/resolver` with the flag `::pco/disable-validate-input-destructuring?` 190 | - Run `::pco/transform` before running the resolver validations 191 | - Fixed bug when combining batch + disabled cache + missing outputs doing infinite loops 192 | 193 | ## [2021.07.23-alpha] 194 | - Add `p.eql/process-one` and `p.a.eql/process-one` helpers 195 | 196 | ## [2021.07.19-alpha] 197 | - BREAKING CHANGE: Strict mode by default, now errors surface quickly 198 | - BREAKING CHANGE: Remove `remove-stats-plugin`, now must use the env flag `:com.wsscode.pathom3.connect.runner/omit-run-stats?` instead 199 | - Optional lenient mode via setting `:com.wsscode.pathom3.error/lenient-mode? true` on env 200 | - Boundary interface now accepts `:pathom/lenient-mode?` so the client can configure it 201 | - `attribute-errors-plugin` is deprecated, when using lenient mode that behavior comes automatically 202 | - Fix async runner reversing lists 203 | - Add `ctry` helper to handle exceptions in sync and async at same time 204 | - Foreign connection errors get wrapped to enrich the error context 205 | - Add spec for `::pci/index-source-id` 206 | - Detect cycles in nested inputs to prevent stack overflow at planner 207 | - Support foreign unions 208 | - Entities can decide union path via `::pf.eql/union-entry-key` 209 | 210 | ## [2021.07.10-1-alpha] 211 | - Add more info to pom.xml to add repository links from clojars and cljdoc 212 | 213 | ## [2021.07.10-alpha] 214 | - Add transit dependencies to fix cljdoc compilation 215 | 216 | ## [2021.07.9-alpha] 217 | - Initial JAR release 218 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/interface/eql_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.interface.eql-test 2 | (:require 3 | [check.core :refer [=> check]] 4 | [clojure.test :refer [deftest is testing]] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.indexes :as pci] 7 | [com.wsscode.pathom3.connect.operation :as pco] 8 | [com.wsscode.pathom3.entity-tree :as p.ent] 9 | [com.wsscode.pathom3.interface.eql :as p.eql] 10 | [com.wsscode.pathom3.test.geometry-resolvers :as geo] 11 | [com.wsscode.pathom3.test.helpers :as h] 12 | [edn-query-language.core :as eql])) 13 | 14 | (pco/defresolver coords [] 15 | {::coords 16 | [{:x 10 :y 20} 17 | {::geo/left 20 ::geo/width 5}]}) 18 | 19 | (def registry 20 | [geo/full-registry 21 | coords 22 | (pbir/constantly-fn-resolver :foo ::foo) 23 | (pbir/constantly-resolver :false false)]) 24 | 25 | (deftest process-test 26 | (testing "read" 27 | (is (= (p.eql/process (pci/register registry) 28 | [::coords]) 29 | {::coords 30 | [{:x 10 :y 20} 31 | {::geo/left 20 ::geo/width 5}]})) 32 | 33 | (is (= (p.eql/process (pci/register geo/full-registry) 34 | {:left 10} 35 | [::geo/x]) 36 | {::geo/x 10})) 37 | 38 | (testing "when not found, key is omitted" 39 | (is (= (p.eql/process (-> (pci/register 40 | {:com.wsscode.pathom3.error/lenient-mode? true} 41 | geo/full-registry) 42 | (p.ent/with-entity {:left 10})) 43 | [::geo/top]) 44 | {:com.wsscode.pathom3.connect.runner/attribute-errors {:com.wsscode.pathom3.test.geometry-resolvers/top {:com.wsscode.pathom3.error/cause :com.wsscode.pathom3.error/attribute-unreachable}}})))) 45 | 46 | (testing "reading with *" 47 | (is (= (-> (p.eql/process (-> (pci/register geo/full-registry) 48 | (p.ent/with-entity {:left 10})) 49 | [::geo/x '*])) 50 | {::geo/x 10 51 | ::geo/left 10 52 | :left 10}))) 53 | 54 | (testing "nested read" 55 | (is (= (p.eql/process (-> (pci/register geo/full-registry) 56 | (p.ent/with-entity {:left 10 :top 5})) 57 | [{::geo/turn-point [:right]}]) 58 | {::geo/turn-point {:right 10}})) 59 | 60 | (is (= (p.eql/process (-> (pci/register geo/full-registry) 61 | (p.ent/with-entity {:foo {::geo/x 10} 62 | :bar {::geo/y 4 63 | :mess "here"} 64 | ::geo/width 50 65 | :other "value"})) 66 | [{:foo [:x]} 67 | {:bar [:top]} 68 | :width]) 69 | {:foo {:x 10} 70 | :bar {:top 4} 71 | :width 50}))) 72 | 73 | (testing "process sequence" 74 | (is (= (p.eql/process (-> {:com.wsscode.pathom3.error/lenient-mode? true} 75 | (pci/register registry) 76 | (p.ent/with-entity {::coords (list 77 | {:x 10 :y 20} 78 | {::geo/left 20 ::geo/width 5})})) 79 | [{::coords [:right]}]) 80 | {::coords [{:com.wsscode.pathom3.connect.runner/attribute-errors {:right {:com.wsscode.pathom3.error/cause :com.wsscode.pathom3.error/attribute-unreachable}}} 81 | {:right 25}]}))) 82 | 83 | (testing "process vector" 84 | (let [res (p.eql/process (-> (pci/register registry) 85 | (p.ent/with-entity {::coords [{::geo/left 20 ::geo/width 15} 86 | {::geo/left 20 ::geo/width 5}]})) 87 | [{::coords [:right]}])] 88 | (is (= res {::coords [{:right 35} {:right 25}]})) 89 | (is (vector? (::coords res))))) 90 | 91 | (testing "process set" 92 | (is (= (p.eql/process (-> (pci/register registry) 93 | (p.ent/with-entity {::coords #{{::geo/left 20 ::geo/width 15} 94 | {::geo/left 20 ::geo/width 5}}})) 95 | [{::coords [:right]}]) 96 | {::coords #{{:right 35} {:right 25}}})))) 97 | 98 | (deftest process-error-test 99 | (check 100 | (h/catch-exception 101 | (p.eql/process 102 | (pci/register 103 | (pco/resolver 'a 104 | {::pco/output [:a]} 105 | (fn [_ _] {}))) 106 | [:a])) 107 | => {:ex/message "Error while processing request [:a] for entity {}" 108 | :ex/data {:entity {} 109 | :tx [:a]}}) 110 | 111 | (testing "caps request on ex message" 112 | (check 113 | (h/catch-exception 114 | (p.eql/process 115 | (pci/register 116 | (pco/resolver 'a 117 | {::pco/output [:a]} 118 | (fn [_ _] {}))) 119 | [:having-a-query {:that-goes [:long]} :in-fact {:very [:long]}])) 120 | => {:ex/message "Error while processing request [:having-a-query {:that-goes [:long]}... for entity {}" 121 | :ex/data {:entity {} 122 | :tx [:having-a-query {:that-goes [:long]} :in-fact {:very [:long]}]}})) 123 | 124 | (testing "caps entity on ex message" 125 | (check 126 | (h/catch-exception 127 | (p.eql/process 128 | (pci/register 129 | (pco/resolver 'a 130 | {::pco/output [:a]} 131 | (fn [_ _] {}))) 132 | {:some "Lorem Ipsum is simply dummy text of the printing and typesetting"} 133 | [:a])) 134 | => {:ex/message "Error while processing request [:a] for entity {:some \"Lorem Ipsum is simply dummy t...", 135 | :ex/data {:entity {:some "Lorem Ipsum is simply dummy text of the printing and typesetting"} 136 | :tx [:a]}})) 137 | 138 | (testing "ex data entity contains accumulated data while ex message shows the input entity" 139 | (check 140 | (h/catch-exception 141 | (p.eql/process 142 | (pci/register 143 | [(pco/resolver 'b 144 | {::pco/input [:a] 145 | ::pco/output [:b]} 146 | (fn [_ _] {:b 2})) 147 | (pco/resolver 'c 148 | {::pco/input [:b] 149 | ::pco/output [:c]} 150 | (fn [_ _] {}))]) 151 | {:a 1} 152 | [:c])) 153 | => {:ex/message "Error while processing request [:c] for entity {:a 1}" 154 | :ex/data {:entity {:a 1 :b 2} 155 | :tx [:c]}}))) 156 | 157 | (deftest process-one-test 158 | (is (= (p.eql/process-one (pci/register registry) {:left 10 :right 30} :width) 159 | 20)) 160 | 161 | (is (= (p.eql/process-one (pci/register geo/full-registry) 162 | {:left 10 :top 5} 163 | {::geo/turn-point [:right]}) 164 | {:right 10})) 165 | 166 | (testing "keeps meta" 167 | (let [response (p.eql/process-one 168 | (pci/register 169 | [(pbir/constantly-resolver :items [{:a 1}])]) 170 | :items)] 171 | (is (= response [{:a 1}])) 172 | (check 173 | (meta response) 174 | => {:com.wsscode.pathom3.connect.runner/run-stats 175 | {:com.wsscode.pathom3.connect.planner/source-ast {}, 176 | :com.wsscode.pathom3.connect.planner/index-attrs {}, 177 | :com.wsscode.pathom3.connect.planner/user-request-shape {}, 178 | :com.wsscode.pathom3.connect.planner/root number?, 179 | :com.wsscode.pathom3.connect.planner/available-data {}, 180 | :com.wsscode.pathom3.connect.runner/node-run-stats {}, 181 | :com.wsscode.pathom3.connect.planner/index-ast {}, 182 | :com.wsscode.pathom3.connect.runner/transient-stats {}, 183 | :com.wsscode.pathom3.connect.planner/index-resolver->nodes {}, 184 | :com.wsscode.pathom3.connect.planner/nodes {}}})) 185 | 186 | (testing "don't change data when its already there" 187 | (let [response (p.eql/process-one 188 | (pci/register 189 | [(pbir/constantly-resolver :items {:a 1}) 190 | (pbir/alias-resolver :a :b)]) 191 | {:items [:b]})] 192 | (is (= response {:b 1})) 193 | (check 194 | (meta response) 195 | => {:com.wsscode.pathom3.connect.runner/run-stats 196 | {:com.wsscode.pathom3.connect.planner/available-data 197 | {:a {}}}}))) 198 | 199 | (testing "returns false" 200 | (is (= (p.eql/process-one (pci/register registry) :false) 201 | false))))) 202 | 203 | (defn run-boundary-interface [env request] 204 | (let [fi (p.eql/boundary-interface env)] 205 | (fi request))) 206 | 207 | (deftest boundary-interface-test 208 | (let [fi (p.eql/boundary-interface (pci/register registry))] 209 | (testing "call with just tx" 210 | (is (= (fi [::coords]) 211 | {::coords 212 | [{:x 10 :y 20} 213 | {::geo/left 20 ::geo/width 5}]}))) 214 | 215 | (testing "call with ast" 216 | (is (= (fi {:pathom/ast (eql/query->ast [::coords])}) 217 | {::coords 218 | [{:x 10 :y 20} 219 | {::geo/left 20 ::geo/width 5}]}))) 220 | 221 | (testing "call with entity and eql" 222 | (is (= (fi {:pathom/entity {:left 10} 223 | :pathom/eql [:x]}) 224 | {:x 10}))) 225 | 226 | (testing "call with entity and tx" 227 | (is (= (fi {:pathom/entity {:left 10} 228 | :pathom/tx [:x]}) 229 | {:x 10}))) 230 | 231 | (testing "merge env" 232 | (is (= (fi [:foo]) 233 | {:foo nil})) 234 | 235 | (is (= (fi {::foo "bar"} [:foo]) 236 | {:foo "bar"}))) 237 | 238 | (testing "modify env" 239 | (is (= (fi #(pci/register % (pbir/constantly-resolver :new "value")) [:new]) 240 | {:new "value"}))) 241 | 242 | (testing "lenient mode" 243 | (is (= (fi {:pathom/eql [:invalid] 244 | :pathom/lenient-mode? true}) 245 | {:com.wsscode.pathom3.connect.runner/attribute-errors {:invalid {:com.wsscode.pathom3.error/cause :com.wsscode.pathom3.error/attribute-unreachable}}})) 246 | 247 | (testing "lenient mode from env" 248 | (let [fi (p.eql/boundary-interface (assoc (pci/register registry) :com.wsscode.pathom3.error/lenient-mode? true))] 249 | (is (= (fi {:pathom/eql [:invalid]}) 250 | {:com.wsscode.pathom3.connect.runner/attribute-errors {:invalid {:com.wsscode.pathom3.error/cause :com.wsscode.pathom3.error/attribute-unreachable}}}))))))) 251 | 252 | (deftest boundary-interface-include-stats-test 253 | (testing "omit stats by default" 254 | (is (nil? 255 | (-> (run-boundary-interface 256 | (pci/register 257 | [(pbir/constantly-resolver :a 10)]) 258 | {:pathom/eql [:a]}) 259 | meta 260 | :com.wsscode.pathom3.connect.runner/run-stats)))) 261 | 262 | (testing "include when requested" 263 | (is (some? 264 | (-> (run-boundary-interface 265 | (pci/register 266 | [(pbir/constantly-resolver :a 10)]) 267 | {:pathom/eql [:a] 268 | :pathom/include-stats? true}) 269 | meta 270 | :com.wsscode.pathom3.connect.runner/run-stats))))) 271 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/format/eql_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.format.eql-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [com.wsscode.misc.coll :as coll] 5 | [com.wsscode.pathom3.connect.runner :as pcr] 6 | [com.wsscode.pathom3.format.eql :as pf.eql] 7 | [com.wsscode.pathom3.plugin :as p.plugin] 8 | [edn-query-language.core :as eql])) 9 | 10 | (deftest query-root-properties-test 11 | (is (= (pf.eql/query-root-properties [{:a [:b]} :c]) 12 | [:a :c])) 13 | 14 | (is (= (pf.eql/query-root-properties {:foo [{:a [:b]} :c] 15 | :bar [:a :d]}) 16 | [:a :c :d]))) 17 | 18 | (deftest union-children?-test 19 | (is (true? (pf.eql/union-children? (eql/query->ast1 [{:union {:a [:foo]}}])))) 20 | (is (false? (pf.eql/union-children? (eql/query->ast1 [:standard]))))) 21 | 22 | (deftest maybe-merge-union-ast-test 23 | (is (= (-> [{:union {:a [:foo] 24 | :b [:bar]}}] 25 | (eql/query->ast1) 26 | (pf.eql/maybe-merge-union-ast) 27 | (eql/ast->query)) 28 | [{:union [:foo :bar]}])) 29 | 30 | (is (= (-> [{:not-union [:baz]}] 31 | (eql/query->ast1) 32 | (pf.eql/maybe-merge-union-ast) 33 | (eql/ast->query)) 34 | [{:not-union [:baz]}]))) 35 | 36 | (deftest ident-key-test 37 | (is (= (pf.eql/ident-key [:foo "bar"]) 38 | :foo))) 39 | 40 | (deftest index-ast-test 41 | (is (= (pf.eql/index-ast (eql/query->ast [:foo {:bar [:baz]}])) 42 | {:foo {:type :prop, :dispatch-key :foo, :key :foo}, 43 | :bar {:type :join, 44 | :dispatch-key :bar, 45 | :key :bar, 46 | :query [:baz], 47 | :children [{:type :prop, :dispatch-key :baz, :key :baz}]}})) 48 | 49 | (testing "remove *" 50 | (is (= (pf.eql/index-ast (eql/query->ast [:foo '*])) 51 | {:foo {:type :prop, :dispatch-key :foo, :key :foo},})))) 52 | 53 | (def protected-list #{:foo}) 54 | 55 | (p.plugin/defplugin elide-specials 56 | {::pf.eql/wrap-map-select-entry 57 | (fn [mst] 58 | (fn [env source {:keys [key] :as ast}] 59 | (if (and (contains? source key) 60 | (contains? protected-list key)) 61 | (coll/make-map-entry key "Protected value") 62 | (mst env source ast))))}) 63 | 64 | (defrecord RecordSample [foo]) 65 | 66 | (deftest map-select-test 67 | (is (= (pf.eql/map-select {} {} [:foo :bar]) 68 | {})) 69 | 70 | (is (= (pf.eql/map-select {} {:foo 123} [:foo :bar]) 71 | {:foo 123})) 72 | 73 | (is (= (pf.eql/map-select {} {:foo {:a 1 :b 2}} [{:foo [:b]}]) 74 | {:foo {:b 2}})) 75 | 76 | (testing "process vector" 77 | (is (= (pf.eql/map-select {} {:foo [{:a 1 :b 2} 78 | {:c 1 :b 1} 79 | {:a 1 :c 1} 80 | 3]} 81 | [{:foo [:b]}]) 82 | {:foo [{:b 2} {:b 1} {} 3]}))) 83 | 84 | (testing "process map container" 85 | (is (= (pf.eql/map-select {} {:foo ^::pcr/map-container? {:x {:a 1 :b 2} 86 | :y {:a 3 :b 4}}} 87 | [{:foo [:b]}]) 88 | {:foo {:x {:b 2} 89 | :y {:b 4}}})) 90 | 91 | (testing "from query" 92 | (is (= (pf.eql/map-select {} {:foo {:x {:a 1 :b 2} 93 | :y {:a 3 :b 4}}} 94 | [{'(:foo {::pcr/map-container? true}) [:b]}]) 95 | {:foo {:x {:b 2} 96 | :y {:b 4}}})))) 97 | 98 | (testing "recursive query" 99 | (is (= (pf.eql/map-select {} 100 | {:x "a" 101 | :y "aa" 102 | :c [{:x "b" 103 | :bla "bb" 104 | :c [{:x "c" 105 | :whatever "d"}]}]} 106 | [:x {:c '...}]) 107 | {:x "a" 108 | :c [{:x "b" 109 | :c [{:x "c"}]}]}))) 110 | 111 | (testing "retain set type" 112 | (is (= (pf.eql/map-select {} {:foo #{{:a 1 :b 2} 113 | {:c 1 :b 1}}} 114 | [{:foo [:b]}]) 115 | {:foo #{{:b 2} {:b 1}}}))) 116 | 117 | (testing "retain list order" 118 | (is (= (pf.eql/map-select {} {:foo (list 1 2)} 119 | [:foo]) 120 | {:foo (list 1 2)}))) 121 | 122 | (testing "union" 123 | (is (= (pf.eql/map-select {} {:foo [{:a 1 :aa 2 :aaa 3} 124 | {:b 2 :bb 10 :bbb 20} 125 | {:c 3 :cc 30 :ccc 300}]} 126 | [{:foo {:a [:aa] 127 | :b [:b] 128 | :c [:ccc]}}]) 129 | {:foo [{:aa 2} {:b 2} {:ccc 300}]}))) 130 | 131 | (testing "*" 132 | (is (= (pf.eql/map-select {} {:foo 1 :bar 2} [:foo '*]) 133 | {:foo 1 :bar 2}))) 134 | 135 | (testing "extended" 136 | (is (= (pf.eql/map-select (p.plugin/register elide-specials) {:foo 1 :bar 2} [:foo :bar]) 137 | {:foo "Protected value" :bar 2})) 138 | 139 | (is (= (pf.eql/map-select (p.plugin/register elide-specials) {:deep {:foo "bar"}} 140 | ['*]) 141 | {:deep {:foo "Protected value"}})) 142 | 143 | (is (= (pf.eql/map-select (p.plugin/register elide-specials) {:deep {:foo "bar"}} 144 | [:deep]) 145 | {:deep {:foo "Protected value"}}))) 146 | 147 | (testing "custom records" 148 | (let [record (->RecordSample "bar")] 149 | (is (= (pf.eql/map-select {} {:foo record} [:foo]) 150 | {:foo record})))) 151 | 152 | (testing "special case: mutations with error" 153 | (is (= (pf.eql/map-select {} 154 | {'foo {:com.wsscode.pathom3.connect.runner/mutation-error "x"}} 155 | '[{(foo) [:bar]}]) 156 | {'foo {:com.wsscode.pathom3.connect.runner/mutation-error "x"}})))) 157 | 158 | (deftest data->query-test 159 | (is (= (pf.eql/data->query {}) [])) 160 | (is (= (pf.eql/data->query {:foo "bar"}) [:foo])) 161 | (is (= (pf.eql/data->query {:b 2 :a 1}) [:a :b])) 162 | (is (= (pf.eql/data->query {:foo {:buz "bar"}}) [{:foo [:buz]}])) 163 | (is (= (pf.eql/data->query {:foo [{:buz "bar"}]}) [{:foo [:buz]}])) 164 | (is (= (pf.eql/data->query {:other "key" [:complex "key"] "value"}) [:other [:complex "key"]])) 165 | (is (= (pf.eql/data->query {:foo ["abc"]}) [:foo])) 166 | (is (= (pf.eql/data->query {:foo [{:buz "baz"} {:it "nih"}]}) [{:foo [:buz :it]}])) 167 | (is (= (pf.eql/data->query {:foo [{:buz "baz"} "abc" {:it "nih"}]}) [{:foo [:buz :it]}])) 168 | (is (= (pf.eql/data->query {:z 10 :a 1 :b {:d 3 :e 4}}) [:a {:b [:d :e]} :z])) 169 | (is (= (pf.eql/data->query {:a {"foo" {:bar "baz"}}}) [:a])) 170 | (is (= (pf.eql/data->query {:a ^::pcr/map-container? {"foo" {:bar "baz"}}}) [{:a [:bar]}])) 171 | (is (= (pf.eql/data->query {:a ^::pcr/map-container? {"foo" {:bar "baz"} 172 | "other" {:z 1}}}) [{:a [:bar :z]}]))) 173 | 174 | (deftest seq-data->query-test 175 | (is (= (pf.eql/seq-data->query [{:a 1} {:b 2}]) [:a :b]))) 176 | 177 | (deftest ast-contains-wildcard?-test 178 | (is (false? (pf.eql/ast-contains-wildcard? (eql/query->ast [:foo])))) 179 | (is (true? (pf.eql/ast-contains-wildcard? (eql/query->ast [:foo '*]))))) 180 | 181 | (deftest pick-union-entry-test 182 | (is (= (pf.eql/pick-union-entry (eql/query->ast1 [{:foo {:a [:x] :b [:y]}}]) 183 | {:b 1}) 184 | {:type :root, :union-key :b 185 | :children [{:type :prop, :dispatch-key :y, :key :y}]})) 186 | 187 | (testing "via meta on data, which has higher priority than the data" 188 | (is (= (pf.eql/pick-union-entry (eql/query->ast1 [{:foo {:a [:x] :b [:y]}}]) 189 | ^{::pf.eql/union-entry-key :a} {:b 1}) 190 | {:type :root, :union-key :a 191 | :children [{:type :prop, :dispatch-key :x, :key :x}]})))) 192 | 193 | (deftest merge-ast-children-test 194 | (is (= (pf.eql/merge-ast-children 195 | (eql/query->ast []) 196 | (eql/query->ast [])) 197 | (eql/query->ast []))) 198 | 199 | (is (= (pf.eql/merge-ast-children 200 | (eql/query->ast [:a]) 201 | (eql/query->ast [:b])) 202 | (eql/query->ast [:a :b]))) 203 | 204 | (is (= (pf.eql/merge-ast-children 205 | nil 206 | (eql/query->ast [:b])) 207 | {:type :join, :children [{:type :prop, :dispatch-key :b, :key :b}]})) 208 | 209 | (is (= (pf.eql/merge-ast-children 210 | (eql/query->ast [:a]) 211 | (eql/query->ast [(list :a {:foo "bar"})])) 212 | (eql/query->ast [:a]))) 213 | 214 | (is (= (pf.eql/merge-ast-children 215 | nil 216 | (eql/query->ast1 [:b])) 217 | (eql/query->ast1 [:b]))) 218 | 219 | (is (= (pf.eql/merge-ast-children 220 | (eql/query->ast [:a]) 221 | (eql/query->ast [:a])) 222 | {:type :root, 223 | :children [{:type :prop, :dispatch-key :a, :key :a}]})) 224 | 225 | (is (= (pf.eql/merge-ast-children 226 | (eql/query->ast1 [:a]) 227 | (eql/query->ast1 [:a])) 228 | {:type :prop, :dispatch-key :a, :key :a})) 229 | 230 | (is (= (pf.eql/merge-ast-children 231 | (eql/query->ast1 [:a]) 232 | (eql/query->ast1 [{:a [:b]}])) 233 | {:type :join, 234 | :dispatch-key :a, 235 | :key :a, 236 | :children [{:type :prop, :dispatch-key :b, :key :b}]})) 237 | 238 | (is (= (pf.eql/merge-ast-children 239 | (eql/query->ast [{:a [:b]}]) 240 | (eql/query->ast [:a])) 241 | {:type :root, 242 | :children [{:type :join, 243 | :dispatch-key :a, 244 | :key :a, 245 | :children [{:type :prop, :dispatch-key :b, :key :b}]}]})) 246 | 247 | (is (= (pf.eql/merge-ast-children 248 | (eql/query->ast [{:a [:b]}]) 249 | (eql/query->ast [{:a [{:b [:c]}]}])) 250 | {:type :root, 251 | :children [{:type :join, 252 | :dispatch-key :a, 253 | :key :a, 254 | :children [{:type :join, 255 | :dispatch-key :b, 256 | :key :b, 257 | :children [{:type :prop, :dispatch-key :c, :key :c}]}]}]})) 258 | 259 | (is (= (pf.eql/merge-ast-children 260 | (eql/query->ast [{:a [:b]}]) 261 | (eql/query->ast [{:a [{:c [:d]}]}])) 262 | {:type :root, 263 | :children [{:type :join, 264 | :dispatch-key :a, 265 | :key :a, 266 | :children [{:type :prop, :dispatch-key :b, :key :b} 267 | {:type :join, 268 | :dispatch-key :c, 269 | :key :c, 270 | :children [{:type :prop, :dispatch-key :d, :key :d}]}]}]}))) 271 | 272 | 273 | (defn with-rs [x] 274 | (with-meta x {:com.wsscode.pathom3.connect.runner/run-stats {}})) 275 | 276 | (deftest stats-value?-test 277 | (is (= (pf.eql/stats-value? {}) 278 | false)) 279 | (is (= (pf.eql/stats-value? (with-rs {})) 280 | true)) 281 | (is (= (pf.eql/stats-value? [(with-rs {})]) 282 | true)) 283 | (is (= (pf.eql/stats-value? [{}]) 284 | false)) 285 | (is (= (pf.eql/stats-value? 3) 286 | false)) 287 | (is (= (pf.eql/stats-value? true) 288 | false)) 289 | (is (= (pf.eql/stats-value? "foo") 290 | false))) 291 | 292 | (deftest select-stats-data-test 293 | (is (= (pf.eql/select-stats-data 294 | {:foo "bar" 295 | :other (with-rs {:a 1 296 | :b (with-rs {:d 1})}) 297 | :more [(with-rs {:b 1}) 298 | (with-rs {:b 2}) 299 | (with-rs {:b 3})]}) 300 | {:other {:b {}}, :more [{} {} {}]}))) 301 | 302 | (deftest cacheable-ast-test 303 | (is (= (pf.eql/cacheable-ast 304 | (eql/query->ast [{[:foo "bar"] [:x]}])) 305 | {:type :root, 306 | :children [{:type :join, 307 | :dispatch-key :foo, 308 | :query [:x], 309 | :children [{:type :prop, :dispatch-key :x, :key :x}]}]}))) 310 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/format/shape_descriptor.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.format.shape-descriptor 2 | "Shape descriptor is a format to describe data. This format optimizes for fast detection 3 | of value present given a shape and a value path. 4 | 5 | This namespace contains functions to operate on maps in the shape descriptor format." 6 | (:require 7 | [clojure.spec.alpha :as s] 8 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 9 | [com.wsscode.misc.coll :as coll] 10 | [com.wsscode.misc.refs :as refs] 11 | [com.wsscode.pathom3.placeholder :as pph] 12 | [edn-query-language.core :as eql])) 13 | 14 | (>def ::shape-descriptor 15 | "Describes the shape of a nested map using maps, this is a way to efficiently check 16 | for the presence of a specific path on data." 17 | (s/map-of any? ::shape-descriptor)) 18 | 19 | (defn merge-shapes 20 | "Deep merge of shapes, it takes in account that values are always maps." 21 | ([a] a) 22 | ([a b] 23 | (cond 24 | (and (map? a) (map? b)) 25 | (with-meta (merge-with merge-shapes a b) 26 | (merge (meta a) (meta b))) 27 | 28 | (map? a) a 29 | (map? b) b 30 | 31 | :else b))) 32 | 33 | (defn data->shape-descriptor 34 | "Helper function to transform a map into an shape descriptor. 35 | 36 | Edges of shape descriptor are always an empty map. If a value of the map is a sequence. 37 | This will combine the keys present in all items on the final shape description. 38 | 39 | WARN: this idea of merging is still under test, this may change in the future." 40 | [data] 41 | (if (map? data) 42 | (reduce-kv 43 | (fn [out k v] 44 | (assoc out 45 | k 46 | (cond 47 | (map? v) 48 | (data->shape-descriptor v) 49 | 50 | (sequential? v) 51 | (let [shape (reduce 52 | (fn [q x] 53 | (coll/merge-grow q (data->shape-descriptor x))) 54 | {} 55 | v)] 56 | (if (seq shape) 57 | shape 58 | {})) 59 | 60 | :else 61 | {}))) 62 | {} 63 | data))) 64 | 65 | (defn data->shape-descriptor-shallow 66 | "Like data->shape-descriptor, but only at the root keys of the data." 67 | [data] 68 | (zipmap (keys data) (repeat {}))) 69 | 70 | (defn shape-params [shape-value params] 71 | (vary-meta shape-value assoc ::params params)) 72 | 73 | (>defn ast->shape-descriptor 74 | "Convert EQL AST to shape descriptor format." 75 | [ast] 76 | [:edn-query-language.ast/node => ::shape-descriptor] 77 | (reduce 78 | (fn [m {:keys [key type children params] :as node}] 79 | (if (refs/kw-identical? :union type) 80 | (let [unions (into [] (map ast->shape-descriptor) children)] 81 | (reduce merge-shapes m unions)) 82 | (assoc m key (cond-> (ast->shape-descriptor node) 83 | (seq params) 84 | (shape-params params))))) 85 | {} 86 | (:children ast))) 87 | 88 | (>defn query->shape-descriptor 89 | "Convert pathom output format into shape descriptor format." 90 | [output] 91 | [:edn-query-language.core/query => ::shape-descriptor] 92 | (ast->shape-descriptor (eql/query->ast output))) 93 | 94 | (>defn shape-descriptor->ast-children 95 | "Convert pathom output format into shape descriptor format." 96 | [shape] 97 | [::shape-descriptor => vector?] 98 | (let [union? (-> shape meta ::union?)] 99 | (if union? 100 | [{:type :union 101 | :children (into [] 102 | (map (fn [[uk uv]] 103 | {:type :union-entry 104 | :union-key uk 105 | :children (shape-descriptor->ast-children uv)})) 106 | shape)}] 107 | 108 | (into [] 109 | (map (fn [[k v]] 110 | (let [params (-> v meta ::params)] 111 | (cond-> {:type :prop 112 | :key k 113 | :dispatch-key k} 114 | (seq v) 115 | (assoc 116 | :type :join 117 | :children (shape-descriptor->ast-children v)) 118 | 119 | (seq params) 120 | (assoc :params params))))) 121 | shape)))) 122 | 123 | (>defn shape-descriptor->ast 124 | "Convert pathom output format into shape descriptor format." 125 | [shape] 126 | [::shape-descriptor => map?] 127 | {:type :root 128 | :children (shape-descriptor->ast-children shape)}) 129 | 130 | (>defn shape-descriptor->query 131 | "Convert shape descriptor format to EQL." 132 | [shape] 133 | [::shape-descriptor => (s/or :eql :edn-query-language.core/query 134 | :union map?)] 135 | (let [union? (-> shape meta ::union?)] 136 | (into (if union? 137 | {} 138 | []) 139 | (map (fn [[k v]] 140 | (let [params (-> v meta ::params)] 141 | (cond-> (if (or (seq v) union?) 142 | {k (shape-descriptor->query v)} 143 | k) 144 | (seq params) 145 | (list params))))) 146 | shape))) 147 | 148 | (defn relax-empty-collections 149 | "This helper will remove nested requirements when data is an empty collection. This 150 | allows for nested inputs with empty collections to still be valid in shape." 151 | [required data] 152 | (reduce 153 | (fn [r [k v]] 154 | (cond 155 | (and (contains? r k) 156 | (coll/collection? v) 157 | (empty? v)) 158 | (assoc r k {}) 159 | 160 | (and (contains? r k) 161 | (not= (get r k) {})) 162 | (update r k relax-empty-collections v) 163 | 164 | :else 165 | r)) 166 | required 167 | (cond 168 | (map? data) 169 | data 170 | 171 | (coll/collection? data) 172 | (first data) 173 | 174 | :else 175 | nil))) 176 | 177 | (>defn missing 178 | "Given some available and required shapes, returns which items are missing from available 179 | in the required. Returns nil when nothing is missing." 180 | ([available-shape required-shape] 181 | [::shape-descriptor ::shape-descriptor 182 | => (? ::shape-descriptor)] 183 | (let [res (into 184 | {} 185 | (keep (fn [el] 186 | (let [attr (key el) 187 | sub-query (val el)] 188 | (if (contains? available-shape attr) 189 | (if-let [sub-req (and (seq sub-query) 190 | (missing (get available-shape attr) sub-query))] 191 | (coll/make-map-entry attr sub-req)) 192 | el)))) 193 | required-shape)] 194 | (if (seq res) res))) 195 | ([available required data] 196 | [::shape-descriptor ::shape-descriptor map? => (? ::shape-descriptor)] 197 | (missing available (relax-empty-collections required data)))) 198 | 199 | (>defn missing-from-data 200 | "Like missing, but starts from data instead of shape. If you are starting from data 201 | prefer this over missing, this can perform better by avoiding scanning the whole 202 | available data to build a shape, when the required-shape is a sub-set of the available 203 | data." 204 | ([available-data required-shape] 205 | [(? map?) ::shape-descriptor 206 | => (? ::shape-descriptor)] 207 | (if (nil? available-data) 208 | nil 209 | (let [res (into 210 | {} 211 | (keep (fn [el] 212 | (let [attr (key el) 213 | sub-shape (val el) 214 | sub-value (get available-data attr)] 215 | (if (contains? available-data attr) 216 | (if (seq sub-shape) 217 | (if (coll/collection? sub-value) 218 | (let [shape (reduce merge-shapes {} 219 | (mapv 220 | #(missing-from-data % sub-shape) 221 | sub-value))] 222 | (if (seq shape) 223 | (coll/make-map-entry attr shape))) 224 | (if-let [sub-req (missing-from-data sub-value sub-shape)] 225 | (coll/make-map-entry attr sub-req)))) 226 | el)))) 227 | required-shape)] 228 | (if (seq res) res))))) 229 | 230 | (>defn difference 231 | "Like set/difference, for shapes." 232 | [s1 s2] 233 | [(? ::shape-descriptor) (? ::shape-descriptor) => ::shape-descriptor] 234 | (reduce-kv 235 | (fn [out k sub] 236 | (if-let [x (find s2 k)] 237 | (let [v (val x)] 238 | (if (and (seq sub) (seq v)) 239 | (let [sub-diff (difference sub v)] 240 | (if (seq sub-diff) 241 | (assoc out k sub-diff) 242 | out)) 243 | out)) 244 | (assoc out k sub))) 245 | (or (empty s1) {}) 246 | s1)) 247 | 248 | (>defn intersection 249 | "Like set/intersection, for shapes." 250 | [s1 s2] 251 | [(? ::shape-descriptor) (? ::shape-descriptor) => ::shape-descriptor] 252 | (reduce-kv 253 | (fn [out k sub] 254 | (if-let [x (find s2 k)] 255 | (let [v (val x) 256 | meta (merge (meta sub) (meta v))] 257 | (if (and (seq sub) (seq v)) 258 | (let [sub-inter (intersection sub v)] 259 | (if (seq sub-inter) 260 | (assoc out k (with-meta sub-inter meta)) 261 | (assoc out k (with-meta {} meta)))) 262 | (assoc out k (with-meta {} meta)))) 263 | out)) 264 | (or (empty s1) {}) 265 | s1)) 266 | 267 | (>defn select-shape 268 | "Select the parts of data covered by shape. This is similar to select-keys, but for 269 | nested shapes." 270 | [data shape] 271 | [map? ::shape-descriptor => map?] 272 | (reduce-kv 273 | (fn [out k sub] 274 | (if-let [x (find data k)] 275 | (let [v (val x)] 276 | (if (seq sub) 277 | (cond 278 | (map? v) 279 | (assoc out k (select-shape v sub)) 280 | 281 | (coll/collection? v) 282 | (assoc out k (into (empty v) (map #(select-shape % sub)) v)) 283 | 284 | :else 285 | (assoc out k v)) 286 | (assoc out k v))) 287 | out)) 288 | (empty data) 289 | shape)) 290 | 291 | (declare select-shape-filtering) 292 | 293 | (defn- select-shape-filter-coll [out k v sub sub-req] 294 | (let [sub-keys (keys sub-req)] 295 | (assoc out k 296 | (into (empty v) 297 | (keep #(let [s' (select-shape-filtering % sub sub-req)] 298 | (if (every? (fn [x] (contains? s' x)) sub-keys) 299 | s'))) 300 | (cond-> v 301 | (coll/coll-append-at-head? v) (reverse)))))) 302 | 303 | (>defn select-shape-filtering 304 | "Like select-shape, but in case of collections, if some item doesn't have all the 305 | required keys, it's removed from the collection." 306 | ([data shape] 307 | [map? ::shape-descriptor => map?] 308 | (select-shape-filtering data shape shape)) 309 | ([data shape required-shape] 310 | [map? ::shape-descriptor (? ::shape-descriptor) => map?] 311 | (reduce-kv 312 | (fn [out k sub] 313 | (if-let [x (find data k)] 314 | (let [v (val x)] 315 | (if (seq sub) 316 | (let [sub-req (get required-shape k)] 317 | (cond 318 | (map? v) 319 | (assoc out k (select-shape-filtering v sub sub-req)) 320 | 321 | (coll/collection? v) 322 | (select-shape-filter-coll out k v sub sub-req) 323 | 324 | :else 325 | (assoc out k v))) 326 | (assoc out k v))) 327 | out)) 328 | (empty data) 329 | shape))) 330 | 331 | (>defn lift-placeholders-first-level 332 | "This function will normalize up all placeholders that start from the root of the tree. 333 | 334 | For example: 335 | 336 | {:>/foo {:a {}}} = becomes => {:a {}} 337 | 338 | Nested items also are bring up: 339 | 340 | {:>/foo {:a {} :>/other {:b {}}}} => {:a {} :b {}} 341 | 342 | But placeholders not connected to the root as kept as-is: 343 | 344 | {:coll {:>/inner {:a {}}}} => {:coll {:>/inner {:a {}}}}" 345 | [env shape] 346 | [map? ::shape-descriptor => ::shape-descriptor] 347 | (reduce-kv 348 | (fn [out k v] 349 | (if (pph/placeholder-key? env k) 350 | (merge out (lift-placeholders-first-level env v)) 351 | (assoc out k v))) 352 | {} 353 | shape)) 354 | -------------------------------------------------------------------------------- /test/com/wsscode/pathom3/interface/smart_map_test.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.interface.smart-map-test 2 | (:require 3 | [clojure.core.protocols :as d] 4 | [clojure.test :refer [deftest is are run-tests testing]] 5 | [com.wsscode.pathom3.connect.built-in.resolvers :as pbir] 6 | [com.wsscode.pathom3.connect.indexes :as pci] 7 | [com.wsscode.pathom3.connect.operation :as pco] 8 | [com.wsscode.pathom3.entity-tree :as p.ent] 9 | [com.wsscode.pathom3.interface.smart-map :as psm] 10 | [com.wsscode.pathom3.test.geometry-resolvers :as geo] 11 | [com.wsscode.pathom3.test.helpers :as th] 12 | [matcher-combinators.test]) 13 | #?(:clj 14 | (:import 15 | (clojure.lang 16 | ExceptionInfo)))) 17 | 18 | (pco/defresolver points-vector [] 19 | {::points-vector 20 | [{:x 1 :y 10} 21 | {:x 3 :y 11} 22 | {:x -10 :y 30}]}) 23 | 24 | (pco/defresolver points-set [] 25 | {::points-set 26 | #{{:x 1 :y 10} 27 | {:x 3 :y 11} 28 | {:x -10 :y 30}}}) 29 | 30 | (def registry 31 | [geo/registry geo/geo->svg-registry 32 | points-vector points-set]) 33 | 34 | (deftest smart-map-test 35 | (testing "reading" 36 | (testing "keyword call read" 37 | (let [sm (psm/smart-map (pci/register geo/registry) 38 | {::geo/left 3 ::geo/width 5})] 39 | (is (= (::geo/right sm) 8)))) 40 | 41 | (testing "get" 42 | (let [sm (psm/smart-map (pci/register geo/registry) 43 | {::geo/left 3 ::geo/width 5})] 44 | (is (= (get sm ::geo/right) 8)))) 45 | 46 | (testing "calling smart map as a fn" 47 | (let [sm (psm/smart-map (pci/register geo/registry) 48 | {::geo/left 3 ::geo/width 5})] 49 | (is (= (sm ::geo/right) 8))))) 50 | 51 | (testing "assoc uses source context on the new smart map" 52 | (let [sm (psm/smart-map (pci/register registry) 53 | {:x 3 :width 5})] 54 | (is (= (:right sm) 8)) 55 | (is (= (:right (assoc sm :width 10)) 13))) 56 | 57 | (testing "via conj" 58 | (let [sm (psm/smart-map (pci/register registry) 59 | {:x 3 :width 5})] 60 | (is (= (:right sm) 8)) 61 | (is (= (:right (conj sm [:width 10])) 13))))) 62 | 63 | (testing "dissoc" 64 | (let [sm (psm/smart-map (pci/register registry) 65 | {:x 3 :width 5})] 66 | (is (= (:right sm) 8)) 67 | (is (= (:right (dissoc sm :width)) nil)))) 68 | 69 | (testing "nested maps should also be smart maps" 70 | (let [sm (psm/smart-map (pci/register registry) 71 | {:x 10 :y 20})] 72 | (is (= (-> sm ::geo/turn-point :right) 73 | 10)))) 74 | 75 | (testing "nested smart maps should return as-is" 76 | (let [sm-child (psm/smart-map (pci/register registry) {:x 10 :width 20}) 77 | sm (psm/smart-map {} {:thing sm-child})] 78 | (is (= (-> sm :thing :right) 79 | 30)))) 80 | 81 | (testing "nested maps in sequences should also be smart maps" 82 | (testing "vector" 83 | (let [sm (psm/smart-map (pci/register registry) 84 | {})] 85 | (is (vector? (->> sm ::points-vector))) 86 | (is (= (->> sm ::points-vector (map :left)) 87 | [1 3 -10])))) 88 | 89 | (testing "set" 90 | (let [sm (psm/smart-map (pci/register registry) 91 | {})] 92 | (is (= (->> sm ::points-set) 93 | #{{:x 1 :y 10} 94 | {:x 3 :y 11} 95 | {:x -10 :y 30}})) 96 | (is (= (->> sm ::points-set first :left) 97 | 3))))) 98 | 99 | (testing "disable wrap nested" 100 | (let [sm (psm/smart-map (-> (pci/register registry) 101 | (psm/with-wrap-nested? false)) 102 | {:x 10 :y 20})] 103 | (is (= (-> sm ::geo/turn-point) 104 | {::geo/bottom 20 105 | ::geo/right 10})) 106 | (is (= (-> sm ::geo/turn-point :right) 107 | nil)))) 108 | 109 | (testing "meta" 110 | (let [sm (-> (pci/register registry) 111 | (psm/smart-map {:x 3 :width 5}) 112 | (with-meta {:foo "bar"}))] 113 | (is (= (:right sm) 8)) 114 | (is (= (meta sm) {:foo "bar"})))) 115 | 116 | (testing "empty" 117 | (testing "retains meta" 118 | (let [sm (-> (pci/register registry) 119 | (psm/smart-map {:x 3 :width 5}) 120 | (with-meta {:foo "bar"}) 121 | (empty))] 122 | (is (= sm {})) 123 | (is (= (-> sm (assoc :x 10) 124 | ::geo/x) 10)) 125 | (is (= (meta sm) {:foo "bar"}))))) 126 | 127 | (testing "count, uses the count from cache-tree" 128 | (let [sm (-> (pci/register registry) 129 | (psm/smart-map {:x 3 :width 5}))] 130 | (is (= (:right sm) 8)) 131 | (is (= (count sm) 7)))) 132 | 133 | (testing "keys" 134 | (testing "using cached keys" 135 | (let [sm (-> (pci/register registry) 136 | (psm/smart-map {:x 3 :width 5}))] 137 | (is (= (:right sm) 8)) 138 | (is (= (into #{} (keys sm)) 139 | #{:x 140 | :width 141 | :right 142 | ::geo/x 143 | ::geo/left 144 | ::geo/width 145 | ::geo/right})))) 146 | 147 | (testing "using reachable keys" 148 | (let [sm (-> (pci/register geo/full-registry) 149 | (psm/with-keys-mode ::psm/keys-mode-reachable) 150 | (psm/smart-map {:x 3}))] 151 | (is (= (into #{} (keys sm)) 152 | #{:com.wsscode.pathom3.test.geometry-resolvers/x 153 | :com.wsscode.pathom3.test.geometry-resolvers/left 154 | :x 155 | :left})) 156 | 157 | (testing "it should not realize the values just by asking the keys" 158 | (is (= (-> sm psm/sm-env p.ent/entity) 159 | {:x 3}))) 160 | 161 | (testing "realizing via sequence" 162 | (is (= (into {} sm) 163 | {:com.wsscode.pathom3.test.geometry-resolvers/left 3 164 | :com.wsscode.pathom3.test.geometry-resolvers/x 3 165 | :left 3 166 | :x 3})))))) 167 | 168 | (testing "contains" 169 | (testing "using cached keys" 170 | (let [sm (-> (pci/register registry) 171 | (psm/smart-map {:x 3 :width 5}))] 172 | (is (true? (contains? sm :x))) 173 | (is (true? (contains? sm :width))) 174 | (is (false? (contains? sm :wrong))) 175 | ; only works on CLJ for now, the reason is that contains? on CLJS doens't 176 | ; take the -contains-key? interface into account, so it's currently not possible 177 | ; to override the original behavior, which is to do a `get` in the map. 178 | ; https://clojure.atlassian.net/browse/CLJS-3283 179 | #?(:clj (is (false? (contains? sm ::geo/x)))))) 180 | 181 | (testing "using reachable keys" 182 | (let [sm (-> (pci/register registry) 183 | (psm/with-keys-mode ::psm/keys-mode-reachable) 184 | (psm/smart-map {:x 3 :width 5}))] 185 | (is (true? (contains? sm :x))) 186 | (is (true? (contains? sm :width))) 187 | (is (true? (contains? sm ::geo/x)))))) 188 | 189 | (testing "seq" 190 | (let [sm (-> (pci/register registry) 191 | (psm/smart-map {:x 3 :width 5}))] 192 | (is (= (seq sm) [[:x 3] [:width 5]]))) 193 | 194 | (testing "nil when keys are empty" 195 | (let [sm (-> (pci/register registry) 196 | (psm/smart-map {}))] 197 | (is (nil? (seq sm)))))) 198 | 199 | (testing "find" 200 | (let [sm (-> (pci/register registry) 201 | (psm/smart-map {:x 3 :width 5}))] 202 | (is (= (find sm :x) [:x 3]))) 203 | 204 | (let [sm (-> (pci/register registry) 205 | (psm/smart-map {:not-in-index 42}))] 206 | (is (= (find sm :not-in-index) [:not-in-index 42]))) 207 | 208 | (let [sm (-> (pci/register registry) 209 | (psm/smart-map {:x 3 :width 5}))] 210 | (is (= (find sm :right) [:right 8])) 211 | (is (= (find sm ::noop) nil))))) 212 | 213 | (deftest smart-map-resolver-cache-test 214 | (testing "uses persistent resolver cache by default" 215 | (let [spy (th/spy {:return {:foo "bar"}}) 216 | env (pci/register (pco/resolver 'spy {::pco/output [:foo]} spy)) 217 | sm (psm/smart-map env {})] 218 | (is (= [(:foo sm) 219 | (:foo (assoc sm :some "data")) 220 | (-> spy meta :calls deref count)] 221 | ["bar" "bar" 1])))) 222 | 223 | (testing "disable persistent cache" 224 | (let [spy (th/spy {:return {:foo "bar"}}) 225 | env (pci/register {::psm/persistent-cache? false} 226 | (pco/resolver 'spy {::pco/output [:foo]} spy)) 227 | sm (psm/smart-map env {})] 228 | (is (= [(:foo sm) 229 | (:foo (assoc sm :some "data")) 230 | (-> spy meta :calls deref count)] 231 | ["bar" "bar" 2])))) 232 | 233 | (testing "disabling persistent cache entirely" 234 | (let [spy (th/spy {:return {:foo "bar"}}) 235 | env (pci/register {:com.wsscode.pathom3.connect.runner/resolver-cache* nil} 236 | (pco/resolver 'spy {::pco/output [:foo]} spy)) 237 | sm (psm/smart-map env {})] 238 | (is (= [(:foo sm) 239 | (:foo (assoc sm :some "data")) 240 | (-> spy meta :calls deref count)] 241 | ["bar" "bar" 2]))))) 242 | 243 | (pco/defresolver error-resolver [] 244 | {:error (throw (ex-info "Error" {}))}) 245 | 246 | (deftest smart-map-datafy-test 247 | (let [sm (-> (pci/register [(pbir/alias-resolver :id :name) 248 | (pbir/alias-resolver :id :age)]) 249 | (psm/smart-map {:id 10})) 250 | smd (d/datafy sm)] 251 | (is (= smd 252 | {:id 10 253 | :name ::pco/unknown-value 254 | :age ::pco/unknown-value})) 255 | 256 | (testing "navigates in" 257 | (is (= (d/nav smd :name ::pco/unknown-value) 258 | 10))))) 259 | 260 | (deftest smart-map-errors-test 261 | (testing "quiet mode (default)" 262 | (let [sm (-> (pci/register error-resolver) 263 | (psm/smart-map))] 264 | (is (= (:error sm) nil)) 265 | (is (= (-> sm 266 | (psm/sm-update-env pci/register (pbir/constantly-resolver :not-here "now it is")) 267 | :not-here) "now it is")))) 268 | 269 | (testing "loud mode" 270 | (let [sm (-> (pci/register error-resolver) 271 | (psm/with-error-mode ::psm/error-mode-loud) 272 | (psm/smart-map))] 273 | (is (thrown? 274 | #?(:clj ExceptionInfo :cljs js/Error) 275 | (:error sm)))) 276 | 277 | (testing "planning error" 278 | (is (thrown? 279 | #?(:clj ExceptionInfo :cljs js/Error) 280 | (let [m (psm/smart-map (-> (pci/register 281 | (pbir/alias-resolver :x :y)) 282 | (psm/with-error-mode ::psm/error-mode-loud)) 283 | {})] 284 | (:y m))))))) 285 | 286 | (deftest sm-update-env-test 287 | (let [sm (-> (pci/register registry) 288 | (psm/smart-map {:x 3 :width 5}))] 289 | (is (= (:not-here sm) nil)) 290 | (is (= (-> sm 291 | (psm/sm-update-env pci/register (pbir/constantly-resolver :not-here "now it is")) 292 | :not-here) "now it is")))) 293 | 294 | (deftest sm-assoc!-test 295 | (testing "uses source context on the new smart map" 296 | (let [sm (psm/smart-map (pci/register registry) 297 | {:x 3 :width 5})] 298 | (is (= (:right sm) 8)) 299 | (is (= (:right (psm/sm-assoc! sm :width 10)) 8)) 300 | (is (= (:width sm) 10))))) 301 | 302 | (deftest sm-dissoc!-test 303 | (testing "uses source context on the new smart map" 304 | (let [sm (psm/smart-map (pci/register registry) 305 | {:x 3 :width 5})] 306 | (is (= (:right sm) 8)) 307 | (is (= (:right (psm/sm-dissoc! sm :width)) 8))))) 308 | 309 | (deftest sm-touch-test 310 | (testing "loads data from a EQL expression into the smart map" 311 | (let [sm (-> (psm/smart-map (pci/register registry) 312 | {:x 3 :y 5}) 313 | (psm/sm-touch! [{::geo/turn-point [:right]}]))] 314 | (is (= (-> sm psm/sm-env p.ent/entity) 315 | {:x 3 316 | :y 5 317 | ::geo/x 3 318 | ::geo/left 3 319 | ::geo/y 5 320 | ::geo/top 5 321 | ::geo/turn-point {::geo/right 3 322 | ::geo/bottom 5 323 | :right 3}}))))) 324 | 325 | (deftest sm-replace-context-test 326 | (let [sm (-> (psm/smart-map (pci/register registry) 327 | {:x 3 :y 5}) 328 | (psm/sm-replace-context {:x 10}))] 329 | (is (= sm {:x 10})) 330 | (is (= (::geo/left sm) 10)))) 331 | 332 | (comment 333 | (-> (pci/register registry) 334 | (assoc :com.wsscode.pathom3.connect.planner/plan-cache* (atom {})) 335 | ((requiring-resolve 'com.wsscode.pathom.viz.ws-connector.pathom3/connect-env) 336 | "geo"))) 337 | -------------------------------------------------------------------------------- /src/main/com/wsscode/pathom3/connect/built_in/resolvers.cljc: -------------------------------------------------------------------------------- 1 | (ns com.wsscode.pathom3.connect.built-in.resolvers 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [com.fulcrologic.guardrails.core :refer [<- => >def >defn >fdef ? |]] 5 | [com.wsscode.pathom3.attribute :as p.attr] 6 | [com.wsscode.pathom3.connect.operation :as pco] 7 | [com.wsscode.pathom3.format.eql :as pf.eql]) 8 | #?(:cljs 9 | (:require-macros 10 | [com.wsscode.pathom3.connect.built-in.resolvers]))) 11 | 12 | (>def ::entity-table (s/map-of any? map?)) 13 | 14 | (defn attr-munge [attr] 15 | (munge (subs (str attr) 1))) 16 | 17 | (defn combine-names [na nb] 18 | (cond 19 | (not nb) 20 | na 21 | 22 | (not na) 23 | nb 24 | 25 | (= na nb) 26 | na 27 | 28 | :else 29 | (str (str na "->" nb)))) 30 | 31 | (defn attr-ns [kw] 32 | (or (namespace kw) "-unqualified")) 33 | 34 | (defn attr-alias-resolver-name 35 | ([from to] 36 | (symbol 37 | (combine-names (attr-ns from) (attr-ns to)) 38 | (combine-names (name from) (name to)))) 39 | ([from to suffix] 40 | (symbol 41 | (combine-names (attr-ns from) (attr-ns to)) 42 | (str (combine-names (name from) (name to)) "--" suffix)))) 43 | 44 | (defn attr->sym 45 | ([kw] 46 | (symbol (attr-ns kw) (name kw))) 47 | ([kw suffix] 48 | (symbol (attr-ns kw) (str (name kw) "--" suffix)))) 49 | 50 | (defn alias-resolver 51 | "Create a resolver that will convert attribute `from` to a attribute `to` with 52 | the same value. This only creates the alias in one direction." 53 | [from to] 54 | (let [resolver-name (attr-alias-resolver-name from to "alias")] 55 | (pco/resolver resolver-name 56 | {::pco/input [from] 57 | ::pco/output [to] 58 | ::pco/cache? false} 59 | (fn [_ input] {to (get input from)})))) 60 | 61 | (defn equivalence-resolver 62 | "Make two attributes equivalent. It's like alias-resolver, but returns a vector containing the alias in both directions." 63 | [attribute-a attribute-b] 64 | [(alias-resolver attribute-a attribute-b) 65 | (alias-resolver attribute-b attribute-a)]) 66 | 67 | (defn constantly-resolver 68 | "Create a simple resolver that always return `value` for `attribute`." 69 | ([attribute value] 70 | (let [resolver-name (attr->sym attribute "const")] 71 | (pco/resolver resolver-name 72 | {::pco/output (if (coll? value) 73 | (pf.eql/data->query {attribute value}) 74 | [attribute]) 75 | ::pco/cache? false} 76 | (fn [_ _] {attribute value}))))) 77 | 78 | (defn constantly-fn-resolver 79 | "Create a simple resolver that always calls value-fn and return its value. Note that 80 | cache is disabled by default in this resolver." 81 | ([attribute value-fn] 82 | (let [resolver-name (attr->sym attribute "const-fn")] 83 | (pco/resolver resolver-name 84 | {::pco/output [attribute] 85 | ::pco/cache? false} 86 | (fn [env _] {attribute (value-fn env)}))))) 87 | 88 | (defn single-attr-resolver 89 | "Apply fn `f` to input `source` and spits the result with the name `target`. 90 | 91 | `f` receives a single argument, which is the attribute value from `source`." 92 | [source target f] 93 | (let [resolver-name (attr-alias-resolver-name source target "attr-transform")] 94 | (pco/resolver resolver-name 95 | {::pco/input [source] 96 | ::pco/output [target]} 97 | (fn [_ input] 98 | {target (f (get input source))})))) 99 | 100 | (defn single-attr-with-env-resolver 101 | "Similar single-attr-resolver, but `f` receives two arguments, `env` and the input." 102 | [source target f] 103 | (let [resolver-name (attr-alias-resolver-name source target "attr-transform")] 104 | (pco/resolver resolver-name 105 | {::pco/input [source] 106 | ::pco/output [target]} 107 | (fn [env input] 108 | {target (f env (get input source))})))) 109 | 110 | (defn table-output 111 | "For a given static table, compute the accumulated output query of the entity values." 112 | [table] 113 | (let [[{:keys [output]}] (pf.eql/data->query {:output (vec (vals table))})] 114 | output)) 115 | 116 | (>defn static-table-resolver 117 | "Exposes data for entities, indexes by attr-key. This is a simple way to extend/provide 118 | data for entities using simple Clojure maps. Example: 119 | 120 | (def registry 121 | [(pbir/static-table-resolver :song/id 122 | {1 {:song/name \"Marchinha Psicotica de Dr. Soup\"} 123 | 2 {:song/name \"There's Enough\"}}) 124 | 125 | ; you can provide a name for the resolver, if so, prefer fully qualified symbols 126 | (pbir/static-table-resolver `song-analysis :song/id 127 | {1 {:song/duration 280 :song/tempo 98} 128 | 2 {:song/duration 150 :song/tempo 130}})]) 129 | 130 | (let [sm (psm/smart-map (pci/register registry) 131 | {:song/id 1})] 132 | (select-keys sm [:song/id :song/name :song/duration])) 133 | ; => #:song{:id 1, :name \"Marchinha Psicotica de Dr. Soup\", :duration 280} 134 | 135 | In this example, we create two different tables that provides data about songs, the 136 | entities are related by the keys on the table, the `attr-key` says what's the attribute 137 | name to be used to related the data, in this case we use `:song/id` on both, so they 138 | get connected by it. 139 | " 140 | ([attribute table] 141 | [::p.attr/attribute ::entity-table 142 | => ::pco/resolver] 143 | (let [resolver-name (attr->sym attribute "static-table")] 144 | (static-table-resolver resolver-name attribute table))) 145 | ([resolver-name attribute table] 146 | [::pco/op-name ::p.attr/attribute ::entity-table 147 | => ::pco/resolver] 148 | (let [output (table-output table)] 149 | (pco/resolver resolver-name 150 | {::pco/input [attribute] 151 | ::pco/output output} 152 | (fn [_ input] 153 | (let [id (get input attribute)] 154 | (get table id))))))) 155 | 156 | (>defn static-attribute-map-resolver 157 | "This is like the static-table-resolver, but provides a single attribute on each 158 | map entry. 159 | 160 | (def registry 161 | [(pbir/static-attribute-map-resolver :song/id :song/name 162 | {1 \"Marchinha Psicotica de Dr. Soup\" 163 | 2 \"There's Enough\"}) 164 | 165 | (pbir/static-table-resolver `song-analysis :song/id 166 | {1 {:song/duration 280 :song/tempo 98} 167 | 2 {:song/duration 150 :song/tempo 130}})]) 168 | 169 | (let [sm (psm/smart-map (pci/register registry) 170 | {:song/id 1})] 171 | (select-keys sm [:song/id :song/name :song/duration])) 172 | ; => #:song{:id 1, :name \"Marchinha Psicotica de Dr. Soup\", :duration 280} 173 | " 174 | [input output mapping] 175 | [::p.attr/attribute ::p.attr/attribute map? 176 | => ::pco/resolver] 177 | (let [resolver-name (symbol (str (attr-alias-resolver-name input output) "--static-attribute-map"))] 178 | (pco/resolver resolver-name 179 | {::pco/input [input] 180 | ::pco/output [output]} 181 | (fn [_ input-map] 182 | (if-let [x (find mapping (get input-map input))] 183 | {output (val x)}))))) 184 | 185 | (>defn attribute-table-resolver 186 | "Similar to static-table-resolver, but instead of a static map, this will pull the 187 | table from another attribute in the system. Given in this case the values can be dynamic, 188 | this helper requires a pre-defined output, so the attributes on this output get 189 | delegated to the created resolver. 190 | 191 | (def registry 192 | [(pbir/static-table-resolver `song-names :song/id 193 | {1 {:song/name \"Marchinha Psicotica de Dr. Soup\"} 194 | 2 {:song/name \"There's Enough\"}}) 195 | 196 | (pbir/constantly-resolver ::song-analysis 197 | {1 {:song/duration 280 :song/tempo 98} 198 | 2 {:song/duration 150 :song/tempo 130}}) 199 | 200 | (pbir/attribute-table-resolver ::song-analysis :song/id 201 | [:song/duration :song/tempo])]) 202 | 203 | (let [sm (psm/smart-map (pci/register registry) 204 | {:song/id 2})] 205 | (select-keys sm [:song/id :song/name :song/duration])) 206 | ; => #:song{:id 2, :name \"There's Enough\", :duration 150} 207 | " 208 | [table-name attr-key output] 209 | [::p.attr/attribute ::p.attr/attribute ::pco/output 210 | => ::pco/resolver] 211 | (let [resolver-name (symbol (str (attr-munge attr-key) "--table-" (attr-munge table-name)))] 212 | (pco/resolver resolver-name 213 | {::pco/input [attr-key table-name] 214 | ::pco/output output} 215 | (fn [_ input] 216 | (let [table (get input table-name) 217 | id (get input attr-key)] 218 | (get table id)))))) 219 | 220 | (>defn env-table-resolver 221 | "Similar to attribute-table-resolver, but pulls table from env instead of other resolver. 222 | 223 | (def registry 224 | [(pbir/static-table-resolver `song-names :song/id 225 | {1 {:song/name \"Marchinha Psicotica de Dr. Soup\"} 226 | 2 {:song/name \"There's Enough\"}}) 227 | 228 | (pbir/env-table-resolver ::song-analysis :song/id 229 | [:song/duration :song/tempo])]) 230 | 231 | (def table 232 | {::song-analysis 233 | {1 {:song/duration 280 :song/tempo 98} 234 | 2 {:song/duration 150 :song/tempo 130}}}) 235 | 236 | ; merge table into env 237 | (let [sm (psm/smart-map (merge (pci/register registry) table) 238 | {:song/id 2})] 239 | (select-keys sm [:song/id :song/name :song/duration])) 240 | ; => #:song{:id 2, :name \"There's Enough\", :duration 150} 241 | " 242 | [table-name attr-key output] 243 | [::p.attr/attribute ::p.attr/attribute ::pco/output 244 | => ::pco/resolver] 245 | (let [resolver-name (symbol (str (attr-munge attr-key) "--env-table-" (attr-munge table-name)))] 246 | (pco/resolver resolver-name 247 | {::pco/input [attr-key] 248 | ::pco/output output} 249 | (fn [env input] 250 | (let [table (get env table-name) 251 | id (get input attr-key)] 252 | (get table id)))))) 253 | 254 | #?(:clj 255 | (defn edn-extract-attr-tables 256 | [data] 257 | `(into [] 258 | (keep 259 | (fn [[k# v#]] 260 | (if-let [attr-key# (and (map? v#) 261 | (:com.wsscode.pathom3/entity-table (meta v#)))] 262 | (attribute-table-resolver k# attr-key# (table-output v#))))) 263 | ~data))) 264 | 265 | #?(:clj 266 | (defmacro edn-file-resolver 267 | "Creates a resolver to provide data loaded from a file. 268 | 269 | This is a macro and the file will be read at compilation time, this way it 270 | can work on both Clojure and Clojurescript, without a need for async processing. 271 | 272 | It's also possible to provide static tables data (as with attribute-table-resolver) 273 | from the data itself, you can do this by setting the meta data :pathom3/entity-table 274 | meta data in your EDN data. For example: 275 | 276 | {:my.system/generic-db 277 | ^{:com.wsscode.pathom3/entity-table :my.system/user-id} 278 | {4 {:my.system.user/name \"Anne\"} 279 | 2 {:my.system.user/name \"Fred\"}}} 280 | 281 | Doing this, the resolvers for the attribute table will be provided automatically. 282 | 283 | Full example: 284 | 285 | ; my-config.edn 286 | {:my.system/port 287 | 1234 288 | 289 | :my.system/initial-path 290 | \"/tmp/system\" 291 | 292 | :my.system/generic-db 293 | ^{:com.wsscode.pathom3/entity-table :my.system/user-id} 294 | {4 {:my.system.user/name \"Anne\"} 295 | 2 {:my.system.user/name \"Fred\"}}} 296 | 297 | ; app 298 | (def registry [(edn-file-resolver \"my-config.edn\")]) 299 | 300 | (let [sm (psm/smart-map (pci/register registry) {:my.system/user-id 4})] 301 | (select-keys sm [:my.system/port :my.system.user/name]) 302 | ; => {:my.system/port 1234, :my.system.user/name \"Anne\"} 303 | 304 | Note that the tables need to be a value of a top level attribute of the config, if 305 | its deeper inside it won't work." 306 | [file-path] 307 | (let [data (read-string (slurp file-path)) 308 | resolver-name (symbol "com.wsscode.pathom3.edn-file-resolver" (munge file-path)) 309 | output (pf.eql/data->query data) 310 | attr-tables (edn-extract-attr-tables data)] 311 | `[(pco/resolver '~resolver-name 312 | {::pco/output ~output} 313 | (fn ~'[_ _] ~data)) 314 | ~attr-tables]))) 315 | 316 | (defn extract-attr-tables 317 | [data] 318 | (into [] 319 | (keep 320 | (fn [[k v]] 321 | (if-let [attr-key (and (map? v) 322 | (:com.wsscode.pathom3/entity-table (meta v)))] 323 | (attribute-table-resolver k attr-key (table-output v))))) 324 | data)) 325 | 326 | (defn global-data-resolver 327 | "Expose data as a resolver, note this data will be available everywhere in the system. 328 | 329 | Works the same as edn-file-resolver, but uses the data directly instead of reading 330 | from a file. This also applies for the attribute tables inside the data." 331 | [data] 332 | (let [resolver-name (symbol "com.wsscode.pathom3.global-data-resolver" (str (hash data))) 333 | output (pf.eql/data->query data) 334 | attr-tables (extract-attr-tables data)] 335 | [(pco/resolver resolver-name 336 | {::pco/output output} 337 | (fn global-data-resolver-fn [_ _] data)) 338 | attr-tables])) 339 | --------------------------------------------------------------------------------