├── .circleci └── config.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── boot.properties ├── build.boot ├── project.clj ├── src ├── sails_forth.clj └── sails_forth │ ├── client.clj │ ├── clojurify.clj │ ├── datomic.clj │ ├── http.clj │ ├── memory2.clj │ ├── query.clj │ ├── repl.clj │ ├── spec.clj │ └── update.clj └── test ├── sails_forth ├── clojurify_test.clj ├── datomic_test.clj ├── http_test.clj ├── memory2_test.clj ├── query_test.clj └── test.clj └── schema.edn /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/clojure:boot-2.7.2 6 | steps: 7 | - checkout 8 | 9 | #Install & cache dependencies 10 | - restore_cache: 11 | key: sparkfund--{{ .Environment.CIRCLE_PROJECT_REPONAME }}--maven-deps--{{ checksum "build.boot" }} 12 | key: sparkfund--{{ .Environment.CIRCLE_PROJECT_REPONAME }}--maven-deps 13 | - run: 14 | name: List Machine Info 15 | command: java -version; echo; boot --version 16 | - run: 17 | name: Install Dependencies 18 | command: boot deps 19 | - save_cache: 20 | key: sparkfund--{{ .Environment.CIRCLE_PROJECT_REPONAME }}--maven-deps--{{ checksum "build.boot" }} 21 | paths: 22 | - "~/.m2" 23 | - "~/.boot" 24 | 25 | - run: 26 | name: Run Tests 27 | command: boot spec-coverage 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | test/config.edn 13 | *.swp 14 | *.tmp 15 | .nrepl-history 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.11.2 (2019-10-02) 2 | 3 | * nil denotes "null" in queries 4 | 5 | ## 0.10.1 (2019-07-08) 6 | 7 | * Add support for comparison operators in soql in the memory client 8 | 9 | ## 0.10.0 (2019-04-08) 10 | 11 | * Update to Clojure 10 12 | 13 | ## 0.9.1 (2018-09-06) 14 | 15 | * Fix string escaping when building queries in `sails-forth.query` 16 | 17 | ## 0.9.0 (2018-07-23) 18 | 19 | * Add `take-action!` implementation to memory client 20 | * Update spec for `build-memory-client` 21 | * See [commits](https://github.com/SparkFund/sails-forth/compare/0.8.1...0.9.0) 22 | 23 | ## 0.8.1 24 | 25 | * Introduce sails-forth.datomic ns with assert-query fn 26 | 27 | ## 0.8.0 28 | 29 | * Update to clojure-1.9.0, also drop boot-mvn 30 | 31 | ## 0.7.0 32 | 33 | * Update to clojure-1.9.0alpha17 and clojure.spec.alpha 34 | 35 | ## 0.6.0 36 | 37 | * Fix incorrect specs 38 | * Add spec-coverage 39 | * Add actions-related methods 40 | 41 | ## 0.5.0 42 | 43 | * Replace core.typed with clojure.spec 44 | * Add import! fn, using the bulk api 45 | 46 | ## 0.4.1 47 | 48 | * Add record-type-id fn and record-types cache 49 | 50 | ## 0.4.0 51 | 52 | * Convert percent type values to decimal values 53 | * Add render-value fn 54 | * Use render-value when pushing updates using sails-forth.update 55 | 56 | ## 0.3.3 57 | 58 | * Allow memory client to accept nil values 59 | * Fix ns references 60 | 61 | ## 0.3.2 62 | 63 | * Relax parentheses in generated soql to faciliate memory client use 64 | 65 | ## 0.3.1 66 | 67 | * Revise memory schema to more closely resemble real schema 68 | 69 | ## 0.3.0 70 | 71 | * Refactor client behind a protocol 72 | * Add memory client to facilitate testing 73 | * Deprecate top-level ns to make emacs clojure-mode happy 74 | 75 | ## 0.2.1 76 | 77 | * Add update! fn 78 | 79 | ## 0.2.0 80 | 81 | * Relax salesforce types 82 | 83 | ## 0.1.7 84 | 85 | * Use ex-info for exceptions to provide better data 86 | 87 | ## 0.1.6 88 | 89 | * Fix bug in resolving field paths for custom relations 90 | * Add :where clause support to query 91 | 92 | ## 0.1.5 93 | 94 | * Fix bug in field description type 95 | * Add url metadata to sails-forth.query/query 96 | 97 | ## 0.1.4 98 | 99 | * Add type registry to allow custom objects to have nicer type names 100 | * Add helper fn to resolve attrs by labels 101 | 102 | ## 0.1.3 103 | 104 | * Convert date fields to joda local-date instances 105 | * Convert some double and int fields to integral types 106 | 107 | ## 0.1.2 108 | 109 | * Add count! fn 110 | * Add sails-forth.query ns 111 | * Configure cheshire to parse json numbers as bigdecimals 112 | * Parse dates and datetimes as joda instances 113 | 114 | ## 0.1.1 115 | 116 | * Move user to sails-forth.repl and remove dependency on test ns 117 | 118 | ## 0.1.0 119 | 120 | * Initial release: uses core.typed predicates to validate responses 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor tocontrol, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sails-forth 2 | 3 | Sails-forth is a clojure salesforce library. It is specified with clojure.spec. 4 | It uses a stateful client object to transparently handle authentication and 5 | version detection. 6 | 7 | ## Installation 8 | 9 | `[sparkfund/sails-forth "0.11.2"]` 10 | 11 | Note that the memory client now uses a dependency that does not exist in 12 | the common maven repositories. That repository is: 13 | 14 | `https://repository.mulesoft.org/releases/` 15 | 16 | If that presents a significant impediment, open an issue; we could look to 17 | include it in our jar or seek permission to publish it on a standard maven. 18 | 19 | ## Usage 20 | 21 | ``` clojure 22 | (require '[sails-forth.client :as sf]) 23 | 24 | (def config 25 | {:username "..." 26 | :password "..." 27 | :token "..." 28 | :consumer-key "..." 29 | :consumer-secret "..." 30 | :sandbox? false}) 31 | 32 | (def client (sf/build-http-client config)) 33 | 34 | (sf/limits! client {}) 35 | 36 | (def object-id 37 | (sf/create! client "contact" {:first_name "Spark" :last_name "Fund"})) 38 | 39 | (sf/update! client "contact object-id {:last_name "Fondue"}) 40 | 41 | (sf/delete! client "contact" object-id) 42 | 43 | (sf/query! client "select First_Name, Last_Name from contact__c") 44 | ``` 45 | 46 | A higher-level query ns leverages the salesforce schema to denote types 47 | and fields using more idiomatically clojure keywords. It also uses the 48 | field types to coerce values. Numbers will always be interpreted from the 49 | json payload as bigdecimals. Fixed point numbers with zero precision will 50 | become longs if possible, bigintegers otherwise. Ints will become longs. 51 | Dates and datetimes will become joda localdates and dates, respectively. 52 | 53 | ```clojure 54 | (require '[sails-forth.query as sq]) 55 | 56 | (sq/query client {:find [:contact :id :first-name :last-name]}) 57 | (sq/query client {:find [:contact :id :first-name :last-name [:faction :id :name]]}) 58 | (sq/query client {:find [:contact :id :first-name :last-name] 59 | :where [[:= [:contract :last-name] "Organa"]]}) 60 | ``` 61 | 62 | There is a memory client that operates on a schema and provides working impls of 63 | all of the client fns. The query fn is limited to a subset of the soql operators, 64 | including =, AND, OR, and IN, though adding support for more operators is very 65 | straightforward. 66 | 67 | ``` clojure 68 | (def mc (sf/build-memory-client (sf/schema client #{:contact}))) 69 | ``` 70 | 71 | There is support for building transactions which can be applied to datomic 72 | to record observations from salesforce queries: 73 | 74 | ``` clojure 75 | (require '[sails-forth.datomic :as sd]) 76 | 77 | (def txns 78 | (sd/assert-query client "sf" {:find [:opportunity :id :name [:customer :id :name]]})) 79 | ``` 80 | 81 | ## Configuration 82 | 83 | You may find http://www.calvinfroedge.com/salesforce-how-to-generate-api-credentials/ 84 | useful to help create your consumer-key and consumer-secret values. 85 | 86 | ## License 87 | 88 | Copyright © 2015-2019 Sparkfund 89 | 90 | Distributed under the Eclipse Public License either version 1.0 or (at 91 | your option) any later version. 92 | -------------------------------------------------------------------------------- /boot.properties: -------------------------------------------------------------------------------- 1 | BOOT_CLOJURE_VERSION=1.10.0 2 | BOOT_JVM_OPTIONS="-XX:-OmitStackTraceInFastThrow" 3 | BOOT_VERSION=2.8.2 -------------------------------------------------------------------------------- /build.boot: -------------------------------------------------------------------------------- 1 | (def version "0.11.3") 2 | 3 | (task-options! 4 | pom {:project 'sparkfund/sails-forth 5 | :version version 6 | :description "A Salesforce library"}) 7 | 8 | (set-env! 9 | :resource-paths #{"src"} 10 | :source-paths #{"test"} 11 | :dependencies 12 | '[[adzerk/bootlaces "0.1.13" :scope "test"] 13 | [adzerk/boot-jar2bin "1.1.0" :scope "test"] 14 | [adzerk/boot-test "1.2.0" :scope "test"] 15 | [cheshire "5.5.0"] 16 | [clj-http "2.0.0"] 17 | [clj-time "0.11.0"] 18 | [com.datomic/datomic-free "0.9.5561.62" :scope "test"] 19 | [org.clojure/clojure "1.10.0" :scope "provided"] 20 | [org.clojure/test.check "0.9.0" :scope "test"] 21 | [org.mule.tools/salesforce-soql-parser "2.0"] 22 | [sparkfund/boot-spec-coverage "0.4.0" :scope "test"]] 23 | :repositories 24 | #(conj % 25 | ["sparkfund" {:url "s3p://sparkfund-maven/releases/"}] 26 | ["mulesoft" {:url "https://repository.mulesoft.org/releases/"}]) 27 | :wagons '[[sparkfund/aws-cli-wagon "1.0.4"]]) 28 | 29 | (require '[adzerk.boot-jar2bin :refer :all] 30 | '[adzerk.boot-test :as bt] 31 | '[clojure.java.io :as io] 32 | '[sparkfund.boot-spec-coverage :as cover]) 33 | 34 | (deftask deps 35 | []) 36 | 37 | (def only-integration '(-> % meta :integration)) 38 | (def no-integration '(-> % meta :integration not)) 39 | 40 | (deftask test-all 41 | "Run every unit test, including integration tests" 42 | [] 43 | (bt/test)) 44 | 45 | (deftask test-integration 46 | "Only run integration tests" 47 | [] 48 | (bt/test 49 | :filters [only-integration])) 50 | 51 | (deftask test 52 | "Run every non-integration test." 53 | [] 54 | (bt/test 55 | :filters [no-integration])) 56 | 57 | (deftask spec-coverage 58 | "Spec coverage checking using non-integration tests." 59 | [] 60 | (cover/spec-coverage 61 | :filters [no-integration] 62 | :instrument 'sparkfund.boot-spec-coverage.instrument/in-n-outstrument)) 63 | 64 | (deftask spec-coverage-all 65 | "Spec coverage checking, including integration tests." 66 | [] 67 | (cover/spec-coverage 68 | :instrument 'sparkfund.boot-spec-coverage.instrument/in-n-outstrument)) 69 | 70 | (require '[adzerk.bootlaces :refer :all]) 71 | 72 | (bootlaces! version) 73 | 74 | (deftask release 75 | [] 76 | (comp (build-jar) (push-release))) 77 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject sparkfund/sails-forth "0.6.0-alpha1" 2 | :description "A Salesforce library" 3 | :url "http://github.com/sparkfund/sails-forth" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[cheshire "5.5.0"] 7 | [clj-http "2.0.0"] 8 | [clj-time "0.11.0"] 9 | [com.github.jsqlparser/jsqlparser "0.9.5"] 10 | [org.clojure/clojure "1.9.0-alpha17"]] 11 | :profiles {:dev {:dependencies [[org.clojure/test.check "0.9.0"]]}} 12 | :repl-options {:init-ns sails-forth.repl} 13 | :test-selectors {:integration :integration 14 | :all (constantly true) 15 | :default (complement :integration)}) 16 | -------------------------------------------------------------------------------- /src/sails_forth.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth 2 | (:require [sails-forth.client :as client])) 3 | 4 | (defn build-client! 5 | {:deprecated "0.3"} 6 | [config] 7 | (client/build-http-client config)) 8 | 9 | (defn create! 10 | {:deprecated "0.3"} 11 | [client type attrs] 12 | (client/create! client type attrs)) 13 | 14 | (defn delete! 15 | {:deprecated "0.3"} 16 | [client type attrs] 17 | (client/delete! client type attrs)) 18 | 19 | (defn update! 20 | {:deprecated "0.3"} 21 | [client type id attrs] 22 | (client/update! type type id attrs)) 23 | 24 | (defn list! 25 | {:deprecated "0.3"} 26 | [client type] 27 | (client/list! client type)) 28 | 29 | (defn describe! 30 | {:deprecated "0.3"} 31 | [client type] 32 | (client/describe! client type)) 33 | 34 | (defn objects! 35 | {:deprecated "0.3"} 36 | [client] 37 | (client/objects! client)) 38 | 39 | (defn query! 40 | {:deprecated "0.3"} 41 | [client query] 42 | (client/query! client query)) 43 | 44 | (defn count! 45 | {:deprecated "0.3"} 46 | [client query] 47 | (client/count! client query)) 48 | 49 | (defn limits! 50 | {:deprecated "0.3"} 51 | [client] 52 | (client/limits! client)) 53 | -------------------------------------------------------------------------------- /src/sails_forth/client.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.client 2 | (:require [clojure.spec.alpha :as s] 3 | [sails-forth.http :as http] 4 | [sails-forth.memory2 :as memory2] 5 | [sails-forth.spec :as spec] 6 | [sails-forth.clojurify :as clj])) 7 | 8 | (defprotocol Cache 9 | (^:spark/no-boot-spec-coverage 10 | put! [_ key value]) 11 | (^:spark/no-boot-spec-coverage 12 | get! [_ key])) 13 | 14 | (s/def ::cache 15 | (partial satisfies? Cache)) 16 | 17 | (s/fdef put! 18 | :args (s/cat :cache ::cache 19 | :key any? 20 | :value any?)) 21 | 22 | (s/fdef get! 23 | :args (s/cat :cache ::cache 24 | :key any?) 25 | :ret any?) 26 | 27 | (defprotocol Client 28 | (^:spark/no-boot-spec-coverage 29 | create! 30 | [_ type attrs] 31 | "Creates an object of the given type and attrs using the given salesforce 32 | client. If salesforce responds successfully, this returns the object's id, 33 | otherwise this raises an exception.") 34 | (delete! 35 | [_ type id] 36 | "Deletes the object of the given type with the given id. This returns true 37 | if it succeeds and raises an exception otherwise.") 38 | (update! 39 | [_ type id attrs] 40 | "Updates the object of the given type with the given id. This returns true 41 | if it succeeds and raises an exception otherwise.") 42 | (^:spark/no-boot-spec-coverage 43 | list! 44 | [_ type] 45 | "Lists all objets of the given type") 46 | (^:spark/no-boot-spec-coverage 47 | describe! 48 | [_ type] 49 | "Describes the given type") 50 | (^:spark/no-boot-spec-coverage 51 | objects! 52 | [_] 53 | "Lists all objects") 54 | (^:spark/no-boot-spec-coverage 55 | query! 56 | [_ query] 57 | "Executes the given query and returns all results, eagerly fetching if there 58 | is pagination") 59 | (^:spark/no-boot-spec-coverage 60 | count! 61 | [_ query] 62 | "Returns the number of results from the given query") 63 | (^:spark/no-boot-spec-coverage 64 | limits! 65 | [_] 66 | "Returns the current limits") 67 | (cache 68 | [_] 69 | "Returns a persistent cache") 70 | (^:spark/no-boot-spec-coverage 71 | import! 72 | [_ type records] 73 | "Imports the given records into the given type") 74 | (list-actions! 75 | [_ path] 76 | "Gets a list of actions that can be performed") 77 | (describe-action! 78 | [_ action] 79 | "Describes an action") 80 | (take-action! 81 | [_ action inputs] 82 | "Submits a request to perform the given action")) 83 | 84 | (s/def ::client 85 | (partial satisfies? Client)) 86 | 87 | (s/fdef create! 88 | :args (s/cat :client ::client 89 | :type ::spec/type 90 | :attrs ::spec/attrs) 91 | :ret ::spec/id) 92 | 93 | (s/fdef delete! 94 | :args (s/cat :client ::client 95 | :type ::spec/type 96 | :id ::spec/id) 97 | :ret #{true}) 98 | 99 | (s/fdef update! 100 | :args (s/cat :client ::client 101 | :type ::spec/type 102 | :id ::spec/id 103 | :attrs ::spec/attrs) 104 | :ret #{true}) 105 | 106 | (s/fdef list! 107 | :args (s/cat :client ::client 108 | :type ::spec/type) 109 | :ret ::spec/json-map) 110 | 111 | (s/fdef describe! 112 | :args (s/cat :client ::client 113 | :type ::spec/type) 114 | :ret ::spec/object-description) 115 | 116 | (s/fdef objects! 117 | :args (s/cat :client ::client) 118 | :ret ::spec/objects-overview) 119 | 120 | (s/fdef query! 121 | :args (s/cat :client ::client 122 | :query ::spec/query) 123 | :ret ::spec/records) 124 | 125 | (s/fdef count! 126 | :args (s/cat :client ::client 127 | :query ::spec/query) 128 | :ret nat-int?) 129 | 130 | (s/fdef limits! 131 | :args (s/cat :client ::client) 132 | :ret ::spec/limits) 133 | 134 | (s/fdef import! 135 | :args (s/cat :client ::client 136 | :type ::spec/type 137 | :records (s/coll-of ::spec/attrs)) 138 | :ret (s/and (partial instance? clojure.lang.IDeref) 139 | (comp (partial s/valid? (s/coll-of any?)) deref)) 140 | :fn (fn [{:keys [args ret]}] 141 | (= (count (:records args)) (count @ret)))) 142 | 143 | (s/fdef build-atomic-cache 144 | :args (s/cat) 145 | :ret ::cache) 146 | 147 | (defn build-atomic-cache 148 | [] 149 | (let [state (atom {})] 150 | (reify Cache 151 | (put! [_ key value] 152 | (swap! state assoc-in [::cache key] value)) 153 | (get! [_ key] 154 | (get-in @state [::cache key]))))) 155 | 156 | (s/fdef build-http-client 157 | :args (s/cat :config ::http/config) 158 | :ret ::client) 159 | 160 | (defn ^:spark/no-boot-spec-coverage build-http-client 161 | [config] 162 | (let [client (http/build-client! config) 163 | cache (build-atomic-cache)] 164 | (reify Client 165 | (create! [_ type attrs] 166 | (http/create! client type attrs)) 167 | (delete! [_ type id] 168 | (http/delete! client type id)) 169 | (update! [_ type id attrs] 170 | (http/update! client type id attrs)) 171 | (list! [_ type] 172 | (http/list! client type)) 173 | (describe! [_ type] 174 | (http/describe! client type)) 175 | (objects! [_] 176 | (http/objects! client)) 177 | (query! [_ query] 178 | (http/query! client query)) 179 | (count! [_ query] 180 | (http/count! client query)) 181 | (limits! [_] 182 | (http/limits! client)) 183 | (import! [_ type records] 184 | (http/import! client type records)) 185 | (cache [_] 186 | cache) 187 | (list-actions! [_ subtype] 188 | (http/list-actions! client subtype)) 189 | (describe-action! [_ action] 190 | (http/describe-action! client action)) 191 | (take-action! [_ action inputs] 192 | (http/take-action! client action inputs))))) 193 | 194 | (s/def ::take-action-map 195 | (s/? (s/map-of string? (s/fdef f :args (s/cat :client any? 196 | :inputs (s/coll-of (s/map-of string? any?))) 197 | :ret any?)))) 198 | 199 | (s/fdef build-memory-client 200 | :args (s/cat :schema ::memory2/schema 201 | :take-action-map ::take-action-map) 202 | :ret ::client) 203 | 204 | (defn build-memory-client 205 | ([schema] 206 | (build-memory-client schema {})) 207 | ([schema take-action-map] 208 | (let [client (memory2/create-state! schema) 209 | cache (build-atomic-cache)] 210 | (reify Client 211 | (create! [_ type attrs] 212 | (memory2/create! client type attrs)) 213 | (delete! [_ type id] 214 | (memory2/delete! client type id)) 215 | (update! [_ type id attrs] 216 | (memory2/update! client type id attrs)) 217 | (list! [_ type] 218 | (memory2/list! client type)) 219 | (describe! [_ type] 220 | (memory2/describe! client type)) 221 | (objects! [_] 222 | (memory2/objects! client)) 223 | (query! [_ query] 224 | (memory2/query! client query)) 225 | (count! [_ query] 226 | (memory2/count! client query)) 227 | (limits! [_] 228 | (memory2/limits! client)) 229 | (import! [_ type records] 230 | (future (mapv (partial create! type) records))) 231 | (cache [_] 232 | cache) 233 | (take-action! [_ action inputs] 234 | (memory2/take-action! client take-action-map action inputs)))))) 235 | 236 | (defn client? 237 | [x] 238 | (and (extends? Client (class x)) x)) 239 | 240 | (s/fdef get-types 241 | :args (s/cat :client ::client) 242 | :ret (s/map-of ::clj/attr ::spec/object-overview)) 243 | 244 | (defn get-types 245 | "Obtains a map of descriptions by type" 246 | [client] 247 | (if-let [types (get! (cache client) ::types)] 248 | types 249 | (let [objects (objects! client) 250 | {:keys [sobjects]} objects 251 | type->object (->> sobjects 252 | (map (juxt clj/object->attr identity)) 253 | clj/set-map)] 254 | (put! (cache client) ::types type->object) 255 | type->object))) 256 | 257 | (s/fdef get-type-description 258 | :args (s/cat :client ::client 259 | :type ::clj/attr) 260 | :ret (s/nilable ::spec/object-description)) 261 | 262 | (defn get-type-description 263 | "Obtains the description for a given type and builds some custom indexes 264 | into it. This will only fetch the type once for a given client." 265 | [client type] 266 | (let [types (get-types client)] 267 | (when-let [overview (type types)] 268 | (if (:fields overview) 269 | overview 270 | (when-let [description (describe! client (:name overview))] 271 | (let [{:keys [fields]} description 272 | attr->field (->> fields 273 | (map (juxt clj/field->attr identity)) 274 | clj/set-map) 275 | field-index (->> fields 276 | (map (juxt (comp keyword :name) identity)) 277 | clj/set-map) 278 | label-index (reduce (fn [accum field] 279 | (let [attr (clj/field->attr field) 280 | {:keys [label]} field] 281 | (update accum label (fnil conj #{}) attr))) 282 | {} 283 | fields) 284 | description (assoc description 285 | ::attr->field attr->field 286 | ::field-index field-index 287 | ::label-index label-index) 288 | updated (merge overview description)] 289 | (put! (cache client) ::types (assoc types type updated)) 290 | updated)))))) 291 | 292 | (s/fdef get-fields 293 | :args (s/cat :client ::client 294 | :type ::clj/attr) 295 | :ret (s/nilable (s/map-of ::clj/attr ::spec/field-description))) 296 | 297 | (defn ^:spark/no-boot-spec-coverage get-fields 298 | "Obtains a map of descriptions by field for the given type" 299 | [client type] 300 | (::attr->field (get-type-description client type))) 301 | 302 | (s/fdef get-field-description 303 | :args (s/cat :client ::client 304 | :type ::clj/attr 305 | :attr ::clj/attr) 306 | :ret (s/nilable ::spec/field-description)) 307 | 308 | (defn get-field-description 309 | "Obtains the description for the given field on a type by its attribute" 310 | [client type attr] 311 | (let [type-description (get-type-description client type)] 312 | (get-in type-description [::attr->field attr]))) 313 | 314 | (s/fdef get-attrs-for-label 315 | :args (s/cat :client ::client 316 | :type ::clj/attr 317 | :label string?) 318 | :ret (s/coll-of ::clj/attr :kind set?)) 319 | 320 | (defn ^:spark/no-boot-spec-coverage get-attrs-for-label 321 | "Returns the set of attributes on the given type that have the given label" 322 | [client type label] 323 | (let [description (get-type-description client type)] 324 | (get (::label-index description) label))) 325 | 326 | (s/fdef resolve-attr-path 327 | :args (s/cat :client ::client 328 | :type ::clj/attr 329 | :attr-path ::clj/attr-path) 330 | :ret ::clj/field-path) 331 | 332 | (defn resolve-attr-path 333 | "Resolves a path of attrs against a given type, returning a path of fields. 334 | All but the last attr in a path must resolve to a reference type." 335 | [client type attr-path] 336 | (loop [type type 337 | attr-path attr-path 338 | fields []] 339 | (if-not (seq attr-path) 340 | fields 341 | (let [attr (first attr-path) 342 | field (get-field-description client type attr) 343 | attr-path' (next attr-path)] 344 | (when-not field 345 | (throw (ex-info "Invalid attr path" {:attr-path attr-path 346 | :type type}))) 347 | (recur (when attr-path' 348 | (clj/field->refers-attr field)) 349 | attr-path' 350 | (conj fields field)))))) 351 | 352 | (s/fdef resolve-field-path 353 | :args (s/cat :field-path ::clj/field-path) 354 | :ret ::clj/attr-path) 355 | 356 | (defn resolve-field-path 357 | "Derives a seq of record keys for the given seq of fields, suitable for 358 | applying to the result of the underlying query! fn" 359 | [field-path] 360 | (loop [record-path [] 361 | field-path field-path] 362 | (if-not (seq field-path) 363 | record-path 364 | (let [field (first field-path) 365 | field-path' (next field-path)] 366 | (when (and (not field-path') 367 | (= "reference" (:type field))) 368 | (throw (ex-info "Invalid field path" 369 | {:field-path field-path}))) 370 | (let [record-key (keyword (if (= "reference" (:type field)) 371 | (:relationshipName field) 372 | (:name field)))] 373 | (recur (conj record-path record-key) 374 | field-path')))))) 375 | 376 | (s/fdef schema 377 | :args (s/cat :client ::client 378 | :types (s/coll-of ::clj/attr)) 379 | :ret ::memory2/schema) 380 | 381 | (defn ^:spark/no-boot-spec-coverage schema 382 | [client types] 383 | (let [type-attrs #{:name :label :custom :fields} 384 | field-attrs #{:name 385 | :type 386 | :referenceTo 387 | :scale 388 | :precision 389 | :label 390 | :relationshipName 391 | :picklistValues 392 | :nillable 393 | :defaultValue} 394 | all-types (get-types client)] 395 | (into {} 396 | (for [type types] 397 | (let [type-name ((comp :name all-types) type) 398 | type-schema (-> (describe! client type-name) 399 | (select-keys type-attrs) 400 | (update :fields (partial map #(select-keys % field-attrs))))] 401 | [type-name type-schema]))))) 402 | -------------------------------------------------------------------------------- /src/sails_forth/clojurify.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.clojurify 2 | "Translates between SalesForce string API type/attribute names and 3 | more Clojure-y keywords." 4 | (:require [clj-time.coerce :as tc] 5 | [clj-time.format :as tf] 6 | [clojure.spec.alpha :as s] 7 | [clojure.string :as string] 8 | [sails-forth.spec :as spec]) 9 | (:import [org.joda.time DateTime LocalDate])) 10 | 11 | (s/def ::attr 12 | keyword?) 13 | 14 | (s/def ::attr-path 15 | (s/coll-of ::attr :kind vector? :min-count 1)) 16 | 17 | (s/def ::field-path 18 | (s/coll-of ::spec/field-description :kind vector? :min-count 1)) 19 | 20 | (s/fdef field->attr 21 | :args (s/cat :field ::spec/field-description) 22 | :ret ::attr) 23 | 24 | (defn field->attr 25 | "Derives a clojurey attribute keyword representation of a Salesforce field. 26 | This converts snake case to kebob case, removes any custom field suffix, 27 | and removes the Id suffix from native reference types." 28 | [field] 29 | (let [{:keys [name type]} field 30 | name' (string/replace name #"__c\Z" "") 31 | custom? (not= name name')] 32 | (-> name' 33 | (cond-> 34 | (and (= type "reference") 35 | (not custom?)) 36 | (string/replace #"Id\Z" "")) 37 | string/lower-case 38 | (string/replace \_ \-) 39 | keyword))) 40 | 41 | (s/fdef field->refers-attr 42 | :args (s/cat :field ::spec/field-description) 43 | :ret ::attr) 44 | 45 | (defn ^:spark/no-boot-spec-coverage field->refers-attr 46 | "Derives a clojurey attribute keyword representation of the Salesforce 47 | relation about which this field refers" 48 | [field] 49 | (let [{:keys [referenceTo type]} field] 50 | (when (or (not= "reference" type) 51 | (not (and (= 1 (count referenceTo)) 52 | (string? (first referenceTo))))) 53 | (throw (ex-info "Invalid reference field" {:field field}))) 54 | (-> referenceTo 55 | first 56 | (string/replace #"__c\Z" "") 57 | string/lower-case 58 | (string/replace \_ \-) 59 | keyword))) 60 | 61 | (s/fdef object->attr 62 | :args (s/cat :object ::spec/object-overview) 63 | :ret ::attr) 64 | 65 | (defn object->attr 66 | "Derives a clojurey type keyword representation of a Salesforce object. 67 | This converts snake case to kebob case and removes any custom field suffix." 68 | [object] 69 | (let [{:keys [name custom]} object 70 | name' (string/replace name #"__c\Z" "")] 71 | (-> name' 72 | string/lower-case 73 | (string/replace #"_{1,2}" "-") 74 | keyword))) 75 | 76 | (s/fdef set-map 77 | :args (s/cat :entries 78 | (s/and (s/coll-of (s/tuple keyword? any?)) 79 | #(= (count %) (count (set (map first %)))))) 80 | :ret (s/map-of keyword? any?)) 81 | 82 | (defn set-map 83 | "Builds a map from the given seq of entries, raising on any duplicate key" 84 | [entries] 85 | (reduce (fn [accum [k v]] 86 | (when (contains? accum k) 87 | (let [msg "Duplicate key given for map"] 88 | (throw (ex-info msg {:key k :entries entries})))) 89 | (assoc accum k v)) 90 | {} 91 | entries)) 92 | 93 | (s/def ::datetime 94 | (partial instance? DateTime)) 95 | 96 | (s/def ::date 97 | (partial instance? LocalDate)) 98 | 99 | (s/def ::value 100 | (s/or :datetime ::datetime 101 | :date ::date 102 | :int integer? 103 | :bigdec decimal? 104 | :other ::spec/json-simple)) 105 | 106 | (s/fdef parse-value 107 | :args (s/cat :field ::spec/field-description 108 | :value ::spec/json-simple) 109 | :ret ::value 110 | ;; TODO could specify that render-value is the inverse 111 | ) 112 | 113 | (def double-type 114 | (let [max-long-precision (dec (count (str Long/MAX_VALUE)))] 115 | (fn [field] 116 | (let [{:keys [scale precision]} field] 117 | (if (zero? scale) 118 | (if (<= precision max-long-precision) 119 | :long 120 | :bigint) 121 | :bigdec))))) 122 | 123 | (def parse-value 124 | "Parses the given value according to its field type and other characteristics" 125 | (let [date-time-formatter (tf/formatters :date-time) 126 | date-formatter (tf/formatters :date) 127 | max-long-precision (dec (count (str Long/MAX_VALUE)))] 128 | (fn [field value] 129 | (let [{:keys [type scale precision]} field] 130 | (case type 131 | "datetime" (tf/parse date-time-formatter value) 132 | "date" (tc/to-local-date (tf/parse date-formatter value)) 133 | "double" ((case (double-type field) 134 | :long long 135 | :bigint bigint 136 | :bigdec bigdec) value) 137 | "int" (long value) 138 | "percent" (/ value 100M) 139 | value))))) 140 | 141 | (defmulti default-coerce-from-salesforce 142 | (fn [field value] 143 | (:type field))) 144 | 145 | (defmethod default-coerce-from-salesforce :default 146 | [field value] 147 | (parse-value field value)) 148 | 149 | (s/fdef render-value 150 | :args (s/cat :field ::spec/field-description 151 | :value ::value) 152 | :ret ::spec/json-simple 153 | ;; TODO could specify that parse-value is the inverse 154 | ) 155 | 156 | (def render-value 157 | "Parses the given value according to its field type and other characteristics" 158 | (let [date-time-formatter (tf/formatters :date-time) 159 | date-formatter (tf/formatters :date)] 160 | (fn [field value] 161 | (let [{:keys [type scale precision]} field] 162 | (case type 163 | "datetime" (tf/unparse date-time-formatter (tc/to-date-time value)) 164 | "date" (tf/unparse date-formatter (tc/to-date-time value)) 165 | "percent" (* value 100M) 166 | value))))) 167 | 168 | (defmulti default-coerce-to-salesforce 169 | (fn [field value] 170 | (:type field))) 171 | 172 | (defmethod default-coerce-to-salesforce :default 173 | [field value] 174 | (render-value field value)) 175 | -------------------------------------------------------------------------------- /src/sails_forth/datomic.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.datomic 2 | "Provides fns to assert the results of salesforce queries as datoms." 3 | (:require [clj-time.coerce :as tc] 4 | [clojure.set :as set] 5 | [clojure.string :as s] 6 | [sails-forth.client :as c] 7 | [sails-forth.clojurify :as clj] 8 | [sails-forth.query :as q])) 9 | 10 | (def datomic-types 11 | {"datetime" :db.type/instant 12 | "date" :db.type/instant 13 | "int" :db.type/long 14 | "percent" :db.type/bigdec 15 | "currency" :db.type/bigdec 16 | "id" :db.type/string 17 | "string" :db.type/string 18 | "reference" :db.type/ref 19 | "boolean" :db.type/boolean 20 | "textarea" :db.type/string 21 | "picklist" :db.type/string 22 | "url" :db.type/uri 23 | "multipicklist" :db.type/string 24 | "phone" :db.type/string 25 | "address" :db.type/ref 26 | "email" :db.type/string 27 | "encryptedstring" :db.type/string}) 28 | 29 | (defn field-ident 30 | [ns-prefix field-name] 31 | (keyword (str (name ns-prefix) ".field") (name field-name))) 32 | 33 | (defn field-attr 34 | [ns-prefix object-name field-name] 35 | (keyword (str (name ns-prefix) ".object." (name object-name)) (name field-name))) 36 | 37 | (defn compound-ident 38 | [ns-prefix compound-name field-name] 39 | (keyword (str (name ns-prefix) ".compoound." (name compound-name)) (name field-name))) 40 | 41 | (defn metadata-schema 42 | [ns-prefix] 43 | ;; More field metadata could come along for the ride 44 | [{:db/ident (field-ident ns-prefix "name") 45 | :db/doc "Salesforce field name" 46 | :db/valueType :db.type/string 47 | :db/cardinality :db.cardinality/one} 48 | {:db/ident (field-ident ns-prefix "type") 49 | :db/doc "Salesforce field type" 50 | :db/valueType :db.type/string 51 | :db/cardinality :db.cardinality/one} 52 | {:db/ident (field-ident ns-prefix "formula") 53 | :db/doc "Salesforce field formula" 54 | :db/valueType :db.type/string 55 | :db/cardinality :db.cardinality/one} 56 | {:db/ident (field-ident ns-prefix "helptext") 57 | :db/doc "Salesforce field help text" 58 | :db/valueType :db.type/string 59 | :db/cardinality :db.cardinality/one} 60 | 61 | {:db/ident (compound-ident ns-prefix "address" "street") 62 | :db/doc "Salesforce address street" 63 | :db/valueType :db.type/string 64 | :db/cardinality :db.cardinality/one} 65 | {:db/ident (compound-ident ns-prefix "address" "city") 66 | :db/doc "Salesforce address city" 67 | :db/valueType :db.type/string 68 | :db/cardinality :db.cardinality/one} 69 | {:db/ident (compound-ident ns-prefix "address" "state-code") 70 | :db/doc "Salesforce address state" 71 | :db/valueType :db.type/string 72 | :db/cardinality :db.cardinality/one} 73 | {:db/ident (compound-ident ns-prefix "address" "postal-code") 74 | :db/doc "Salesforce address postal code" 75 | :db/valueType :db.type/string 76 | :db/cardinality :db.cardinality/one} 77 | {:db/ident (compound-ident ns-prefix "address" "country-code") 78 | :db/doc "Salesforce address country" 79 | :db/valueType :db.type/string 80 | :db/cardinality :db.cardinality/one}]) 81 | 82 | (defn object-schema 83 | [ns-prefix object-key fields] 84 | (letfn [(field-datoms [[key field]] 85 | (let [{:keys [name 86 | label 87 | type 88 | calculatedFormula 89 | inlineHelpText 90 | unique]} 91 | field 92 | cardinality (if (= "multipicklist" type) 93 | :db.cardinality/many 94 | :db.cardinality/one) 95 | recordtype? (= key :recordtype) 96 | valuetype (cond 97 | recordtype? :db.type/string 98 | (= "double" type) 99 | (case (clj/double-type field) 100 | :long :db.type/long 101 | :bigint :db.type/bigint 102 | :bigdec :db.type/bigdec) 103 | :else 104 | (get datomic-types type))] 105 | (cond-> {:db/ident (field-attr ns-prefix object-key key) 106 | :db/doc label 107 | :db/valueType valuetype 108 | :db/cardinality cardinality 109 | (field-ident ns-prefix "name") name 110 | (field-ident ns-prefix "type") type} 111 | ;; Sorta funny that id types don't have :unique true 112 | (or (= "id" type) unique) 113 | (assoc :db/unique :db.unique/identity) 114 | (= "address" type) 115 | (assoc :db/isComponent true) 116 | calculatedFormula 117 | (assoc (field-ident ns-prefix "formula") calculatedFormula) 118 | inlineHelpText 119 | (assoc (field-ident ns-prefix "helptext") inlineHelpText))))] 120 | (into [] (map field-datoms) fields))) 121 | 122 | (defn build-schema 123 | [client ns-prefix object-keys] 124 | [(metadata-schema ns-prefix) 125 | (into [] 126 | (mapcat (fn [object-key] 127 | (object-schema ns-prefix object-key (c/get-fields client object-key)))) 128 | object-keys)]) 129 | 130 | (defn assert-object 131 | [client ns-prefix object-key m] 132 | (let [fields (c/get-fields client object-key)] 133 | (reduce-kv (fn [txn field-key value] 134 | (let [attr (field-attr ns-prefix object-key field-key) 135 | field (get fields field-key) 136 | {:keys [type referenceTo]} field 137 | recordtype? (= field-key :recordtype) 138 | [value ref-types] 139 | (case type 140 | "multipicklist" 141 | [(s/split value #";")] 142 | "date" 143 | [(tc/to-date value)] 144 | "reference" 145 | (if-not recordtype? 146 | (let [ref-key (clj/field->refers-attr field) 147 | ref-object (assert-object client ns-prefix ref-key value)] 148 | [(dissoc ref-object ::types) 149 | (get ref-object ::types)]) 150 | [(get value :name)]) 151 | "address" 152 | (let [{:keys [street city stateCode postalCode countryCode]} value 153 | attr (partial compound-ident ns-prefix "address")] 154 | [(cond-> {} 155 | street (assoc (attr "street") street) 156 | city (assoc (attr "city") city) 157 | stateCode (assoc (attr "state-code") stateCode) 158 | postalCode (assoc (attr "postal-code") postalCode) 159 | countryCode (assoc (attr "country-code") countryCode))]) 160 | [value])] 161 | (-> txn 162 | (assoc attr value) 163 | (cond-> (seq ref-types) 164 | (update ::types into ref-types))))) 165 | {::types #{object-key}} 166 | m))) 167 | 168 | (defn assert-query 169 | "Returns a seq of transaction seqs that if transacted in order will assert 170 | the results of the given query in a datomic database. 171 | 172 | Given an ns-prefix of `\"sf\"` and a query of 173 | `{:find [:customer :id :sectors [:contact :id :phone]]}`: 174 | 175 | The first transaction asserts a set of attributes that will be defined on the 176 | attributes that will model the salesforce fields where there is no direct 177 | datomic analog, e.g. `sf.field/helptext`. 178 | 179 | The second transaction asserts a set of attributes that model the 180 | fields of objects used in the query, 181 | e.g. `sf.object.customer/id`. Note this is a complete set of 182 | attributes, not limited simply to those used in the query. 183 | 184 | The last transaction asserts the entities returned by the query. 185 | 186 | Most field values have natural datomic types. Notable exceptions include: 187 | 188 | * picklist, multipicklist: stored as strings. The api does not provide any 189 | access to inactive picklist items, which makes asserting e.g. enum values 190 | problematic 191 | * recordtype references: stored as strings for similar reasons 192 | * address: stored as component references 193 | 194 | There are some modest restrictions on the queries that can be asserted. 195 | All join references must include an identity field, except for recordtype 196 | joins which must only include the `:name` field." 197 | [client ns-prefix query] 198 | (let [objects (into [] 199 | (comp (map (comp first seq)) 200 | (map (partial apply assert-object client ns-prefix))) 201 | (q/query client query)) 202 | object-keys (reduce set/union #{} (map ::types objects))] 203 | (conj (build-schema client ns-prefix object-keys) 204 | (into [] (map (fn [m] (dissoc m ::types)) objects))))) 205 | -------------------------------------------------------------------------------- /src/sails_forth/http.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.http 2 | (:require [cheshire.parse] 3 | [clj-http.client :as http] 4 | [clojure.spec.alpha :as s] 5 | [clojure.string :as string] 6 | [sails-forth.spec :as spec])) 7 | 8 | (def http-methods 9 | #{:get :post :patch :put :delete}) 10 | 11 | (s/def ::method 12 | http-methods) 13 | 14 | (s/def ::url 15 | string?) 16 | 17 | (s/def ::params 18 | (s/map-of keyword? any?)) 19 | 20 | (s/def ::status 21 | (s/int-in 100 600)) 22 | 23 | (s/def ::body 24 | ::spec/json-map) 25 | 26 | (s/def ::headers 27 | (s/map-of string? string?)) 28 | 29 | (s/def ::request 30 | (s/keys :req-un [::method ::url])) 31 | 32 | (s/def ::response 33 | (s/keys ::req-un [::status] 34 | ::opt-un [::body ::headers])) 35 | 36 | (s/fdef json-request 37 | :args (s/cat :method ::method 38 | :headers ::headers 39 | :url ::url 40 | :params (s/nilable ::params)) 41 | :ret ::response) 42 | 43 | (defn ^:spark/no-boot-spec-coverage json-request 44 | [method headers url params] 45 | (let [request (cond-> {:method method 46 | :url url 47 | :throw-exceptions false 48 | :accept :json 49 | :coerce :always 50 | :as :json} 51 | (seq headers) 52 | (assoc :headers headers) 53 | (and (not (nil? params)) 54 | (or (= :post method) 55 | (= :patch method))) 56 | (assoc :form-params params 57 | :content-type :json) 58 | (and (seq params) 59 | (= :get method)) 60 | (assoc :query-params params))] 61 | (binding [cheshire.parse/*use-bigdecimals?* true] 62 | (http/request request)))) 63 | 64 | (s/def ::instance_url 65 | ::url) 66 | 67 | (s/def ::access_token 68 | string?) 69 | 70 | (s/def ::non-nil-authentication 71 | (s/keys :req-un [::instance_url 72 | ::access_token])) 73 | 74 | (s/def ::authentication 75 | (s/nilable ::non-nil-authentication)) 76 | 77 | (s/def ::username 78 | string?) 79 | 80 | (s/def ::password 81 | string?) 82 | 83 | (s/def ::token 84 | string?) 85 | 86 | (s/def ::consumer-key 87 | string?) 88 | 89 | (s/def ::consumer-secret 90 | string?) 91 | 92 | (s/def ::version 93 | string?) 94 | 95 | (s/def ::sandbox? 96 | boolean?) 97 | 98 | (s/def ::host 99 | string?) 100 | 101 | (s/def ::read-only? 102 | boolean?) 103 | 104 | (s/def ::config 105 | (s/keys :req-un [::username 106 | ::password 107 | ::token 108 | ::consumer-key 109 | ::consumer-secret] 110 | :opt-un [::version 111 | ::sandbox? 112 | ::host 113 | ::read-only?])) 114 | 115 | (s/def ::version-url 116 | (s/nilable ::url)) 117 | 118 | (s/def ::requests 119 | (s/or :zero zero? 120 | :pos pos-int?)) 121 | 122 | (s/def ::state 123 | (s/keys :req-un [::host 124 | ::requests 125 | ::read-only? 126 | ::config] 127 | :opt-un [::authentication 128 | ::version-url 129 | ::version])) 130 | 131 | (s/fdef authenticate 132 | :args (s/cat :config ::config) 133 | :ret ::authentication) 134 | 135 | (defn ^:spark/no-boot-spec-coverage authenticate 136 | [config] 137 | (let [{:keys [host username password token consumer-key consumer-secret]} config 138 | params {:username username 139 | :password (str password token) 140 | :client_id consumer-key 141 | :client_secret consumer-secret 142 | :grant_type "password"} 143 | url (str "https://" host "/services/oauth2/token") 144 | ;; TODO this is just like json-request except the body is form encoded 145 | request {:method :post 146 | :url url 147 | :throw-exceptions false 148 | :form-params params 149 | :accept :json 150 | :coerce :always 151 | :as :json} 152 | response (http/request request) 153 | {:keys [status body]} response] 154 | (when (and (= 200 status) 155 | (s/valid? ::non-nil-authentication body)) 156 | body))) 157 | 158 | (s/def ::version-map 159 | (s/keys :req-un [::url 160 | ::version])) 161 | 162 | (s/def ::versions 163 | (s/coll-of ::version-map)) 164 | 165 | (s/fdef versions 166 | :args (s/cat :url ::url) 167 | :ret (s/nilable ::versions)) 168 | 169 | (defn ^:spark/no-boot-spec-coverage versions 170 | [url] 171 | (let [url (str url "/services/data/") 172 | response (json-request :get {} url nil) 173 | {:keys [status body]} response] 174 | (when (and (= 200 status) 175 | (s/valid? ::versions body)) 176 | body))) 177 | 178 | (s/def ::api-hosts 179 | #{"test.salesforce.com" "login.salesforce.com"}) 180 | 181 | (s/fdef derive-host 182 | :args (s/cat :config ::config) 183 | :ret (s/or :implied ::api-hosts 184 | :given ::host)) 185 | 186 | (defn ^:spark/no-boot-spec-coverage derive-host 187 | [config] 188 | (let [{:keys [sandbox? host]} config] 189 | (or host (if sandbox? "test.salesforce.com" "login.salesforce.com")))) 190 | 191 | (s/fdef build-state 192 | :args (s/cat :config ::config) 193 | :ret ::state) 194 | 195 | (defn ^:spark/no-boot-spec-coverage build-state 196 | [config] 197 | (let [{:keys [version]} config 198 | host (derive-host config) 199 | read-only? (get config :read-only? false)] 200 | (cond-> {:authentication nil 201 | :version-url nil 202 | :requests 0 203 | :host host 204 | :read-only? read-only? 205 | :config (assoc config :host host)} 206 | version (assoc :version-url (str "/services/data/v" version))))) 207 | 208 | (s/fdef try-authentication 209 | :args (s/cat :state ::state) 210 | :ret ::state) 211 | 212 | (defn ^:spark/no-boot-spec-coverage try-authentication 213 | [state] 214 | (let [{:keys [authentication config requests]} state] 215 | (cond-> state 216 | (not authentication) 217 | (assoc :authentication (authenticate config) 218 | :requests (inc requests))))) 219 | 220 | (s/fdef try-to-find-latest-version 221 | :args (s/cat :state ::state) 222 | :ret ::state) 223 | 224 | (defn ^:spark/no-boot-spec-coverage try-to-find-latest-version 225 | [state] 226 | (let [{:keys [authentication requests version version-url]} state 227 | last-version (when (and (not (and version version-url)) 228 | authentication) 229 | (last (versions (:instance_url authentication))))] 230 | (cond-> state 231 | last-version 232 | (assoc :version-url (:url last-version) 233 | :version (:version last-version) 234 | :requests (inc requests))))) 235 | 236 | (s/def ::service 237 | #{:data :async}) 238 | 239 | (s/fdef request 240 | :args (s/cat :state ::state 241 | :method ::method 242 | :service ::service 243 | :url ::url 244 | :params ::params) 245 | :ret (s/tuple ::state (s/nilable ::response))) 246 | 247 | (defn ^:spark/no-boot-spec-coverage request 248 | [state method service url params] 249 | (when (and (:read-only? state) 250 | (case method 251 | :post true 252 | :put true 253 | :patch true 254 | :delete true 255 | false)) 256 | (let [data {:method method 257 | :url url 258 | :params params} 259 | message "Read-only clients may not issue requests with side effects"] 260 | (throw (ex-info message data)))) 261 | (loop [state state 262 | tries 0] 263 | (let [state (-> state 264 | try-authentication 265 | try-to-find-latest-version) 266 | {:keys [authentication requests version version-url]} state 267 | response (when-let [{:keys [access_token instance_url]} authentication] 268 | (let [[url-pattern headers] 269 | ;; Salesforce: not a shining example of consistency 270 | (case service 271 | :data ["/services/%s/v%s" 272 | {"Authorization" (str "Bearer " access_token)}] 273 | :async ["/services/%s/%s" 274 | {"X-SFDC-Session" access_token}]) 275 | prefix (format url-pattern (name service) version) 276 | url (if (string/starts-with? url prefix) 277 | (str instance_url url) ;; url already knows its service and version, dont append prefix a second time 278 | (str instance_url prefix url))] 279 | (json-request method headers url params))) 280 | {:keys [status body]} response 281 | state (cond-> state 282 | authentication 283 | (assoc :requests (inc requests)))] 284 | (if (and (= 401 status) 285 | (= tries 0)) 286 | (recur (assoc state :authentication nil) (inc tries)) 287 | [state response])))) 288 | 289 | (s/def ::client 290 | (s/and (partial instance? clojure.lang.Atom) 291 | (comp (partial s/valid? ::state) deref))) 292 | 293 | (s/fdef request! 294 | :args (s/cat :client ::client 295 | :method ::method 296 | :service ::service 297 | :url ::url 298 | :params ::params) 299 | :ret (s/nilable ::response)) 300 | 301 | (defn ^:spark/no-boot-spec-coverage request! 302 | "Issue the given request using the given client" 303 | [client method service url params] 304 | (let [[client' response] (request @client method service url params)] 305 | ;; TODO could try to merge states, otherwise request count may be incorrect 306 | (reset! client client') 307 | response)) 308 | 309 | (s/fdef build-client! 310 | :args (s/cat :config ::config) 311 | :ret ::client) 312 | 313 | (defn ^:spark/no-boot-spec-coverage build-client! 314 | "Creates a stateful Salesforce client from the given config. The client 315 | authenticates lazily and uses the latest Salesforce version if none is 316 | specified. If an authenticated request receives an invalid authentication 317 | response, the client will try to reauthenticate and retry the request. 318 | 319 | The client may be used concurrently, but it may unnecessarily attempt 320 | to authenticate concurrently and may not update its internal request count 321 | correctly. 322 | 323 | This fn explicitly makes no guarantees regarding the type of the client 324 | entity, other than it can be used with the user-facing fns in this ns." 325 | [config] 326 | (atom (build-state config))) 327 | 328 | (s/fdef create! 329 | :args (s/cat :client ::client 330 | :type ::spec/type 331 | :attrs ::spec/attrs) 332 | :ret ::spec/id) 333 | 334 | (defn ^:spark/no-boot-spec-coverage create! 335 | "Creates an object of the given type and attrs using the given salesforce 336 | client. If salesforce responds successfully, this returns the object's id, 337 | otherwise this raises an exception." 338 | [client type attrs] 339 | (let [url (str "/sobjects/" type) 340 | response (request! client :post :data url attrs) 341 | {:keys [status body]} response] 342 | (if (and (= 201 status) 343 | (s/valid? ::spec/entity body)) 344 | (get body :id) 345 | (let [data {:type type 346 | :attrs attrs 347 | :status status 348 | :body body} 349 | message (case status 350 | 400 "Could not create invalid salesforce object" 351 | nil "Could not authenticate to salesforce" 352 | "Invalid salesforce response")] 353 | (throw (ex-info message data)))))) 354 | 355 | (s/fdef delete! 356 | :args (s/cat :client ::client 357 | :type ::spec/type 358 | :id ::spec/id) 359 | :ret boolean?) 360 | 361 | (defn ^:spark/no-boot-spec-coverage delete! 362 | "Deletes the object of the given type with the given id. This returns true 363 | if it succeeds and raises an exception otherwise." 364 | [client type id] 365 | (let [url (str "/sobjects/" type "/" id) 366 | response (request! client :delete :data url {}) 367 | {:keys [status body]} response] 368 | (if (= 204 status) 369 | true 370 | (let [data {:type type 371 | :id id 372 | :status status 373 | :body body} 374 | message (case status 375 | nil "Could not authenticate to salesforce" 376 | "Invalid salesforce response")] 377 | (throw (ex-info message data)))))) 378 | 379 | (s/fdef update! 380 | :args (s/cat :client ::client 381 | :type ::spec/type 382 | :id ::spec/id 383 | :attrs ::spec/attrs) 384 | :ret boolean?) 385 | 386 | (defn ^:spark/no-boot-spec-coverage update! 387 | "Updates the object of the given type with the given id. This returns true 388 | if it succeeds and raises an exception otherwise." 389 | [client type id attrs] 390 | (let [url (str "/sobjects/" type "/" id) 391 | response (request! client :patch :data url attrs) 392 | {:keys [status body]} response] 393 | (if (= 204 status) 394 | true 395 | (let [data {:type type 396 | :id id 397 | :status status 398 | :body body} 399 | message (case status 400 | nil "Could not authenticate to salesforce" 401 | "Invalid salesforce response")] 402 | (throw (ex-info message data)))))) 403 | 404 | (s/fdef list! 405 | :args (s/cat :client ::spec/client 406 | :type ::spec/type) 407 | :ret (s/nilable ::spec/json-map)) 408 | 409 | (defn ^:spark/no-boot-spec-coverage list! 410 | [client type] 411 | (let [url (str "/sobjects/" type) 412 | response (request! client :get :data url {}) 413 | {:keys [status body]} response] 414 | (cond (and (= 200 status) 415 | (s/valid? ::spec/json-map body)) 416 | body 417 | (= 404 status) 418 | nil 419 | :else 420 | (let [data {:type type 421 | :status status 422 | :body body} 423 | message "Could not retrieve list of salesforce objects"] 424 | (throw (ex-info message data)))))) 425 | 426 | (s/fdef describe! 427 | :args (s/cat :client ::client 428 | :type ::spec/type) 429 | :ret (s/nilable ::spec/object-description)) 430 | 431 | (defn ^:spark/no-boot-spec-coverage describe! 432 | [client type] 433 | (let [url (str "/sobjects/" type "/describe") 434 | response (request! client :get :data url {}) 435 | {:keys [status body]} response] 436 | (cond (and (= 200 status) 437 | (s/valid? ::spec/object-description body)) 438 | body 439 | (= 404 status) 440 | nil 441 | :else 442 | (let [data {:type type 443 | :status status 444 | :body body} 445 | message "Could not retrieve description of salesforce object"] 446 | (throw (ex-info message data)))))) 447 | 448 | (s/fdef objects! 449 | :args (s/cat :client ::client) 450 | :ret ::spec/objects-overview) 451 | 452 | (defn ^:spark/no-boot-spec-coverage objects! 453 | [client] 454 | (let [url "/sobjects" 455 | response (request! client :get :data url {}) 456 | {:keys [status body]} response] 457 | (cond (and (= 200 status) 458 | (s/valid? ::spec/objects-overview body)) 459 | body 460 | :else 461 | (let [data {:status status 462 | :body body} 463 | message "Could not retrieve list of salesforce objects"] 464 | (throw (ex-info message data)))))) 465 | 466 | (s/fdef query! 467 | :args (s/cat :client ::client 468 | :query ::spec/query) 469 | :ret ::spec/records) 470 | 471 | (defn ^:spark/no-boot-spec-coverage query! 472 | "Executes the given query and returns all results, eagerly fetching if there 473 | is pagination" 474 | [client query] 475 | (let [url "/query" 476 | params {:q query} 477 | response (request! client :get :data url params)] 478 | (loop [response response 479 | results []] 480 | (let [{:keys [status body]} response] 481 | (if (and (= 200 status) 482 | (s/valid? ::spec/query-results body)) 483 | (let [results (into results (get body :records))] 484 | (if (get body :done) 485 | results 486 | (let [url (get body :nextRecordsUrl)] 487 | (recur (request! client :get :data url {}) results)))) 488 | (let [data {:query query 489 | :status status 490 | :body body} 491 | message "Could not execute salesforce query"] 492 | (throw (ex-info message data)))))))) 493 | 494 | (s/fdef count! 495 | :args (s/cat :client ::client 496 | :query ::spec/query) 497 | :ret nat-int?) 498 | 499 | (defn ^:spark/no-boot-spec-coverage count! 500 | "Executes the given query and returns the total number of results. 501 | This is intended for use with COUNT() queries." 502 | [client query] 503 | (let [url "/query" 504 | params {:q query} 505 | response (request! client :get :data url params)] 506 | (let [{:keys [status body]} response] 507 | (if (and (= 200 status) 508 | (s/valid? ::spec/count-query-results body)) 509 | (get body :totalSize) 510 | (let [data {:query query 511 | :status status 512 | :body body} 513 | message "Could not execute salesforce count query"] 514 | (throw (ex-info message data))))))) 515 | 516 | (s/fdef limits! 517 | :args (s/cat :client ::client) 518 | :ret ::spec/limits) 519 | 520 | (defn ^:spark/no-boot-spec-coverage limits! 521 | [client] 522 | (let [response (request! client :get :data "/limits" {}) 523 | {:keys [status body]} response] 524 | (if (and (= 200 status) 525 | (s/valid? ::spec/limits body)) 526 | body 527 | (let [data {:status status 528 | :body body} 529 | message "Could not find salesforce limits"] 530 | (throw (ex-info message data)))))) 531 | 532 | (s/def ::job-operation 533 | #{:insert}) 534 | 535 | (s/def :sails-forth.http.job/state 536 | #{"Open" "Closed"}) 537 | 538 | (s/def ::job 539 | (s/keys :req-un [::spec/id 540 | :sails-forth.http.job/state])) 541 | 542 | (s/fdef create-import-job! 543 | :args (s/cat :client ::client 544 | :type ::spec/type 545 | :operation ::job-operation) 546 | :ret (s/nilable ::spec/id)) 547 | 548 | (defn ^:spark/no-boot-spec-coverage create-import-job! 549 | [client type operation] 550 | (let [params {:operation (name operation) 551 | :object type 552 | :contentType "JSON"} 553 | response (request! client :post :async "/job" params) 554 | {:keys [status body]} response] 555 | (when (and (= 201 status) 556 | (s/valid? ::job body) 557 | (= "Open" (:state body))) 558 | (:id body)))) 559 | 560 | (s/fdef close-import-job! 561 | :args (s/cat :client ::client 562 | :id ::spec/id) 563 | :ret (s/nilable true?)) 564 | 565 | (defn ^:spark/no-boot-spec-coverage close-import-job! 566 | [client id] 567 | (let [params {:state "Closed"} 568 | response (request! client :post :async (str "/job/" id) params) 569 | {:keys [status body]} response] 570 | (when (and (= 200 status) 571 | (s/valid? ::job body) 572 | (= "Closed" (:state body))) 573 | true))) 574 | 575 | (s/def ::batch 576 | (s/keys :req-un [::spec/id])) 577 | 578 | (s/fdef add-import-batch! 579 | :args (s/cat :client ::client 580 | :id ::spec/id 581 | :records (s/coll-of ::spec/attrs)) 582 | :ret ::spec/id) 583 | 584 | (defn ^:spark/no-boot-spec-coverage add-import-batch! 585 | [client id records] 586 | (let [url (str "/job/" id "/batch") 587 | response (request! client :post :async url records) 588 | {:keys [status body]} response] 589 | (when (and (= 201 status) 590 | (s/valid? ::batch body)) 591 | (:id body)))) 592 | 593 | (s/def ::success 594 | boolean?) 595 | 596 | (s/def ::created 597 | boolean?) 598 | 599 | (s/def ::batch-results 600 | (s/coll-of (s/keys :req-un [::success]))) 601 | 602 | (defn get-batch-results! 603 | [client job-id batch-id] 604 | (let [url (str "/job/" job-id "/batch/" batch-id "/result") 605 | response (request! client :get :async url {}) 606 | {:keys [status body]} response] 607 | (when (and (= 200 status) 608 | (s/valid? ::batch-results body)) 609 | body))) 610 | 611 | (def import-poll-timeout-ms 612 | (* 60 1000)) 613 | 614 | (def import-total-tries 615 | 10) 616 | 617 | (defn import! 618 | [client type records] 619 | (let [job-id (create-import-job! client type :insert) 620 | batch-id (when job-id 621 | (add-import-batch! client job-id records))] 622 | (when (and batch-id (close-import-job! client job-id)) 623 | (delay (loop [tries 0] 624 | (or (get-batch-results! client job-id batch-id) 625 | (when (< tries import-total-tries) 626 | (Thread/sleep import-poll-timeout-ms) 627 | (recur (inc tries))))))))) 628 | 629 | (defn list-actions! 630 | "Lists actions that can be performed at the given path." 631 | [client path] 632 | (let [url (str "/actions/" path) 633 | response (request! client :get :data url nil) 634 | {:keys [status body]} response] 635 | (when (= status 200) 636 | (if (contains? body :actions) 637 | (vec (for [action (:actions body)] 638 | (str path "/" (:name action)))) 639 | (mapv #(str path "/" (name %)) (keys body)))))) 640 | 641 | (defn describe-action! 642 | "Describes the action at the given path." 643 | [client action] 644 | (let [url (str "/actions/" action) 645 | response (request! client :get :data url nil) 646 | {:keys [status body]} response] 647 | (when (= status 200) 648 | (when (contains? body :inputs) 649 | body)))) 650 | 651 | (defn take-action! 652 | [client action inputs] 653 | (let [url (str "/actions/" action) 654 | response (request! client :post :data url {"inputs" inputs}) 655 | {:keys [status body]} response] 656 | (if (and (= status 200) 657 | (every? :isSuccess body)) 658 | (mapv (comp :output :outputValues) body) 659 | (throw (ex-info "action failed" response))))) 660 | -------------------------------------------------------------------------------- /src/sails_forth/memory2.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.memory2 2 | (:require [clojure.set :as set] 3 | [clojure.spec.alpha :as s] 4 | [clojure.string :as string] 5 | [clojure.walk :as walk] 6 | [sails-forth.clojurify :as clj]) 7 | (:import [java.util UUID] 8 | [org.mule.tools.soql SOQLParserHelper] 9 | [org.joda.time DateTime LocalDate]) 10 | (:refer-clojure :exclude [list update])) 11 | 12 | ;; TODO could narrow this down a bit lol 13 | (s/def ::schema 14 | any?) 15 | 16 | (defn build-state 17 | [schema] 18 | {:last-id nil 19 | :objects {} 20 | :schema schema}) 21 | 22 | (defn create-state! 23 | [schema] 24 | (atom (build-state schema))) 25 | 26 | (defn type-schema 27 | [schema type] 28 | (let [type-schema (schema type)] 29 | (when-not type-schema 30 | (throw (ex-info "invalid type" {:type type}))) 31 | type-schema)) 32 | 33 | (defn object-exists? 34 | [state type id] 35 | (let [{:keys [objects]} state] 36 | (get-in objects [type id]))) 37 | 38 | (defn validate-existence 39 | [state type id] 40 | (when-not (object-exists? state type id) 41 | (throw (ex-info "object not found" {:type type :id id})))) 42 | 43 | (defn validate-attr 44 | [state field value] 45 | (let [{:keys [type]} field 46 | picklist-values (->> (:picklistValues field) 47 | (filter :active) 48 | (map :value) 49 | (into #{}))] 50 | (or (nil? value) 51 | (case type 52 | "id" 53 | (string? value) 54 | "string" 55 | (string? value) 56 | "textarea" 57 | (string? value) 58 | "url" 59 | (string? value) 60 | "datetime" 61 | (instance? DateTime value) 62 | "date" 63 | (instance? LocalDate value) 64 | "double" 65 | (number? value) 66 | "currency" 67 | (number? value) 68 | "percent" 69 | (number? value) 70 | "int" 71 | (integer? value) 72 | "picklist" 73 | (contains? picklist-values value) 74 | "multipicklist" 75 | (let [all-values (string/split value #";")] 76 | (every? (partial contains? picklist-values) all-values)) 77 | "phone" 78 | (string? value) 79 | "boolean" 80 | (#{true false} value) 81 | "reference" 82 | (let [type (first (:referenceTo field))] 83 | (object-exists? state type value)) 84 | (throw (ex-info "Unknown field type" {:field field})))))) 85 | 86 | (defn validate-attrs 87 | [state type attrs] 88 | (let [{:keys [schema]} state 89 | type-schema (type-schema schema type) 90 | {:keys [fields]} type-schema 91 | fields (into {} (map (juxt :name identity) fields)) 92 | diff (set/difference (set (keys attrs)) (set (keys fields))) 93 | invalid-attrs (keep (fn [[attr value]] 94 | (let [field (get fields attr) 95 | value (clj/default-coerce-from-salesforce field value)] 96 | nil)) 97 | attrs)] 98 | (when (or (seq diff) (not (empty? invalid-attrs))) 99 | (throw (ex-info "invalid attrs" 100 | {:attrs attrs 101 | :extra-keys diff 102 | :values-with-invalid-type invalid-attrs}))))) 103 | 104 | (defn create 105 | [state type attrs] 106 | (let [{:keys [schema objects]} state 107 | id (str (UUID/randomUUID)) 108 | attrs' (assoc attrs "Id" id)] 109 | (validate-attrs state type attrs') 110 | (-> state 111 | (assoc :last-id id) 112 | (assoc-in [:objects type id] attrs')))) 113 | 114 | (defn create! 115 | [astate type attrs] 116 | (:last-id (swap! astate create type attrs))) 117 | 118 | (defn delete 119 | [state type id] 120 | (validate-existence state type id) 121 | (update-in state [:objects type] dissoc id)) 122 | 123 | (defn delete! 124 | [astate type id] 125 | (swap! astate delete type id) 126 | true) 127 | 128 | (defn update-object 129 | [state type id attrs] 130 | (validate-existence state type id) 131 | (let [old (get-in state [:objects type id]) 132 | new (merge old attrs)] 133 | (validate-attrs state type new) 134 | (assoc-in state [:objects type id] new))) 135 | 136 | (defn update! 137 | [astate type id attrs] 138 | (swap! astate update-object type id attrs) 139 | true) 140 | 141 | (defn list 142 | [state type] 143 | (into [] (vals (get-in state [:objects type])))) 144 | 145 | (defn list! 146 | [astate type] 147 | (list @astate type)) 148 | 149 | (defn describe 150 | [state type] 151 | (let [{:keys [schema]} state] 152 | (type-schema schema type))) 153 | 154 | (defn describe! 155 | [astate stype] 156 | (describe @astate stype)) 157 | 158 | (defn objects 159 | [state] 160 | (let [{:keys [schema]} state] 161 | {:sobjects (mapv #(dissoc % :fields) (vals schema))})) 162 | 163 | (defn objects! 164 | [astate] 165 | (objects @astate)) 166 | 167 | (defprotocol Filter 168 | (allows? [this schema object] 169 | "Returns true if the object is allowed by this filter")) 170 | 171 | (defprotocol Evaluable 172 | ;; This is used by the filter implementations to evaluate their operands 173 | (eval2 [this schema object] 174 | "Evalutes this against the schema and object")) 175 | 176 | (defn parse-literal 177 | [s] 178 | ;; Absolutely wild that the parser throws away the type info 179 | ;; so we get to duplicate the cases about which we care here 180 | (condp re-matches s 181 | #"true" true 182 | #"false" false 183 | #"null" nil 184 | #"\d\d\d\d-\d\d-\d\d" (org.joda.time.LocalDate. s) 185 | #"\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|(\+|-)?\d\d:\d\d)?" (org.joda.time.DateTime. s) 186 | #"(\+|-)?\d+" (Long/parseLong s) 187 | #"(\+|-)?\d+(\.\d+)?" (Double/parseDouble s) 188 | #"'(.*)'" :>> (fn [[_ s]] s))) 189 | 190 | (extend-protocol Evaluable 191 | org.mule.tools.soql.query.data.Literal 192 | (eval2 [literal _ _] 193 | (parse-literal (.toString literal))) 194 | org.mule.tools.soql.query.data.Field 195 | (eval2 [field schema object] 196 | (let [object-path (into [] (map keyword) (.getObjectPrefixNames field)) 197 | object (cond-> object 198 | (seq object-path) 199 | (get-in object-path)) 200 | type (get-in object [:attributes :type]) 201 | field-name (.getFieldName field) 202 | sf-field (some (fn [sf-field] 203 | (when (= field-name 204 | (get sf-field :name)) 205 | sf-field)) 206 | (get-in schema [type :fields])) 207 | value (get object (keyword field-name))] 208 | (when value 209 | (clj/default-coerce-from-salesforce sf-field value))))) 210 | 211 | (extend-protocol Filter 212 | org.mule.tools.soql.query.condition.operator.AndOperator 213 | (allows? [operator schema object] 214 | (and (allows? (.getLeftCondition operator) schema object) 215 | (allows? (.getRightCondition operator) schema object))) 216 | org.mule.tools.soql.query.condition.operator.OrOperator 217 | (allows? [operator schema object] 218 | (or (allows? (.getLeftCondition operator) schema object) 219 | (allows? (.getRightCondition operator) schema object))) 220 | org.mule.tools.soql.query.condition.operator.NotOperator 221 | (allows? [operator schema object] 222 | (not (allows? (.getCondition operator) schema object))) 223 | org.mule.tools.soql.query.condition.operator.Parenthesis 224 | (allows? [operator schema object] 225 | (allows? (.getCondition operator) schema object)) 226 | org.mule.tools.soql.query.condition.FieldBasedCondition 227 | (allows? [condition schema object] 228 | (let [pred (case (.toString (.getOperator condition)) 229 | ">" pos? 230 | ">=" (complement neg?) 231 | "<" neg? 232 | "<=" (complement pos?) 233 | "=" zero? 234 | "!=" (complement zero?) 235 | "<>" (throw (ex-info "Not implemented" {})))] 236 | (pred (compare (eval2 (.getConditionField condition) schema object) 237 | (eval2 (.getLiteral condition) schema object))))) 238 | org.mule.tools.soql.query.condition.SetBasedCondition 239 | (allows? [condition schema object] 240 | (let [values (into #{} 241 | (map (fn [literal] 242 | (eval2 literal schema object))) 243 | (.getValues (.getSet condition))) 244 | value (eval2 (.getConditionField condition) schema object) 245 | set-contains-value? (contains? values value)] 246 | (case (.toString (.getOperator condition)) 247 | "IN" 248 | set-contains-value? 249 | "NOT IN" 250 | (not set-contains-value?)))) 251 | nil 252 | (allows? [_ _ _] 253 | true)) 254 | 255 | (defn parse-soql 256 | [soql] 257 | (let [form (SOQLParserHelper/createSOQLData soql)] 258 | ;; TODO could validate the other bits of the soql query are missing 259 | {:select (for [spec (.getSelectSpecs form)] 260 | (conj (into [] (.getObjectPrefixNames spec)) (.getFieldName spec))) 261 | :from (.toString (.getMainObjectSpec (.getFromClause form))) 262 | :where (some-> (.getWhereClause form) (.getCondition))})) 263 | 264 | (defn project 265 | [state type object path] 266 | (let [{:keys [schema objects]} state 267 | type-schema (type-schema schema type) 268 | fields (->> (:fields type-schema) 269 | (map (fn [field] 270 | (let [k (if (= "reference" (:type field)) 271 | (:relationshipName field) 272 | (:name field))] 273 | [k field]))) 274 | (into {})) 275 | path-field (get fields (first path))] 276 | (when-not path-field 277 | (throw (ex-info "invalid path" {:type type :path path}))) 278 | (if (= "reference" (:type path-field)) 279 | (do 280 | (when-not (next path) 281 | (throw (ex-info "invalid path" {:type type :path path}))) 282 | (if-let [id (get object (:name path-field))] 283 | (let [type' (first (:referenceTo path-field)) 284 | object' (get-in state [:objects type' id])] 285 | {(keyword (first path)) 286 | (project state (first (:referenceTo path-field)) object' (next path))}) 287 | {})) 288 | (do 289 | (when (next path) 290 | (throw (ex-info "invalid path" {:type type :path path}))) 291 | (-> object 292 | (select-keys [(first path)]) 293 | walk/keywordize-keys 294 | (assoc :attributes {:type type})))))) 295 | 296 | (defn deep-merge 297 | [& maps] 298 | (if (every? map? maps) 299 | (apply merge-with deep-merge maps) 300 | (last maps))) 301 | 302 | (defn query 303 | [state soql] 304 | (let [{:keys [schema objects]} state 305 | {:keys [select from where]} (parse-soql soql)] 306 | (->> (vals (get objects from)) 307 | (map (fn [object] 308 | (reduce deep-merge {} 309 | (map (partial project state from object) select)))) 310 | (filter (partial allows? where schema)) 311 | (into [])))) 312 | 313 | (defn query! 314 | [astate soql] 315 | (query @astate soql)) 316 | 317 | (defn count! 318 | [astate soql] 319 | (count (query! astate soql))) 320 | 321 | (defn limits! 322 | [astate] 323 | {}) 324 | 325 | (defn take-action! 326 | [astate take-action-map action inputs] 327 | (if-let [f (get take-action-map action)] 328 | (f astate inputs) 329 | (throw (ex-info "action failed" {:cause (str action " not implemented") 330 | :take-action-map take-action-map})))) 331 | -------------------------------------------------------------------------------- /src/sails_forth/query.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.query 2 | "Provides for executing queries using more idiomatic clojure forms" 3 | (:require [clojure.spec.alpha :as s] 4 | [clojure.string :as string] 5 | [sails-forth.client :as sf] 6 | [sails-forth.clojurify :as sc] 7 | [sails-forth.spec :as spec])) 8 | 9 | (defprotocol SoqlValue 10 | (soql-value [_])) 11 | 12 | (defn soql-string-escape 13 | [s] 14 | (-> s 15 | (string/replace #"\\" (string/re-quote-replacement "\\\\")) 16 | (string/replace #"\n" (string/re-quote-replacement "\\n")) 17 | (string/replace #"'" (string/re-quote-replacement "\\'")))) 18 | 19 | ;; TODO figure out how to annotate these impls 20 | (extend-protocol SoqlValue 21 | String 22 | (soql-value [s] 23 | (str "'" (soql-string-escape s) "'")) 24 | clojure.lang.IPersistentSet 25 | (soql-value [xs] 26 | (str "(" (string/join "," (map soql-value xs)) ")")) 27 | clojure.lang.IPersistentVector 28 | (soql-value [fields] 29 | (loop [refs [] 30 | fields fields] 31 | (if-not (seq fields) 32 | (string/join "." refs) 33 | (let [field (first fields) 34 | fields' (next fields)] 35 | (when (and (not fields') 36 | (= "reference" (:type field))) 37 | (throw (ex-info "Invalid field path" 38 | {:field-path fields}))) 39 | (let [ref (if (= "reference" (:type field)) 40 | (:relationshipName field) 41 | (:name field))] 42 | (recur (conj refs ref) 43 | fields')))))) 44 | org.joda.time.DateTime 45 | (soql-value [dt] 46 | (.toString dt)) 47 | org.joda.time.LocalDate 48 | (soql-value [ld] 49 | (.toString ld)) 50 | nil 51 | (soql-value [_] "null")) 52 | 53 | (s/def ::where-operator 54 | #{:in := :or :> :< :>= :<=}) 55 | 56 | (s/def ::where-value 57 | (s/or :string string? 58 | :set (s/coll-of string? :kind set?) 59 | :fields (s/coll-of (s/or :simple-field ::spec/field 60 | :field-description ::spec/field-description) 61 | :kind vector?))) 62 | 63 | ;; use tuple over cat so `exercise` will generate vectors. 64 | (s/def ::where-clause 65 | (s/tuple #_:operator ::where-operator 66 | #_:lhs ::where-value 67 | #_:rhs ::where-value)) 68 | 69 | (declare soql-where*) 70 | 71 | (s/fdef soql-where 72 | :args (s/cat :clause ::where-clause) 73 | :ret string?) 74 | 75 | (defn ^:spark/no-boot-spec-coverage soql-where 76 | [[op & args]] 77 | ;; TODO the type of op is significant 78 | (case op 79 | :or (str "(" (string/join ") OR (" (map soql-where* args)) ")") 80 | (let [[lh rh] args] 81 | (str (soql-value lh) " " (name op) " " (soql-value rh))))) 82 | 83 | (s/fdef soql-where* 84 | :args (s/cat :clauses (s/coll-of ::where-clause)) 85 | :ret string?) 86 | 87 | (defn ^:spark/no-boot-spec-coverage soql-where* 88 | [where*] 89 | (string/join " AND " (map soql-where where*))) 90 | 91 | (s/fdef soql-query 92 | :args (s/cat :client ::sf/client 93 | :type ::sc/attr 94 | :field-paths (s/coll-of ::sc/field-path :min-count 1) 95 | :where (s/coll-of ::where-clause)) 96 | :ret string?) 97 | 98 | (defn soql-query 99 | "Creates a soql query string for the given client, type, and seq of field 100 | paths" 101 | [client type field-paths where] 102 | (let [description (sf/get-type-description client type) 103 | soql-fields (map soql-value field-paths)] 104 | (str "SELECT " (string/join "," soql-fields) 105 | " FROM " (:name description) 106 | (when (seq where) 107 | (str " WHERE " (soql-where* where)))))) 108 | 109 | (defn update-attr-path 110 | [client where] 111 | (mapv (fn [[op & args :as clause]] 112 | (case op 113 | :or `[:or ~@(map (partial update-attr-path client) args)] 114 | (update clause 1 (fn [path] (sf/resolve-attr-path client (nth path 0) (subvec path 1)))))) 115 | where)) 116 | 117 | (s/fdef query-attr-paths 118 | :args (s/cat :client ::sf/client 119 | :type ::sc/attr 120 | :attr-paths (s/coll-of ::sc/attr-path :min-count 1) 121 | :where (s/nilable (s/coll-of ::where-clause))) 122 | :ret (s/coll-of map? :kind vector?)) 123 | 124 | (defn query-attr-paths 125 | "Queries the given client and type for the given seq of attr-paths, e.g. 126 | [[:account :name] [:account :createdby :lastname]]. This returns a vector 127 | of maps with keyword paths matching each of the attr-paths, e.g. 128 | {:account {:name ... :createdby {:lastname ...}}}. The base type will also 129 | have metadata with a :url resolvable by the current client." 130 | [client type attr-paths where] 131 | (let [field-paths (mapv (partial sf/resolve-attr-path client type) attr-paths) 132 | where (update-attr-path client where) 133 | soql (soql-query client type field-paths where) 134 | records (sf/query! client soql)] 135 | (mapv (fn [record] 136 | (let [url (get-in record [:attributes :url])] 137 | (with-meta 138 | (reduce (fn [record' [field-path attr-path]] 139 | (let [record-path (sf/resolve-field-path field-path) 140 | value (get-in record record-path) 141 | field (last field-path)] 142 | (cond-> record' 143 | value 144 | (assoc-in attr-path (sc/default-coerce-from-salesforce field value))))) 145 | {} 146 | (map vector field-paths attr-paths)) 147 | {:url url}))) 148 | records))) 149 | 150 | (s/def ::variant 151 | (s/coll-of (s/or :attr ::sc/attr :variant ::variant) 152 | :kind vector? :min-count 1)) 153 | 154 | (s/fdef expand-variants 155 | :args (s/cat :variant ::variant) 156 | :ret (s/coll-of ::sc/attr-path :kind vector?)) 157 | 158 | (defn expand-variants 159 | "Expands a variant path into a seq of attr paths" 160 | [variant-path] 161 | (let [[type & refs] variant-path] 162 | (reduce (fn [accum ref] 163 | (if (sequential? ref) 164 | (into accum (mapv (partial into [type]) (expand-variants ref))) 165 | (conj accum [type ref]))) 166 | [] 167 | refs))) 168 | 169 | (s/def ::find 170 | ::variant) 171 | 172 | (s/def ::where 173 | (s/coll-of ::where-clause :kind vector?)) 174 | 175 | (s/def ::query 176 | (s/keys :req-un [::find] 177 | :opt-un [::where])) 178 | 179 | (s/fdef query 180 | :args (s/cat :client ::sf/client 181 | :query ::query) 182 | :ret (s/coll-of map? :kind vector?) 183 | ;; TODO :fn specify structure of maps from query find 184 | ) 185 | 186 | (defn query 187 | "Returns the results of the given query against the given client. The query is 188 | a map with a :find keyword whose value must be a vector of keywords and 189 | vectors; the first position of any vector is taken to be a reference type. 190 | For example, [:account :name [:createdby :lastname]]. The result will be a 191 | vector of maps whose structures are given by the find clause, e.g. 192 | {:account {:name ... :createdby {:lastname ...}}}" 193 | [client query] 194 | (let [attr-paths (expand-variants (:find query))] 195 | (when (seq attr-paths) 196 | (let [type (ffirst attr-paths)] 197 | (mapv (fn [record] 198 | {type record}) 199 | (query-attr-paths client type (map #(subvec % 1) attr-paths) (:where query))))))) 200 | 201 | (s/fdef record-types 202 | :args (s/cat :client ::sf/client) 203 | :ret (s/map-of ::spec/id string?)) 204 | 205 | (defn ^:spark/no-boot-spec-coverage record-types 206 | [client] 207 | (let [cache (sf/cache client)] 208 | (or (sf/get! cache ::record-types) 209 | (let [rows (query client {:find [:recordtype :id :name]}) 210 | idx (->> (map :recordtype rows) 211 | (map (juxt :name :id)) 212 | (into {}))] 213 | (sf/put! cache ::record-types idx) 214 | idx)))) 215 | 216 | (s/fdef record-type-id 217 | :args (s/cat :client ::sf/client 218 | :name ::spec/type) 219 | :ret ::spec/id) 220 | 221 | (defn ^:spark/no-boot-spec-coverage record-type-id 222 | [client record-type-name] 223 | (get (record-types client) record-type-name)) 224 | -------------------------------------------------------------------------------- /src/sails_forth/repl.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.repl 2 | (:require [clojure.edn :as edn] 3 | [clojure.spec.alpha :as s] 4 | [sails-forth.client :as sf] 5 | [sails-forth.clojurify :as sc] 6 | [sails-forth.query :as sq] 7 | [sails-forth.spec :as spec] 8 | [sails-forth.update :as su])) 9 | 10 | (defn build-client! 11 | [path] 12 | (-> path slurp edn/read-string sf/build-http-client)) 13 | -------------------------------------------------------------------------------- /src/sails_forth/spec.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.spec 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::id 5 | string?) 6 | 7 | (s/def ::type 8 | string?) 9 | 10 | (s/def ::field 11 | (s/or :keyword keyword? 12 | :string string?)) 13 | 14 | (s/def ::attrs 15 | (s/map-of ::field ::json-simple)) 16 | 17 | (s/def ::query 18 | string?) 19 | 20 | (s/def ::entity 21 | (s/keys :req-un [::id])) 22 | 23 | (s/def ::json-simple 24 | (s/or :string string? 25 | ;; some internal unit tests fail with bigdec? 26 | :number number? ;bigdec? 27 | :nil nil? 28 | :boolean boolean?)) 29 | 30 | (s/def ::json 31 | (s/or :simple ::json-simple 32 | :vector (s/coll-of ::json :kind vector?) 33 | :map (s/map-of keyword? ::json))) 34 | 35 | (s/def ::json-map 36 | (s/map-of keyword? ::json)) 37 | 38 | (s/def ::referenceTo 39 | (s/coll-of ::type :kind vector?)) 40 | 41 | (s/def ::scale 42 | nat-int?) 43 | 44 | (s/def ::precision 45 | nat-int?) 46 | 47 | (s/def ::label 48 | string?) 49 | 50 | (s/def ::name 51 | string?) 52 | 53 | (s/def ::relationshipName 54 | any?) 55 | 56 | (s/def ::value 57 | string?) 58 | 59 | (s/def ::active 60 | boolean?) 61 | 62 | ;; other possible keys: 63 | ;; :validFor 64 | ;; :defaultValue 65 | (s/def ::picklistValue 66 | (s/keys :req-un [ 67 | ; label can be nil in a picklistValue, is that correct? Awkward to express. 68 | ; ::label 69 | 70 | ::value 71 | ::active])) 72 | 73 | (s/def ::picklistValues 74 | (s/coll-of ::picklistValue 75 | :kind vector?)) 76 | 77 | (defmulti field-description-type :type) 78 | (defmethod field-description-type "datetime" [_] (s/keys :req-un [::type])) 79 | (defmethod field-description-type "date" [_] (s/keys :req-un [::type])) 80 | (defmethod field-description-type "int" [_] (s/keys :req-un [::type])) 81 | (defmethod field-description-type "percent" [_] (s/keys :req-un [::type ::scale ::precision])) 82 | (defmethod field-description-type "double" [_] (s/keys :req-un [::type ::scale ::precision])) 83 | (defmethod field-description-type "currency" [_] 84 | (s/keys :req-un [::type 85 | ::picklistValues 86 | ::name 87 | ::referenceTo 88 | ::scale 89 | ::precision 90 | ::label 91 | ::relationshipName])) 92 | (defmethod field-description-type "id" [_] 93 | (s/keys :req-un [::type 94 | ::picklistValues 95 | ::name 96 | ::referenceTo 97 | ::scale 98 | ::precision 99 | ::label 100 | ::relationshipName])) 101 | (defmethod field-description-type "string" [_] 102 | (s/keys :req-un [::type 103 | ::picklistValues 104 | ::name 105 | ::referenceTo 106 | ::scale 107 | ::precision 108 | ::label 109 | ::relationshipName])) 110 | (defmethod field-description-type "reference" [_] 111 | (s/keys :req-un [::type 112 | ::picklistValues 113 | ::name 114 | ::referenceTo 115 | ::scale 116 | ::precision 117 | ::label 118 | ::relationshipName])) 119 | (defmethod field-description-type "boolean" [_] 120 | (s/keys :req-un [::type 121 | ::picklistValues 122 | ::name 123 | ::referenceTo 124 | ::scale 125 | ::precision 126 | ::label 127 | ::relationshipName])) 128 | (defmethod field-description-type "textarea" [_] 129 | (s/keys :req-un [::type 130 | ::picklistValues 131 | ::name 132 | ::referenceTo 133 | ::scale 134 | ::precision 135 | ::label 136 | ::relationshipName])) 137 | (defmethod field-description-type "picklist" [_] 138 | (s/keys :req-un [::type 139 | ::picklistValues 140 | ::name 141 | ::referenceTo 142 | ::scale 143 | ::precision 144 | ::label 145 | ::relationshipName])) 146 | (defmethod field-description-type "url" [_] 147 | (s/keys :req-un [::type 148 | ::picklistValues 149 | ::name 150 | ::referenceTo 151 | ::scale 152 | ::precision 153 | ::label 154 | ::relationshipName])) 155 | (defmethod field-description-type "multipicklist" [_] 156 | (s/keys :req-un [::type 157 | ::picklistValues 158 | ::name 159 | ::referenceTo 160 | ::scale 161 | ::precision 162 | ::label 163 | ::relationshipName])) 164 | (defmethod field-description-type "address" [_] 165 | (s/keys :req-un [::type 166 | ::picklistValues 167 | ::name 168 | ::referenceTo 169 | ::scale 170 | ::precision 171 | ::label 172 | ::relationshipName])) 173 | (defmethod field-description-type "phone" [_] 174 | (s/keys :req-un [::type 175 | ::picklistValues 176 | ::name 177 | ::referenceTo 178 | ::scale 179 | ::precision 180 | ::label 181 | ::relationshipName])) 182 | (defmethod field-description-type "email" [_] 183 | (s/keys :req-un [::type 184 | ::picklistValues 185 | ::name 186 | ::referenceTo 187 | ::scale 188 | ::precision 189 | ::label 190 | ::relationshipName])) 191 | (defmethod field-description-type "encryptedstring" [_] 192 | (s/keys :req-un [::type 193 | ::picklistValues 194 | ::name 195 | ::referenceTo 196 | ::scale 197 | ::precision 198 | ::label 199 | ::relationshipName])) 200 | 201 | (s/def ::field-description 202 | (s/multi-spec field-description-type :type)) 203 | 204 | (s/def ::custom 205 | boolean?) 206 | 207 | (s/def ::fields 208 | (s/coll-of ::field-description :kind vector?)) 209 | 210 | (s/def ::object-description 211 | (s/keys ::req-un [::name 212 | ::label 213 | ::custom 214 | ::fields])) 215 | 216 | (s/def ::object-overview 217 | (s/keys :req-un [::name 218 | ::label 219 | ::custom])) 220 | 221 | (s/def ::sobjects 222 | (s/coll-of ::object-overview :kind vector?)) 223 | 224 | (s/def ::objects-overview 225 | (s/keys :req-un [::sobjects])) 226 | 227 | (s/def ::done 228 | boolean?) 229 | 230 | (s/def ::totalSize 231 | nat-int?) 232 | 233 | (s/def ::records 234 | (s/coll-of ::json-map :kind vector?)) 235 | 236 | (s/def ::nextRecordsUrl 237 | string?) 238 | 239 | (s/def ::query-results 240 | (s/and (s/keys :req-un [::done 241 | ::totalSize 242 | ::records] 243 | :opt-un [::nextRecordsUrl]) 244 | #(if (:done %) 245 | (not (:nextRecordsUrl %)) 246 | (:nextRecordsUrl %)))) 247 | 248 | (s/def ::count-query-results 249 | (s/and (s/keys :req-un [::done 250 | ::totalSize 251 | ::records]) 252 | #(:done %) 253 | #(every? empty (:records %)))) 254 | 255 | (s/def ::Max 256 | nat-int?) 257 | 258 | (s/def ::Remaining 259 | nat-int?) 260 | 261 | (s/def ::limit 262 | (s/keys :req-un [::Max 263 | ::Remaining])) 264 | 265 | (s/def ::limits 266 | (s/map-of keyword? ::limit)) 267 | -------------------------------------------------------------------------------- /src/sails_forth/update.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.update 2 | "Execute SalesForce updates using more idiomatic Clojure syntax." 3 | (:require [clojure.spec.alpha :as s] 4 | [sails-forth.client :as sf] 5 | [sails-forth.clojurify :as sc] 6 | [sails-forth.spec :as spec])) 7 | 8 | (defn- get-sf-type-name 9 | [client type] 10 | (let [sf-type (sf/get-type-description client type) 11 | sf-type-name (:name sf-type)] 12 | (when-not sf-type-name 13 | (throw (ex-info (str "no SalesForce type for " type) 14 | {:description sf-type}))) 15 | sf-type-name)) 16 | 17 | (defn sf-attrs 18 | "Converts the given record to a map of salesforce attrs for the given object 19 | type" 20 | [client type record] 21 | (->> (for [[k v] record 22 | :let [desc (sf/get-field-description client type k) 23 | sf-k (:name desc)]] 24 | (do (when-not desc 25 | (throw (ex-info (str "no SalesForce attribute for " k) 26 | {:description desc}))) 27 | [sf-k (sc/default-coerce-to-salesforce desc v)])) 28 | (into {}))) 29 | 30 | (s/fdef update! 31 | :args (s/cat :client ::sf/client 32 | :type ::sc/attr 33 | :object-id ::spec/id 34 | :values (s/map-of ::sc/attr ::sc/value)) 35 | :ret #{true}) 36 | 37 | (defn ^:spark/no-boot-spec-coverage update! 38 | "Performs updates on the object `object-id` with the given `type`." 39 | [client type object-id new-value-map] 40 | (let [sf-type (get-sf-type-name client type) 41 | sf-value-map (sf-attrs client type new-value-map)] 42 | (sf/update! client sf-type object-id sf-value-map))) 43 | 44 | (s/fdef create! 45 | :args (s/cat :client ::sf/client 46 | :type ::sc/attr 47 | :values (s/map-of ::sc/attr ::sc/value)) 48 | :ret ::spec/id) 49 | 50 | (defn ^:spark/no-boot-spec-coverage create! 51 | [client type new-value-map] 52 | (let [sf-type (get-sf-type-name client type) 53 | sf-value-map (sf-attrs client type new-value-map)] 54 | (sf/create! client sf-type sf-value-map))) 55 | 56 | (s/fdef import! 57 | :args (s/cat :client ::sf/client 58 | :type ::sc/attr 59 | :records (s/coll-of (s/map-of ::sc/attr ::sc/value))) 60 | :ret (s/and (partial instance? clojure.lang.IDeref) 61 | (comp (partial s/valid? (s/coll-of any?)) deref)) 62 | :fn (fn [{:keys [args ret]}] 63 | (= (count (:records args)) (count @ret)))) 64 | 65 | (defn ^:spark/no-boot-spec-coverage import! 66 | [client type records] 67 | (let [sf-type (get-sf-type-name client type) 68 | sf-value-maps (mapv (partial sf-attrs client type) records)] 69 | (sf/import! client sf-type sf-value-maps))) 70 | 71 | (defn delete! 72 | [client type id] 73 | (let [sf-type (get-sf-type-name client type)] 74 | (sf/delete! client sf-type id))) 75 | -------------------------------------------------------------------------------- /test/sails_forth/clojurify_test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.clojurify-test 2 | (:require [clj-time.core :as time] 3 | [clojure.test :refer :all] 4 | [clojure.spec.test.alpha :as stest] 5 | [sails-forth.client :as sf] 6 | [sails-forth.clojurify :refer :all] 7 | [sails-forth.test :as test])) 8 | 9 | ;;; NOTE these tests are not guaranteed to work on arbitrary salesforce dbs 10 | 11 | (deftest test-parse-value 12 | (testing "datetime" 13 | (is (= (time/date-time 2015 1 1 12 30 15 500) 14 | (parse-value {:type "datetime"} 15 | "2015-01-01T12:30:15.500Z")))) 16 | (testing "date" 17 | (is (= (time/local-date 2015 2 1) 18 | (parse-value {:type "date"} 19 | "2015-02-01")))) 20 | (testing "double" 21 | (is (= 500 (parse-value {:type "double" 22 | :scale 0 23 | :precision 18} 24 | 500M))) 25 | (is (= 500N (parse-value {:type "double" 26 | :scale 0 27 | :precision 19} 28 | 500M))) 29 | (is (= 500M (parse-value {:type "double" 30 | :scale 2 31 | :precision 18} 32 | 500M)))) 33 | (testing "percent" 34 | (is (= 0.0618M (parse-value {:type "percent" 35 | :scale 2 36 | :precision 4} 37 | 6.18M)))) 38 | (testing "int" 39 | (is (= 500 (parse-value {:type "int"} 500M))))) 40 | 41 | (deftest test-render-value 42 | (testing "datetime" 43 | (is (= (render-value {:type "datetime"} 44 | (time/date-time 2015 1 1 12 30 15 500)) 45 | "2015-01-01T12:30:15.500Z")) 46 | (is (= (render-value {:type "datetime"} 47 | "2015-01-01T12:30:15.500Z") 48 | "2015-01-01T12:30:15.500Z"))) 49 | (testing "date" 50 | (is (= (render-value {:type "date"} 51 | (time/local-date 2015 2 1)) 52 | "2015-02-01"))) 53 | (testing "percent" 54 | (is (= (render-value {:type "percent" 55 | :scale 2 56 | :precision 4} 57 | 0.0618M) 58 | 6.18M)))) 59 | 60 | (deftest ^:integration test-get-field-description 61 | (let [client (sf/build-http-client (test/load-config))] 62 | (is (sf/get-field-description client :opportunity :id)) 63 | (is (sf/get-field-description client :payment :opportunity)))) 64 | 65 | (deftest ^:integration test-resolve-attr-path 66 | (let [client (sf/build-http-client (test/load-config))] 67 | (is (sf/resolve-attr-path client :opportunity [:id])) 68 | (is (sf/resolve-attr-path client :opportunity [:counterparty-account :id])) 69 | (is (sf/resolve-attr-path client :opportunity [:counterparty-account :recordtype :name])) 70 | (is (sf/resolve-attr-path client :payment [:opportunity :id])) 71 | (is (sf/resolve-attr-path client :payment [:project-schedule :id])))) 72 | -------------------------------------------------------------------------------- /test/sails_forth/datomic_test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.datomic-test 2 | (:require [clojure.edn :as edn] 3 | [clojure.test :refer :all] 4 | [sails-forth.client :as sf] 5 | [sails-forth.datomic :refer :all])) 6 | 7 | (deftest test-assert-query 8 | (let [schema (edn/read-string (slurp "test/schema.edn")) 9 | client (sf/build-memory-client schema) 10 | user-id (sf/create! client "User" {"Name" "Donald"}) 11 | _ (sf/create! client "Payment__c" {"Amount__c" 5 12 | "CreatedById" user-id}) 13 | txns (assert-query client "sf" {:find [:payment :id 14 | [:createdby :id :name]]})] 15 | (is (= 3 (count txns))) 16 | (is (= "Donald" (get-in txns [2 0 17 | :sf.object.payment/createdby 18 | :sf.object.user/name]))))) 19 | -------------------------------------------------------------------------------- /test/sails_forth/http_test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.http-test 2 | (:require [clojure.test :refer :all] 3 | [sails-forth.http :refer :all] 4 | [sails-forth.test :as test])) 5 | 6 | (deftest ^:integration test-client 7 | (let [config (test/load-config)] 8 | (when-not config 9 | (throw (Exception. "Salesforce tests require a config"))) 10 | (when-not (:sandbox? config) 11 | (throw (Exception. "Salesforce tests may only be run in a sandbox"))) 12 | (testing "with valid credentials" 13 | (let [client (build-client! config)] 14 | (testing "can issue requests" 15 | (let [{:keys [status body]} (request! client :get :data "/limits" {})] 16 | (is (= 200 status)) 17 | (is (map? body)))) 18 | (testing "derefs to report its state" 19 | (let [state @client] 20 | (is (= 3 (:requests state))) 21 | (is (:authentication state)))) 22 | (testing "caches authentication and version" 23 | (is (= 200 (:status (request! client :get :data "/limits" {})))) 24 | (is (= 4 (:requests @client)))))) 25 | (testing "with invalid credentials" 26 | (let [config (update config :token str "x") 27 | client (build-client! config)] 28 | (testing "cannot issue requests" 29 | (is (nil? (request! client :get :data "/limits" {})))) 30 | (testing "is not authenticated" 31 | (let [state @client] 32 | (is (not (:authentication state))) 33 | (is (= 1 (:requests state))))) 34 | (testing "attempts to authenticate again" 35 | (request! client :get :data "/limits" {}) 36 | (is (= 2 (:requests @client)))))) 37 | (testing "with a read-only client" 38 | (let [config (assoc config :read-only? true) 39 | client (build-client! config)] 40 | (testing "cannot issue side effecting requests" 41 | (doseq [method [:post :put :patch :delete]] 42 | (is (thrown? Exception (request! client method :data "/anything" {}))))))))) 43 | -------------------------------------------------------------------------------- /test/sails_forth/memory2_test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.memory2-test 2 | (:require [clojure.edn :as edn] 3 | [clojure.test :refer :all] 4 | [clojure.spec.test.alpha :as stest] 5 | [sails-forth.client :as sf] 6 | [sails-forth.memory2 :refer :all] 7 | [sails-forth.query :as sq]) 8 | (:refer-clojure :exclude [update list])) 9 | 10 | (deftest test-memory-client 11 | (let [schema (edn/read-string (slurp "test/schema.edn")) 12 | client (sf/build-memory-client schema {"custom/apex/TestEndpoint" (fn [c inputs] inputs)})] 13 | (testing "schema" 14 | (is (= #{"Payment__c" "User"} 15 | (set (map :name (:sobjects (sf/objects! client)))))) 16 | (is (every? (set (map :name (:fields (sf/describe! client "Payment__c")))) 17 | #{"Id" "Amount__c"}))) 18 | (testing "crud" 19 | (let [id (sf/create! client "Payment__c" {"Amount__c" 5})] 20 | (is id) 21 | (is (= [{"Id" id "Amount__c" 5}] (sf/list! client "Payment__c"))) 22 | (is (sf/update! client "Payment__c" id {"Amount__c" 10})) 23 | (is (= [{"Id" id "Amount__c" 10}] (sf/list! client "Payment__c"))) 24 | (is (sf/delete! client "Payment__c" id)) 25 | (is (not (seq (sf/list! client "Payment__c")))))) 26 | (testing "queries" 27 | (let [user-id (sf/create! client "User" {"Name" "Donald"})] 28 | (sf/create! client "Payment__c" {"Amount__c" 5 29 | "CreatedById" user-id})) 30 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"}}] 31 | (sf/query! client (str "select Amount__c from Payment__c")))) 32 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"}}] 33 | (sf/query! client (str "select Amount__c from Payment__c " 34 | "where Amount__c = 5")))) 35 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"}}] 36 | (sf/query! client (str "select Amount__c from Payment__c " 37 | "where Amount__c >= 5")))) 38 | (is (= [] 39 | (sf/query! client (str "select Amount__c from Payment__c " 40 | "where Amount__c >= 6")))) 41 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"}}] 42 | (sf/query! client (str "select Amount__c from Payment__c " 43 | "where not (Amount__c = 6)")))) 44 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"}}] 45 | (sf/query! client (str "select Amount__c from Payment__c " 46 | "where (Amount__c = 5)")))) 47 | (is (not (seq (sf/query! client (str "select Amount__c from Payment__c " 48 | "where Amount__c = 0"))))) 49 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"} 50 | :CreatedBy {:Name "Donald" :attributes {:type "User"}}}] 51 | (sf/query! client (str "select Amount__c,CreatedBy.Name from Payment__c")))) 52 | (is (= 1 (sf/count! client "select Amount__c from Payment__c"))) 53 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"} 54 | :CreatedBy {:Name "Donald" :attributes {:type "User"}}}] 55 | (sf/query! client (str "select Amount__c, CreatedBy.Name from Payment__c " 56 | "where CreatedBy.Name = 'Donald'")))) 57 | (is (= [{:Amount__c 5 :attributes {:type "Payment__c"} 58 | :CreatedBy {:Name "Donald" :attributes {:type "User"}}}] 59 | (sf/query! client (str "select Amount__c, CreatedBy.Name from Payment__c " 60 | "where CreatedBy.Name IN ('Donald', 'Claire')")))) 61 | (is (= [] 62 | (sf/query! client (str "select Amount__c, CreatedBy.Name from Payment__c " 63 | "where CreatedBy.Name NOT IN ('Donald', 'Claire')"))))) 64 | (testing "dates" 65 | (sf/create! client "Payment__c" {"Actual_Date__c" "2016-01-01"}) 66 | (is (= (sq/query client {:find [:payment :actual-date]}) 67 | [{:payment {}} 68 | {:payment {:actual-date (org.joda.time.LocalDate. "2016-01-01")}}])) 69 | (is (= [{:Actual_Date__c "2016-01-01" 70 | :attributes {:type "Payment__c"}}] 71 | (sf/query! client (str "select Amount__c, Actual_Date__c from Payment__c " 72 | "where Actual_Date__c = 2016-01-01")))) 73 | (is (not (seq 74 | (sf/query! client (str "select Amount__c, Actual_Date__c from Payment__c " 75 | "where Actual_Date__c = 2016-01-02")))))) 76 | (testing "limits" 77 | (is (= {} (sf/limits! client)))) 78 | (testing "take-action!" 79 | (is (= "some input" (sf/take-action! client "custom/apex/TestEndpoint" "some input"))) 80 | (is (thrown? clojure.lang.ExceptionInfo (sf/take-action! client "custom/apex/BadEndpoint" "other input")))))) 81 | -------------------------------------------------------------------------------- /test/sails_forth/query_test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.query-test 2 | (:require [clj-time.core :as time] 3 | [clojure.spec.test.alpha :as stest] 4 | [clojure.test :refer :all] 5 | [sails-forth.client :as sf] 6 | [sails-forth.clojurify :refer :all] 7 | [sails-forth.query :refer :all] 8 | [sails-forth.test :as test])) 9 | 10 | (deftest test-soql-value 11 | (is (= "''" (soql-value ""))) 12 | (is (= "'test'" (soql-value "test"))) 13 | (is (= "'escape\\\\backslashes'" (soql-value "escape\\backslashes"))) 14 | (is (= "'escape\\nnewlines'" (soql-value "escape\nnewlines"))) 15 | (is (= "'escape\\'quotes'" (soql-value "escape'quotes")))) 16 | 17 | ;;; NOTE these tests are not guaranteed to work on arbitrary salesforce dbs 18 | 19 | (deftest ^:integration test-soql-query 20 | (let [client (sf/build-http-client (test/load-config))] 21 | (is (= "SELECT Id FROM Opportunity" 22 | (soql-query client :opportunity [(sf/resolve-attr-path client :opportunity [:id])] []))))) 23 | 24 | (deftest ^:integration test-query 25 | (let [client (sf/build-http-client (test/load-config))] 26 | (is (query client {:find [:payment 27 | :id 28 | [:opportunity :id] 29 | [:project-schedule :id]]})) 30 | (is (query client {:find [:payment 31 | :id 32 | [:opportunity :name]] 33 | :where [[:= [:payment :opportunity :name] "Test Opportunity"]]})))) 34 | -------------------------------------------------------------------------------- /test/sails_forth/test.clj: -------------------------------------------------------------------------------- 1 | (ns sails-forth.test 2 | (:require [clojure.edn :as edn])) 3 | 4 | (defn load-config 5 | [] 6 | (some-> (try (slurp "test/config.edn") (catch Exception _)) 7 | edn/read-string)) 8 | --------------------------------------------------------------------------------