├── .gitignore ├── LICENSE ├── README.md ├── deps.edn ├── pom.xml ├── release.sh ├── src └── com │ └── github │ └── ivarref │ ├── clj_paginate.clj │ └── clj_paginate │ └── impl │ ├── bst2.clj │ ├── bst_before.clj │ ├── pag_first_map.clj │ ├── pag_last_map.clj │ └── utils.clj ├── test └── com │ └── github │ └── ivarref │ └── clj_paginate │ ├── auto_reset_test.clj │ ├── batch_test.clj │ ├── edge_test.clj │ ├── global_sort_test.clj │ ├── inclusive_test.clj │ ├── multi_sort_test.clj │ ├── or_filter_test.clj │ ├── paginate_test.clj │ ├── perf2_test.clj │ ├── perf3_test.clj │ ├── perf_test.clj │ ├── readme.clj │ ├── reverse_test.clj │ ├── stacktrace.clj │ └── ticker.clj └── tests.edn /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache/ 2 | target/ 3 | .idea/ 4 | *.iml 5 | .nrepl-port 6 | .clj-kondo/ 7 | *.pom.asc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clj-paginate 2 | 3 | A Clojure (JVM only) implementation of the 4 | [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) 5 | with vector or map as the backing data. 6 | 7 | Supports: 8 | * Collections that grows and/or changes. 9 | * Long polling (`:first` only, not `:last`). 10 | * Multiple sort criteria. 11 | * Ascending or descending sorting. 12 | * Basic OR filtering (maps only). 13 | * Batching (optional). 14 | 15 | No external dependencies. 16 | 17 | ## Prerequisites 18 | 19 | The user of this library is assumed to be moderately 20 | familiar with [GraphQL pagination](https://graphql.org/learn/pagination/) 21 | and know the basic structure of the 22 | [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm), 23 | particularly the fact that the desired response looks like the following: 24 | 25 | ``` 26 | {"edges": [{"node": ..., "cursor": ...}, 27 | {"node": ..., "cursor": ...}, 28 | {"node": ..., "cursor": ...}, 29 | ...] 30 | "pageInfo": {"hasNextPage": Boolean 31 | "hasPrevPage": Boolean 32 | "totalCount": Integer 33 | "startCursor": String 34 | "endCursor": String}} 35 | ``` 36 | 37 | ## Installation 38 | 39 | [![Clojars Project](https://img.shields.io/clojars/v/com.github.ivarref/clj-paginate.svg)](https://clojars.org/com.github.ivarref/clj-paginate) 40 | 41 | ## 1-minute example 42 | 43 | ```clojure 44 | (require '[com.github.ivarref.clj-paginate :as cp]) 45 | 46 | (defn nodes [page] 47 | (->> page 48 | :edges 49 | (mapv :node))) 50 | 51 | (def data (vec (shuffle [{:inst 0} 52 | {:inst 1} 53 | {:inst 2}]))) 54 | 55 | ; Get the initial page: 56 | (def page-1 (cp/paginate 57 | data 58 | 59 | ; The next argument, `sort-attrs`, specifies how the vector should be sorted. 60 | ; It must be a single keyword, a vector of keywords or a vector of pairs (keyword and :asc/:desc). 61 | ; See more documentation below for information about ascending or descending 62 | ; sorting. 63 | :inst 64 | 65 | ; A function to transform an initial node into a final node, 66 | ; i.e. load more data from a database. 67 | identity 68 | 69 | ; What to get, the first two elements in this case: 70 | {:first 2})) 71 | ; page-1 72 | ;=> 73 | ;{:edges 74 | ; [{:node {:inst 0}, :cursor "{:context {} :cursor [0 ]}"} 75 | ; {:node {:inst 1}, :cursor "{:context {} :cursor [1 ]}"}], 76 | ; :pageInfo 77 | ; {:hasPrevPage false, 78 | ; :hasNextPage true, 79 | ; :startCursor "{:context {} :cursor [0 ]}", 80 | ; :endCursor "{:context {} :cursor [1 ]}", 81 | ; :totalCount 3}} 82 | 83 | 84 | ; Get the second page: 85 | (def page-2 (cp/paginate data 86 | :inst 87 | identity 88 | {:first 2 89 | :after (get-in page-1 [:pageInfo :endCursor])})) 90 | ; (nodes page-2) 91 | ; => [{:inst 2}] 92 | 93 | ; Get the next (empty) page: 94 | (def page-3 (cp/paginate data 95 | :inst 96 | identity 97 | {:first 2 98 | :after (get-in page-2 [:pageInfo :endCursor])})) 99 | ; (nodes page-3) 100 | ; => [] 101 | ; No more data! 102 | ; The poller, i.e. a different backend, should now sleep for some time before attempting again. 103 | 104 | 105 | ; More data has arrived: 106 | (def data [{:inst 0} ; old 107 | {:inst 1} ; old 108 | {:inst 2} ; old 109 | {:inst 3} ; new item 110 | {:inst 4} ; new item 111 | ]) 112 | 113 | ; Time for another poll. Growing data is handled: 114 | (def page-4 (cp/paginate data 115 | :inst 116 | identity 117 | {:first 2 118 | :after (get-in page-3 [:pageInfo :endCursor])})) 119 | ; (nodes page-4) 120 | ; => [{:inst 3} {:inst 4}] 121 | 122 | ; More data has arrived, and old data expired/got removed: 123 | (def data [{:inst 6} 124 | {:inst 7} 125 | {:inst 8}]) 126 | 127 | ; Changed data is handled as long as the newer data adheres to sorting 128 | (def page-5 (cp/paginate data 129 | :inst 130 | identity 131 | {:first 2 132 | :after (get-in page-4 [:pageInfo :endCursor])})) 133 | ; (nodes page-5) 134 | ; => [{:inst 6} {:inst 7}] 135 | ``` 136 | 137 | ## Background 138 | 139 | This library was developed for supporting pagination for "heavy" Datomic queries that 140 | spent too much time on delivering the initial result that would then have to be sorted and 141 | paginated. 142 | 143 | ## Data requirements 144 | 145 | Nodes must be maps. 146 | 147 | ## Basic use case example 148 | 149 | ```clojure 150 | (require '[com.github.ivarref.clj-paginate :as cp]) 151 | 152 | (def data 153 | (vec (shuffle [{:inst 0 :id 1} 154 | {:inst 1 :id 2} 155 | {:inst 2 :id 3}]))) 156 | 157 | (defn http-post-handler 158 | [response data http-body] 159 | (assoc response 160 | :status 200 161 | :body (cp/paginate 162 | ; The first argument is the data to paginate. 163 | data 164 | 165 | ; The second argument specifies how the vector is sorted. 166 | ; It thus also specifies what constitute a unique identifier for a node. 167 | ; It may be a single keyword, a vector of keywords, 168 | ; or a vector of pairs where each pair has a keyword and :asc or :desc. 169 | [:inst] 170 | 171 | ; The third argument is a function that further processes the node. 172 | ; The function may for example load more data from a database or other external storage. 173 | (fn [{:keys [inst id] :as node}] 174 | (Thread/sleep 10) ; Do some heavy work. 175 | (assoc node :value-from-db 1)) 176 | 177 | ; The fourth argument should be a map containing the arguments to the pagination. 178 | ; This map must contain either: 179 | ; :first (Integer), how many items to fetch from the start, and optionally :after, the cursor, 180 | ; or :last (Integer), how many items to fetch from the end, and optionally :before, the cursor. 181 | ; If this requirement is not satisfied, an exception will be thrown. 182 | http-body))) 183 | ``` 184 | 185 | That is all that is needed for the basic use case to work. 186 | 187 | ## Multiple sort criteria and descending values example 188 | 189 | The default behaviour of `clj-paginate` is to assume that all attributes in `:sort-attrs` is sorted ascendingly. 190 | It's possible to override this behaviour using pairs of `keyword :asc/:desc` in the `:sort-attrs` vector: 191 | 192 | ```clojure 193 | (require '[com.github.ivarref.clj-paginate :as cp]) 194 | 195 | (def data 196 | (vec (shuffle [{:inst #inst"2000" :id 1} 197 | {:inst #inst"2000" :id 2} 198 | {:inst #inst"2001" :id 3}]))) 199 | 200 | (cp/paginate 201 | ; The first argument is the data to paginate. 202 | data 203 | 204 | ; The second argument specifies how the vector should be sorted. 205 | [[:inst :asc] [:id :desc]] 206 | 207 | identity 208 | {:first 2}) 209 | 210 | (def conn *1) 211 | 212 | (mapv :node (:edges conn)) 213 | => [{:inst #inst"2000", :id 2} {:inst #inst"2000", :id 1}] 214 | 215 | (cp/paginate 216 | ; The first argument is the data to paginate. 217 | ; The data must already be sorted. 218 | data 219 | 220 | ; The second argument specifies which attributes constitute a unique identifier for a node. 221 | ; It may be a single keyword, or a vector of keywords. 222 | [[:inst :asc] [:id :desc]] 223 | 224 | identity 225 | {:first 2 :after (get-in conn [:pageInfo :endCursor])}) 226 | 227 | (mapv :node (:edges *1)) 228 | => [{:inst #inst"2001", :id 3}] 229 | ``` 230 | 231 | ## OR filters 232 | 233 | Sometimes you may want to provide filtering of the data. 234 | This is done in two steps: 235 | 236 | 1. Your HTTP endpoint must support a parameter that represents the or filter. 237 | 2. Pass a map to the `paginate` function along with `:filter` in `opts`. 238 | `:filter` should be a vector of the keys of the map that you want to filter on. 239 | 240 | As an example, let's add a `:status` property to our previous example and make it filterable: 241 | 242 | ```clojure 243 | (require '[com.github.ivarref.clj-paginate :as cp]) 244 | 245 | (def data 246 | (group-by :status 247 | [{:inst 0 :id 1 :status :init} 248 | {:inst 1 :id 2 :status :pending} 249 | {:inst 2 :id 3 :status :done} 250 | {:inst 3 :id 4 :status :error} 251 | {:inst 4 :id 5 :status :done}])) 252 | 253 | (defn http-post-handler 254 | [response data http-body] 255 | (assoc response 256 | :status 200 257 | :body (cp/paginate 258 | data ; data is now a map. 259 | :inst 260 | (fn [{:keys [inst id] :as node}] 261 | (Thread/sleep 10) ; Do some heavy work. 262 | (assoc node :value-from-db 1)) 263 | 264 | ; Assume that the HTTP endpoint accepts a parameter `:statuses` for the body, 265 | ; and that when present, this is a vector such as `[:init :pending :done :error]` or similar, 266 | ; i.e. the keys of `data` that we want to filter on. 267 | ; 268 | ; Paginate's `opts` accepts a key `:filter` that does exactly this for data maps. 269 | ; Thus we can simply rename `:statuses` to `:filter` in the http body. 270 | ; clj-paginate takes care of storing the value of `:filter` in the cursor 271 | ; for subsequent queries. 272 | (clojure.set/rename-keys http-body {:statuses :filter})))) 273 | 274 | ; To illustrate this, consider the following code: 275 | (let [conn (cp/paginate 276 | data 277 | :inst 278 | identity 279 | {:first 1 280 | :filter [:done]})] 281 | ; Will print [{:inst 2, :id 3, :status :done}]. 282 | (println (mapv :node (:edges conn))) 283 | 284 | ; Will print [{:inst 4, :id 5, :status :done}]. 285 | ; Notice here that we do not re-specify `:filter`. 286 | ; It is already stored in the cursor from the original connection. 287 | (println (mapv :node (:edges (cp/paginate 288 | data 289 | :inst 290 | identity 291 | {:first 1 292 | :after (get-in conn [:pageInfo :endCursor])}))))) 293 | ``` 294 | 295 | The consumer client only needs to send `:statuses` on the initial query. 296 | When subsequent iteration is done, the cursor, `:after` or `:before`, 297 | already includes `:filter`, and thus it is not necessary to re-send 298 | this information on every request. If `:filter` is not specified for 299 | a map, every key is included. 300 | 301 | ### Refresh a page 302 | 303 | If you want to refresh a page, you may add `:inclusive? true` as 304 | a named parameter when calling `paginate`. 305 | The results will then include the given cursor. This is useful 306 | if you want to check for updates of a given page only based on a 307 | previous pageInfo. 308 | 309 | ```clojure 310 | (require '[com.github.ivarref.clj-paginate :as cp]) 311 | 312 | ; Using :first: 313 | (cp/paginate 314 | data 315 | [:sort-attrs] 316 | identity 317 | {:first 10 :after (:startCursor (:pageInfo connection))} 318 | :inclusive? true) 319 | 320 | ; Using :last: 321 | (cp/paginate 322 | data 323 | [:sort-attrs] 324 | identity 325 | {:last 10 :before (:endCursor (:pageInfo connection))} 326 | :inclusive? true) 327 | ``` 328 | 329 | ### Batching 330 | 331 | Batching is supported. Add `:batch? true` when calling `paginate`. 332 | `f`, the third parameter to paginate, must now accept a vector of nodes, and return 333 | a vector of processed nodes. The returned vector must have the same 334 | ordering as the input vector. You may want to use the function 335 | `ensure-order` to make sure the order is correct: 336 | 337 | ```clojure 338 | (require '[com.github.ivarref.clj-paginate :as cp]) 339 | 340 | (defn load-batch [nodes] 341 | (let [loaded-nodes (->> (mapv :id nodes) 342 | 343 | ; load data from database using pull-many: 344 | (datomic.api/pull-many datomic-db '[:*]) 345 | 346 | ; Do we have any ordering guarantees? Pretend the ordering got mixed up: 347 | (shuffle))] 348 | (cp/ensure-order nodes 349 | loaded-nodes 350 | :sf :id ; Source id function, defaults to :id. 351 | :df :db/id ; Dest id function, defaults to :id. 352 | 353 | ; (sf input-node) must be equal to some (df output-node). 354 | ; ensure-order uses this to order `loaded-nodes` according 355 | ; to how `nodes` were ordered. 356 | ))) 357 | 358 | ; Using load-batch 359 | (cp/paginate 360 | data 361 | :id 362 | load-batch 363 | {:first 100} 364 | 365 | ; The named parameter :batch? is set to `true`: 366 | :batch? true 367 | ) 368 | ``` 369 | 370 | 371 | ## Performance 372 | 373 | `clj-paginate` treats the (sorted) input vectors as binary trees, 374 | and thus the general performance is `O(log n)` for finding where to continue 375 | giving out data. When paginating over maps, this 376 | has to be multiplied by the number of selected keys. 377 | 378 | Using `:first 1000` and 10 million dummy entries, the average 379 | overhead was about 1-5 ms per iteration on my machine. That is about 380 | 1-5 microsecond per returned node. 381 | 382 | ## Change log 383 | 384 | ### 2022-10-06 0.3.54 385 | 386 | Add support for `inclusive?`, multiple sort criteria with `:asc` or `:desc`. 387 | Added named parameter `sort?` which defaults to `true`. 388 | 389 | ### 2022-09-23 0.2.53 390 | Bugfix. Values for `pageInfo.hasPrevPage` and `pageInfo.hasNextPage` for `last/before` pagination were reversed. Thanks [@kthu](https://github.com/kthu)! 391 | 392 | ### 2022-09-20 0.2.52 393 | Support descending values. 394 | 395 | ### 2022-02-16 0.2.51 396 | Initial release publicly announced. 397 | 398 | ## Misc 399 | 400 | A few days after I made the initial announcement, I came across 401 | [java.util.NavigableSet](https://docs.oracle.com/javase/8/docs/api/java/util/NavigableSet.html) 402 | that looks like a perfect fit for doing pagination 403 | in JVM-land. 404 | 405 | ## License 406 | 407 | Copyright © 2022 Ivar Refsdal 408 | 409 | This program and the accompanying materials are made available under the 410 | terms of the Eclipse Public License 2.0 which is available at 411 | http://www.eclipse.org/legal/epl-2.0. 412 | 413 | This Source Code may also be made available under the following Secondary 414 | Licenses when the conditions for such availability set forth in the Eclipse 415 | Public License, v. 2.0 are satisfied: GNU General Public License as published by 416 | the Free Software Foundation, either version 2 of the License, or (at your 417 | option) any later version, with the GNU Classpath Exception which is available 418 | at https://www.gnu.org/software/classpath/license.html. 419 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.10.3"}} 2 | 3 | :paths ["src"] 4 | 5 | :aliases {:test-coverage {:extra-paths ["test"] 6 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.60.972"} 7 | lambdaisland/kaocha-cloverage {:mvn/version "1.0.75"} 8 | criterium/criterium {:mvn/version "0.4.6"} 9 | io.aviso/pretty {:mvn/version "1.1"}} 10 | :exec-fn kaocha.runner/exec-fn 11 | :exec-args {}} 12 | 13 | :test {:extra-paths ["test"] 14 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.0" :git/sha "b3fd0d2"} 15 | criterium/criterium {:mvn/version "0.4.6"} 16 | io.aviso/pretty {:mvn/version "1.1"}} 17 | :main-opts ["-m" "cognitect.test-runner"] 18 | :exec-fn cognitect.test-runner.api/test 19 | :exec-args {}} 20 | 21 | :profile {:extra-deps {com.clojure-goes-fast/clj-async-profiler {:mvn/version "0.5.1"}} 22 | :jvm-opts ["-Djdk.attach.allowAttachSelf" "-XX:+UnlockDiagnosticVMOptions" "-XX:+DebugNonSafepoints"]} 23 | 24 | :jar {:extra-deps {pack/pack.alpha {:git/url "https://github.com/juxt/pack.alpha.git" 25 | :sha "0e8731e0f24db05b74769e219051b0e92b50624a"}} 26 | :main-opts ["-m" "mach.pack.alpha.skinny" "--no-libs" "--project-path" "target/out.jar"]} 27 | 28 | :release {:extra-deps {ivarref/pom-patch {:mvn/version "0.1.16"}}} 29 | 30 | :deploy {:extra-deps {slipset/deps-deploy {:mvn/version "0.2.0"}} 31 | :exec-fn deps-deploy.deps-deploy/deploy 32 | :exec-args {:installer :remote 33 | :sign-releases? false 34 | :artifact "target/out.jar"}}}} 35 | 36 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | jar 5 | com.github.ivarref 6 | clj-paginate 7 | 0.3.54 8 | clj-paginate 9 | 10 | 11 | org.clojure 12 | clojure 13 | 1.10.3 14 | 15 | 16 | 17 | src 18 | 19 | 20 | 21 | clojars 22 | https://repo.clojars.org/ 23 | 24 | 25 | 26 | scm:git:git://github.com/ivarref/clj-paginate.git 27 | scm:git:ssh://git@github.com/ivarref/clj-paginate.git 28 | v0.3.54 29 | https://github.com/ivarref/clj-paginate 30 | 31 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $# -ne 1 ]]; then 4 | echo "Illegal number of parameters" >&2 5 | exit 2 6 | fi 7 | 8 | set -ex 9 | 10 | git update-index --refresh 11 | git diff-index --quiet HEAD -- 12 | 13 | clojure -Spom 14 | clojure -X:test 15 | clojure -M:jar 16 | clojure -X:release ivarref.pom-patch/clojars-repo-only! 17 | 18 | LAST_TAG="$(git rev-list --tags --no-walk --max-count=1)" 19 | COMMITS_SINCE_LAST_TAG="$(git rev-list "$LAST_TAG"..HEAD --count)" 20 | echo "Squashing $COMMITS_SINCE_LAST_TAG commits ..." 21 | git reset --soft HEAD~"$COMMITS_SINCE_LAST_TAG" 22 | MSG="$(git log --format=%B --reverse HEAD..HEAD@{1})" 23 | git commit -m"$MSG" 24 | 25 | VERSION="$(clojure -X:release ivarref.pom-patch/set-patch-version! :patch :commit-count)" 26 | echo "Releasing $VERSION: $1" 27 | git add pom.xml README.md 28 | git commit -m "Release $VERSION" 29 | git reset --soft HEAD~2 30 | git commit -m"Release v$VERSION: $1" 31 | 32 | git tag -a v"$VERSION" -m "Release v$VERSION: $1" 33 | git push --follow-tags --force 34 | 35 | clojure -X:deploy 36 | echo "Released $VERSION: $1" 37 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate 2 | (:require [com.github.ivarref.clj-paginate.impl.pag-first-map :as pf] 3 | [com.github.ivarref.clj-paginate.impl.pag-last-map :as pl] 4 | [com.github.ivarref.clj-paginate.impl.utils :as u])) 5 | 6 | (defn compare-fn 7 | "Takes a vector of pairs as input. 8 | For example: `[[:id :asc] [:date :desc]]`. 9 | 10 | It also accepts a single keyword as input. This will be transformed to [[:keyword :asc]]. 11 | 12 | It also accepts a vector of keywords as input. This will be transformed to [[:kw1 :asc] [:kw2 :asc]]. 13 | 14 | Returns a function that can be used to sort a collection, e.g: 15 | `(into [] (sort (compare-fn [[:id :asc] [:date :desc]]) my-vector))`" 16 | [sort-attrs] 17 | (let [sort-attrs (->> (if (keyword? sort-attrs) 18 | [sort-attrs] 19 | sort-attrs) 20 | (mapv (fn [kw-or-pair] 21 | (if (keyword? kw-or-pair) 22 | [kw-or-pair :asc] 23 | kw-or-pair))))] 24 | (if 25 | (every? vector? sort-attrs) 26 | (do 27 | (doseq [[attr order] sort-attrs] 28 | (when-not (contains? #{:asc :desc} order) 29 | (throw (ex-info (str "Order must be either :asc or :desc for attribute: " attr) {})))) 30 | (fn [a b] 31 | (reduce (fn [_ [attr order]] 32 | (let [a-attr (get a attr) 33 | b-attr (get b attr) 34 | r (if (= order :asc) 35 | (compare a-attr b-attr) 36 | (compare b-attr a-attr))] 37 | (if (= r 0) 38 | 0 39 | (reduced r)))) 40 | 0 41 | sort-attrs))) 42 | (throw (ex-info (str "Unsupported format for sort-attrs. Given: " sort-attrs) {:sort-attrs sort-attrs}))))) 43 | 44 | (defn paginate 45 | "Required parameters 46 | ==================== 47 | data: The data to paginate. Must be either a vector or a map with vectors as values. 48 | All vectors must be sorted based on `node-id-attrs` or `:sort-fn`. 49 | The actual nodes in the vectors must be maps. 50 | 51 | sort-attrs: Attributes that specifies how the vector is sorted. 52 | It can be: 53 | * a single keyword. clj-paginate will assume ascending sorting. 54 | * a vector of keywords. clj-paginate will assume ascending sorting. 55 | * a vector of pairs. A pair must contain the keyword and :asc or :desc. 56 | Example: [[:id :asc] [:date :desc]]. 57 | 58 | f: Invoked on each node. 59 | Invoked a single time on all nodes if :batch? is true. 60 | 61 | opts: A map specifying what data is to be fetched. 62 | It must contain either: 63 | :first: an int specifying how many nodes to fetch, 64 | starting at the beginning of the data. 65 | 66 | Or: 67 | :last: an int specifying how many nodes to fetch, 68 | starting at the end of the data. 69 | 70 | The cursor, i.e. where to continue fetching data from 71 | on subsequent queries, should be given as a string 72 | in :after if :first is used, or :before if :last is used. 73 | 74 | Opts may also contain `:filter`, which, if present, should be a collection of 75 | keys to filter the data map on. The filter value will be persisted in 76 | the cursor string, and will automatically be used on subsequent queries. 77 | If `:filter` is not specified, no filtering is done. 78 | Thus the default behaviour is to include everything. 79 | 80 | 81 | Optional named parameters 82 | ========================= 83 | :sort-fn: Supply a custom sorting function for how the data was sorted. 84 | This allows the data to be sorted descending based on some attribute, 85 | e.g. you may pass `(juxt (comp - :foo) :bar)` for `:sort-fn` 86 | while `:node-id-attrs` is given as `[:foo :bar]`. 87 | Defaults to `(apply juxt node-id-attrs)`, i.e. ascending sorting 88 | for all attributes. 89 | Deprecated in favor of using `sort-attrs` e.g. [[:foo :desc] [:bar :asc]]. 90 | 91 | :context: User-defined data to store in every cursor. Must be pr-str-able. 92 | Defaults to {}. Can be retrieved on subsequent queries using 93 | `(get-context ...)`. 94 | 95 | :batch?: Set to true if f should be invoked a single time on all nodes, 96 | and not once for each node. If this is set to true, 97 | f must return the output nodes in the same order as the input nodes. 98 | Please see the `ensure-order` function for a helper function 99 | that makes sure the ordering is correct. 100 | The default value of :batch? is false. 101 | 102 | :inclusive?: Set to true if the result should include the node pointed to by the cursor. 103 | This is useful if you want to check for updates to a given page only based on 104 | a previous pageInfo. 105 | The default value of :inclusive? is false. 106 | 107 | :sort?: Set to `false` if you do not want clj-paginate to sort the vector(s) before doing anything. 108 | If set to `false` this will speed up processing, but of course requires that the data 109 | is already sorted. 110 | Defaults to `true`. 111 | 112 | Return value 113 | ============ 114 | The paginated data. 115 | Returns a map of 116 | {:edges [{:node { ...} :cursor ...} 117 | {:node { ...} :cursor ...} 118 | ...] 119 | :pageInfo {:hasNextPage Boolean 120 | :hasPrevPage Boolean 121 | :totalCount Integer 122 | :startCursor String 123 | :endCursor String}}" 124 | [data sort-attrs f opts & {:keys [context batch? sort-fn inclusive? sort?] :or {context {} batch? false inclusive? false sort? true}}] 125 | (assert (map? opts) "Expected opts to be a map") 126 | (let [f (if (keyword? f) (fn [node] (get node f)) f) 127 | _ (assert (fn? f) "Expected f to be a function") 128 | sort-attrs (if (keyword? sort-attrs) 129 | [sort-attrs] 130 | sort-attrs) 131 | cmp-fn (if sort-fn 132 | #(compare (sort-fn %1) (sort-fn %2)) 133 | (compare-fn sort-attrs)) 134 | sort-fn (if sort-fn sort-fn (apply juxt sort-attrs)) 135 | sort-attrs (if (every? vector? sort-attrs) 136 | (mapv first sort-attrs) 137 | sort-attrs) 138 | _ (assert (and (vector? sort-attrs) 139 | (every? keyword? sort-attrs)) 140 | "Expected sort-attrs to be a single keyword or a vector of keywords") 141 | f-map (if batch? 142 | {:batch-f f} 143 | {:f f}) 144 | cursor-str (or (get opts :after) (get opts :before) "") 145 | data (cond 146 | (vector? data) 147 | {"default" data} 148 | (and (map? data) (every? vector? (vals data))) 149 | data 150 | :else (throw (ex-info "Unsupported data type" {:data data}))) 151 | data (if sort? 152 | (reduce-kv (fn [o k v] 153 | (assoc o k (into [] (sort cmp-fn v)))) 154 | {} 155 | data) 156 | data) 157 | filter (when-let [filter (not-empty (or (get opts :filter) 158 | (get (u/maybe-decode-cursor cursor-str) :filter)))] 159 | (vec (distinct filter))) 160 | data (if (not-empty filter) 161 | (select-keys data filter) 162 | data)] 163 | (binding [*print-dup* false 164 | *print-meta* false 165 | *print-readably* true 166 | *print-length* nil 167 | *print-level* nil 168 | *print-namespace-maps* false] 169 | (cond 170 | (and (some? (get opts :first)) (some? (get opts :last))) 171 | (throw (ex-info "Both :first and :last given, don't know what to do." {:opts opts})) 172 | 173 | (and (some? (get opts :first)) (some? (get opts :before))) 174 | (throw (ex-info ":first and :before given, please use :first with :after" {:opts opts})) 175 | 176 | (and (some? (get opts :last)) (some? (get opts :after))) 177 | (throw (ex-info ":last and :after given, please use :last with :before" {:opts opts})) 178 | 179 | (pos-int? (get opts :first)) 180 | (pf/paginate-first data 181 | (merge f-map 182 | {:filter filter 183 | :max-items (get opts :first) 184 | :context context 185 | :sort-attrs sort-attrs 186 | :sort-fn sort-fn 187 | :inclusive? inclusive? 188 | :compare-fn cmp-fn}) 189 | cursor-str) 190 | 191 | (pos-int? (get opts :last)) 192 | (pl/paginate-last data 193 | (merge f-map 194 | {:filter filter 195 | :max-items (get opts :last) 196 | :context context 197 | :sort-attrs sort-attrs 198 | :sort-fn sort-fn 199 | :inclusive? inclusive? 200 | :compare-fn cmp-fn}) 201 | cursor-str) 202 | 203 | :else 204 | (throw (ex-info "Bad opts given, expected either :first or :last to be a positive integer." {:opts opts})))))) 205 | 206 | 207 | (defn ensure-order 208 | "Orders dst-vec according to src-vec. 209 | 210 | Optional named parameters `sf and `df` 211 | that defaults to `:id`. 212 | 213 | (sf source-node) must be equal to some 214 | (df dest-node) for one element in dst-vec." 215 | [src-vec dst-vec & {:keys [sf df] :or {sf :id df :id}}] 216 | (assert (vector? src-vec) "src-vec must be a vector") 217 | (assert (vector? dst-vec) "dst-vec must be a vector") 218 | (let [ks (mapv (fn [node] 219 | (if-some [id (sf node)] 220 | id 221 | (throw (ex-info "No key value for src node" {:sf sf :node node})))) 222 | src-vec) 223 | m (persistent! 224 | (reduce (fn [o node] 225 | (assoc! o (if-some [id (df node)] 226 | id 227 | (throw (ex-info "No key value for dst node" {:df df :node node}))) node)) 228 | (transient {}) 229 | dst-vec))] 230 | (persistent! 231 | (reduce 232 | (fn [res k] 233 | (when-not (contains? m k) 234 | (throw (ex-info (str "Missing key " k) {:key k}))) 235 | (conj! res (get m k))) 236 | (transient []) 237 | ks)))) 238 | 239 | 240 | (defn get-context 241 | "Gets the :context from a previous invocation of paginate as stored in :before or :after" 242 | [opts] 243 | (:context (u/get-cursor opts))) 244 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate/impl/bst2.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.impl.bst2 2 | (:import (clojure.lang IPersistentVector))) 3 | 4 | (defn get-nth [^IPersistentVector x idx] 5 | (.nth x idx)) 6 | 7 | (defn init-vector [v] 8 | [v 0 (count v)]) 9 | 10 | (defn contains-index [^IPersistentVector v idx] 11 | (when (and (< idx (count ^IPersistentVector v)) 12 | (>= idx 0)) 13 | idx)) 14 | 15 | (defn after-value-index 16 | [find-value compare-fn inclusive? [v start end]] 17 | (if (>= start end) 18 | nil 19 | (let [mid (int (/ (+ start end) 2)) 20 | curr-val (nth v mid) 21 | cmp-int (compare-fn find-value curr-val)] 22 | (cond (= 0 cmp-int) 23 | (contains-index v (if inclusive? mid (inc mid))) 24 | 25 | (neg-int? cmp-int) 26 | (or (after-value-index find-value compare-fn inclusive? [v start mid]) 27 | (contains-index v mid)) 28 | 29 | :else 30 | (after-value-index find-value compare-fn inclusive? [v (inc mid) end]))))) 31 | 32 | 33 | (defrecord tmpres [v vidx idx]) 34 | 35 | 36 | (defn after-value-take [^IPersistentVector vecs compare-fn max-items starting-indexes] 37 | (loop [res (transient []) 38 | indexes starting-indexes] 39 | (if (= max-items (count res)) 40 | (persistent! res) 41 | (if-let [^tmpres v (let [cnt (count indexes)] 42 | (loop [vec-idx 0 43 | ^tmpres res nil] 44 | (if (= vec-idx cnt) 45 | res 46 | (let [idx (get-nth indexes vec-idx)] 47 | (if (nil? idx) 48 | (recur (inc vec-idx) res) 49 | (let [v (get-nth (get-nth vecs vec-idx) idx)] 50 | (if (or (nil? res) 51 | (neg-int? (compare-fn v (.-v res)))) 52 | (recur (inc vec-idx) (tmpres. v vec-idx idx)) 53 | (recur (inc vec-idx) res))))))))] 54 | (recur (conj! res (.-v v)) 55 | (assoc indexes (.-vidx v) (contains-index (get-nth vecs (.-vidx v)) 56 | (inc (.-idx v))))) 57 | (persistent! res))))) 58 | 59 | (defn after-value2 [vecs from-value compare-fn max-items inclusive?] 60 | (->> vecs 61 | (mapv init-vector) 62 | (mapv (partial after-value-index from-value compare-fn inclusive?)) 63 | (after-value-take vecs compare-fn max-items))) 64 | 65 | (defn from-beginning 66 | [vecs compare-fn max-items] 67 | (after-value-take vecs compare-fn max-items (mapv (fn [v] (if (empty? v) nil 0)) vecs))) 68 | 69 | 70 | (defn total-count [vecs] 71 | (->> vecs 72 | (map (fn [v] (count v))) 73 | (reduce + 0))) 74 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate/impl/bst_before.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.impl.bst-before 2 | (:import (clojure.lang IPersistentVector))) 3 | 4 | (defn get-nth [^IPersistentVector x idx] 5 | (.nth x idx)) 6 | 7 | (defn init-vector [v] 8 | [v 0 (count v)]) 9 | 10 | (defn contains-index [^IPersistentVector v idx] 11 | (when (and (< idx (count ^IPersistentVector v)) 12 | (>= idx 0)) 13 | idx)) 14 | 15 | 16 | (defn before-value-index 17 | [find-value compare-fn inclusive? [v start end]] 18 | (if (>= start end) 19 | (if (and (= start end) 20 | (= end (count v))) 21 | (dec end) 22 | nil) 23 | (let [mid (int (/ (+ start end) 2)) 24 | curr-val (nth v mid) 25 | cmp-int (compare-fn find-value curr-val)] 26 | (cond (= 0 cmp-int) 27 | (contains-index v (if inclusive? mid (dec mid))) 28 | 29 | (neg-int? cmp-int) 30 | (before-value-index find-value compare-fn inclusive? [v start mid]) 31 | 32 | :else 33 | (or (before-value-index find-value compare-fn inclusive? [v (inc mid) end]) 34 | (contains-index v mid)))))) 35 | 36 | (defrecord tmpres [v vidx idx]) 37 | 38 | (defn before-value-take [^IPersistentVector vecs compare-fn max-items starting-indexes] 39 | (vec 40 | (reverse 41 | (loop [res (transient []) 42 | indexes starting-indexes] 43 | (if (= max-items (count res)) 44 | (persistent! res) 45 | (if-let [^tmpres v (let [cnt (count indexes)] 46 | (loop [vec-idx 0 47 | ^tmpres res nil] 48 | (if (= vec-idx cnt) 49 | res 50 | (let [idx (get-nth indexes vec-idx)] 51 | (if (nil? idx) 52 | (recur (inc vec-idx) res) 53 | (let [v (get-nth (get-nth vecs vec-idx) idx)] 54 | (if (or (nil? res) 55 | (pos-int? (compare-fn v (.-v res)))) 56 | (recur (inc vec-idx) (tmpres. v vec-idx idx)) 57 | (recur (inc vec-idx) res))))))))] 58 | (recur (conj! res (.-v v)) 59 | (assoc indexes (.-vidx v) (contains-index (get-nth vecs (.-vidx v)) 60 | (dec (.-idx v))))) 61 | (persistent! res))))))) 62 | 63 | (defn before-value [vecs from-value compare-fn max-items inclusive?] 64 | (->> vecs 65 | (mapv init-vector) 66 | (mapv (partial before-value-index from-value compare-fn inclusive?)) 67 | (before-value-take vecs compare-fn max-items))) 68 | 69 | (defn from-end 70 | [vecs compare-fn max-items] 71 | (before-value-take vecs compare-fn max-items (mapv (fn [v] 72 | (when (>= (count v) 1) 73 | (dec (count v)))) 74 | vecs))) 75 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate/impl/pag_first_map.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.impl.pag-first-map 2 | (:require [com.github.ivarref.clj-paginate.impl.bst2 :as bst2] 3 | [com.github.ivarref.clj-paginate.impl.utils :as u])) 4 | 5 | (defn paginate-first [m 6 | {:keys [max-items 7 | f 8 | batch-f 9 | sort-attrs 10 | context 11 | filter 12 | sort-fn 13 | inclusive? 14 | compare-fn] 15 | :or {f identity 16 | batch-f identity 17 | context nil}} 18 | cursor-str] 19 | (let [vecs (into [] (vals m)) 20 | decoded-cursor (u/maybe-decode-cursor cursor-str) 21 | cursor (-> (merge {:context context} 22 | (when filter {:filter filter}) 23 | decoded-cursor)) 24 | sort-fn (if sort-fn sort-fn (apply juxt sort-attrs)) 25 | compare-fn (if compare-fn compare-fn #(compare (sort-fn %1) (sort-fn %2))) 26 | nodes-plus-1 (if-let [from-value (get cursor :cursor)] 27 | (do 28 | (when (not= (count from-value) (count sort-attrs)) 29 | (throw (ex-info "Mismatch in size of :node-id-attrs and :cursor" {:node-id-attrs sort-attrs 30 | :cursor from-value}))) 31 | (bst2/after-value2 vecs (zipmap sort-attrs from-value) compare-fn (inc max-items) inclusive?)) 32 | (bst2/from-beginning vecs compare-fn (inc max-items))) 33 | edges (u/get-edges (take max-items nodes-plus-1) batch-f f sort-attrs cursor) 34 | hasPrevPage (or (when (not-empty nodes-plus-1) 35 | (not= (first nodes-plus-1) 36 | (first (bst2/from-beginning vecs compare-fn 1)))) 37 | (and (empty? nodes-plus-1) 38 | (some? cursor-str)))] 39 | {:edges edges 40 | :pageInfo {:hasPrevPage (true? hasPrevPage) 41 | :hasNextPage (= (count nodes-plus-1) (inc max-items)) 42 | :startCursor (or (get (first edges) :cursor) cursor-str) 43 | :endCursor (or (get (last edges) :cursor) cursor-str) 44 | :totalCount (bst2/total-count vecs)}})) 45 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate/impl/pag_last_map.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.impl.pag-last-map 2 | (:require [com.github.ivarref.clj-paginate.impl.utils :as u] 3 | [com.github.ivarref.clj-paginate.impl.bst2 :as bst2] 4 | [com.github.ivarref.clj-paginate.impl.bst-before :as bst])) 5 | 6 | (defn paginate-last [m 7 | {:keys [max-items 8 | f 9 | batch-f 10 | sort-attrs 11 | filter 12 | context 13 | sort-fn 14 | inclusive? 15 | compare-fn] 16 | :or {f identity 17 | batch-f identity 18 | context nil}} 19 | cursor-str] 20 | (let [vecs (into [] (vals m)) 21 | decoded-cursor (u/maybe-decode-cursor cursor-str) 22 | cursor (-> (merge {:context context} 23 | (when filter {:filter filter}) 24 | decoded-cursor)) 25 | sort-fn (if sort-fn sort-fn (apply juxt sort-attrs)) 26 | compare-fn (if compare-fn compare-fn #(compare (sort-fn %1) (sort-fn %2))) 27 | nodes-plus-1 (if-let [from-value (get cursor :cursor)] 28 | (do 29 | (when (not= (count from-value) (count sort-attrs)) 30 | (throw (ex-info "Mismatch in size of :node-id-attrs and :cursor" {:node-id-attrs sort-attrs 31 | :cursor from-value}))) 32 | (bst/before-value vecs (zipmap sort-attrs from-value) compare-fn (inc max-items) inclusive?)) 33 | (bst/from-end vecs compare-fn (inc max-items))) 34 | edges (u/get-edges (take-last max-items nodes-plus-1) batch-f f sort-attrs cursor)] 35 | {:edges edges 36 | :pageInfo {:hasPrevPage (> (count nodes-plus-1) max-items) 37 | :hasNextPage (or (when (not-empty nodes-plus-1) 38 | (not= (last nodes-plus-1) 39 | (first (bst/from-end vecs compare-fn 1)))) 40 | (and (empty? nodes-plus-1) 41 | (some? cursor-str))) 42 | :startCursor (or (get (first edges) :cursor) cursor-str) 43 | :endCursor (or (get (last edges) :cursor) cursor-str) 44 | :totalCount (bst2/total-count vecs)}})) 45 | -------------------------------------------------------------------------------- /src/com/github/ivarref/clj_paginate/impl/utils.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.impl.utils 2 | (:require [clojure.edn :as edn] 3 | [clojure.string :as str]) 4 | (:import (java.io StringWriter Writer))) 5 | 6 | 7 | (defn maybe-decode-cursor [cursor] 8 | (when cursor 9 | (when (and (string? cursor) 10 | (not-empty cursor) 11 | (str/starts-with? cursor "{")) 12 | (edn/read-string cursor)))) 13 | 14 | 15 | (defn get-cursor [opts] 16 | (or 17 | (let [cursor (or (get opts :after) (get opts :before))] 18 | (when (and (string? cursor) 19 | (not-empty cursor) 20 | (str/starts-with? cursor "{")) 21 | (edn/read-string cursor))) 22 | {})) 23 | 24 | 25 | (defn cursor-pre [cursor] 26 | (let [s (pr-str (dissoc cursor :cursor)) 27 | s (subs s 0 (dec (count s)))] 28 | (str s " :cursor ["))) 29 | 30 | 31 | (defn node-cursor [cursor-pre node sort-attrs] 32 | (with-open [^Writer sw (StringWriter.)] 33 | (.append sw ^String cursor-pre) 34 | (doseq [attr sort-attrs] 35 | (print-method (get node attr) sw) 36 | (.append sw " ")) 37 | (.append sw "]}") 38 | (.toString sw))) 39 | 40 | 41 | (defn get-edges [nodes batch-f f sort-attrs cursor] 42 | (let [nodes (vec nodes)] 43 | (if (empty? nodes) 44 | [] 45 | (let [cursor-pre-str (cursor-pre cursor) 46 | batch-nodes (batch-f nodes)] 47 | (loop [i 0 48 | res (transient [])] 49 | (if (= i (count nodes)) 50 | (persistent! res) 51 | (recur (inc i) 52 | (conj! res 53 | {:node (f (nth batch-nodes i)) 54 | :cursor (node-cursor cursor-pre-str 55 | (nth nodes i) 56 | sort-attrs)})))))))) 57 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/auto_reset_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.auto-reset-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as cp])) 4 | 5 | 6 | (defn nodes [conn] 7 | (->> conn 8 | :edges 9 | (mapv :node) 10 | (mapv :inst))) 11 | 12 | #_(deftest autoreset-first 13 | (let [data [{:inst 0} {:inst 1} {:inst 2} {:inst 3}] 14 | p1 (cp/prepare-paginate {:version "git-sha-1" :sort-by [:inst]} data) 15 | p2 (cp/prepare-paginate {:version "git-sha-2" :sort-by [:inst]} data) 16 | conn (cp/paginate p1 identity {:first 2})] 17 | (is (= [0 1] (nodes conn))) 18 | (is (= [2 3] (nodes (cp/paginate p1 identity {:first 2 :after (->> conn 19 | :edges 20 | last 21 | :cursor)})))) 22 | (is (= [2 3] (nodes (cp/paginate (cp/prepare-paginate {:version "git-sha-1" :sort-by [:inst]} data) 23 | identity {:first 2 :after (->> conn 24 | :edges 25 | last 26 | :cursor)})))) 27 | (is (= [0 1] (nodes (cp/paginate p2 identity {:first 2 :after (->> conn 28 | :edges 29 | last 30 | :cursor)} 31 | :auto-reset? true)))))) 32 | 33 | 34 | #_(deftest autoreset-last 35 | (let [data [{:inst 0} {:inst 1} {:inst 2} {:inst 3}] 36 | p1 (cp/prepare-paginate {:version "git-sha-1" :sort-by [:inst]} data) 37 | p2 (cp/prepare-paginate {:version "git-sha-2" :sort-by [:inst]} data) 38 | conn (cp/paginate p1 identity {:last 2})] 39 | (is (= [2 3] (nodes conn))) 40 | (is (= [0 1] (nodes (cp/paginate p1 identity {:last 2 :before (->> conn 41 | :edges 42 | first 43 | :cursor)})))) 44 | (is (= [2 3] (nodes (cp/paginate p2 identity {:last 2 :before (->> conn 45 | :edges 46 | first 47 | :cursor)} 48 | :auto-reset? true)))))) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/batch_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.batch-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as pv] 4 | [com.github.ivarref.clj-paginate.stacktrace])) 5 | 6 | 7 | (deftest batch-first 8 | (let [v (vec (range 10)) 9 | data (mapv #(assoc {} :inst %) v) 10 | seen-data (atom nil) 11 | batch-f (fn [nodes] 12 | (reset! seen-data nodes) 13 | (mapv #(assoc % :new-prop 1) nodes)) 14 | conn (pv/paginate data :inst batch-f {:first 5} :batch? true)] 15 | (is (= [{:inst 0} {:inst 1} {:inst 2} {:inst 3} {:inst 4}] @seen-data)) 16 | (is (= [{:inst 0, :new-prop 1} 17 | {:inst 1, :new-prop 1} 18 | {:inst 2, :new-prop 1} 19 | {:inst 3, :new-prop 1} 20 | {:inst 4, :new-prop 1}] 21 | (mapv :node (:edges conn)))))) 22 | 23 | 24 | (deftest ensure-order-test 25 | (let [v (mapv #(assoc {} :id %) (range 5))] 26 | (is (= v (pv/ensure-order v (vec (reverse v))))))) 27 | 28 | 29 | (deftest ensure-order-sif-dif-test 30 | (is (= [{:b/id 1} 31 | {:b/id 2}] 32 | (pv/ensure-order [{:a/id 1} 33 | {:a/id 2}] 34 | [{:b/id 2} 35 | {:b/id 1}] 36 | :sf :a/id 37 | :df :b/id)))) 38 | 39 | 40 | (deftest ensure-order-throws-test 41 | (is (thrown? Exception (pv/ensure-order [{:id 1}] [{:id 2}]))) 42 | (is (thrown? Exception (pv/ensure-order [{:a/id 1}] [{:b/id 1}]))) 43 | (is (thrown? Exception (pv/ensure-order [{:a/id 1}] [{:id 1}]))) 44 | (is (thrown? Exception (pv/ensure-order [{:id 1}] [{:b/id 1}])))) 45 | 46 | 47 | (deftest batch-last 48 | (let [v (vec (range 10)) 49 | data (mapv #(assoc {} :inst %) v) 50 | seen-data (atom nil) 51 | batch-f (fn [nodes] 52 | (reset! seen-data nodes) 53 | (mapv #(assoc % :new-prop 1) nodes)) 54 | conn (pv/paginate data :inst batch-f {:last 5} :batch? true)] 55 | (is (= [{:inst 5} {:inst 6} {:inst 7} {:inst 8} {:inst 9}] @seen-data)) 56 | (is (= [{:inst 5, :new-prop 1} 57 | {:inst 6, :new-prop 1} 58 | {:inst 7, :new-prop 1} 59 | {:inst 8, :new-prop 1} 60 | {:inst 9, :new-prop 1}] 61 | (mapv :node (:edges conn)))))) 62 | 63 | 64 | (deftest batch-cursor-first 65 | (let [v (vec (range 6)) 66 | data (mapv #(assoc {} :a/inst %) v) 67 | batch-f (fn [nodes] (mapv #(assoc {} :b/inst (:a/inst %)) nodes)) 68 | conn (pv/paginate data :a/inst batch-f {:first 3} :batch? true)] 69 | (is (= [{:b/inst 0} {:b/inst 1} {:b/inst 2}] (mapv :node (:edges conn)))) 70 | (is (= [{:b/inst 3} {:b/inst 4} {:b/inst 5}] 71 | (mapv :node (:edges 72 | (pv/paginate data 73 | :a/inst 74 | batch-f 75 | {:first 3 76 | :after (get-in conn [:pageInfo :endCursor])} 77 | :batch? true))))))) 78 | 79 | 80 | (deftest batch-cursor-last 81 | (let [v (vec (range 6)) 82 | data (mapv #(assoc {} :a/inst %) v) 83 | batch-f (fn [nodes] (mapv #(assoc {} :b/inst (:a/inst %)) nodes)) 84 | conn (pv/paginate data :a/inst batch-f {:last 3} :batch? true)] 85 | (is (= [{:b/inst 3} {:b/inst 4} {:b/inst 5}] (mapv :node (:edges conn)))) 86 | (is (= [{:b/inst 0} {:b/inst 1} {:b/inst 2}] 87 | (mapv :node (:edges 88 | (pv/paginate data :a/inst 89 | batch-f 90 | {:last 3 91 | :before (get-in conn [:pageInfo :startCursor])} 92 | :batch? true))))))) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/edge_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.edge-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as pag])) 4 | 5 | 6 | (deftest empty-input 7 | (is (= [] (:edges (pag/paginate {"empty" []} :inst identity {:first 10}))))) 8 | 9 | 10 | (deftest differing-lengths 11 | (is (= [0 1 2 3 4] 12 | (mapv :node (:edges (pag/paginate {"even" [{:inst 0} {:inst 2} {:inst 4}] 13 | "odd" [{:inst 1} {:inst 3}]} 14 | :inst 15 | :inst 16 | {:first 10})))))) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/global_sort_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.global-sort-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate.impl.pag-first-map :as pf] 4 | [clojure.set :as set]) 5 | (:import (clojure.lang IDeref IFn ILookup))) 6 | 7 | 8 | (defn nodes [res] 9 | (mapv :node (get res :edges))) 10 | 11 | 12 | (defn after [res] 13 | (->> res 14 | :pageInfo 15 | :endCursor)) 16 | 17 | 18 | (defn before [res] 19 | (->> res 20 | :pageInfo 21 | :startCursor)) 22 | 23 | 24 | (defn pagg2 [data sort-attrs fetch-opts] 25 | (let [data (atom data) 26 | fetch-opts (atom fetch-opts) 27 | conn (atom nil)] 28 | (reify 29 | ILookup 30 | (valAt [_ attr] 31 | (cond (contains? #{:hasNextPage :hasPrevPage :totalCount :endCursor :startCursor} attr) 32 | (get-in @conn [:pageInfo attr]) 33 | 34 | (= :after attr) 35 | (after @conn) 36 | 37 | (= :before attr) 38 | (before @conn) 39 | 40 | :else 41 | (get @conn attr))) 42 | IFn 43 | (invoke [this s] 44 | (cond (vector? s) 45 | (do (reset! data s) 46 | this))) 47 | IDeref 48 | (deref [_] 49 | (when (:first @fetch-opts) 50 | (reset! conn (pf/paginate-first 51 | {"default" @data} 52 | (merge 53 | {:sort-attrs sort-attrs} 54 | (set/rename-keys @fetch-opts {:first :max-items})) 55 | (when-let [conn @conn] 56 | (after conn))))) 57 | (nodes @conn))))) 58 | 59 | 60 | (deftest paginate-groups-test 61 | (let [conn (pagg2 62 | [{:inst 1 :group :a} 63 | {:inst 1 :group :b}] 64 | [:inst :group] 65 | {:first 2})] 66 | (is (= [{:inst 1, :group :a} {:inst 1, :group :b}] @conn)) 67 | (is (false? (:hasPrevPage conn))) 68 | (is (false? (:hasNextPage conn))) 69 | 70 | (is (= [] @conn)) 71 | (is (true? (:hasPrevPage conn))) 72 | (is (false? (:hasNextPage conn))) 73 | 74 | (is (= [] @conn)) 75 | (is (true? (:hasPrevPage conn))) 76 | (is (false? (:hasNextPage conn))) 77 | 78 | ; new data arrives: 79 | (conn [{:inst 1 :group :a} 80 | {:inst 1 :group :b} 81 | {:inst 2 :group :a} 82 | {:inst 2 :group :b}]) 83 | 84 | (is (= [{:inst 2, :group :a} {:inst 2, :group :b}] @conn)) 85 | (is (true? (:hasPrevPage conn))) 86 | (is (false? (:hasNextPage conn))) 87 | 88 | (conn [{:inst 2 :group :a} 89 | {:inst 2 :group :b} 90 | {:inst 3 :group :a} 91 | {:inst 3 :group :b}]) 92 | 93 | (is (= [{:inst 3, :group :a} {:inst 3, :group :b}] @conn)) 94 | (is (true? (:hasPrevPage conn))) 95 | (is (false? (:hasNextPage conn))) 96 | 97 | (conn [{:inst 4 :group :a} 98 | {:inst 4 :group :b}]) 99 | 100 | (is (= [{:inst 4, :group :a} {:inst 4, :group :b}] @conn)) 101 | (is (false? (:hasPrevPage conn))) 102 | (is (false? (:hasNextPage conn))) 103 | 104 | (conn [{:inst 4 :group :a} 105 | {:inst 5 :group :a} 106 | {:inst 5 :group :b} 107 | {:inst 6 :group :a} 108 | {:inst 7 :group :a}]) 109 | 110 | (is (= [{:inst 5, :group :a} {:inst 5, :group :b}] @conn)) 111 | (is (true? (:hasPrevPage conn))) 112 | (is (true? (:hasNextPage conn))) 113 | 114 | (is (= [{:inst 6, :group :a} {:inst 7, :group :a}] @conn)) 115 | (is (true? (:hasPrevPage conn))) 116 | (is (false? (:hasNextPage conn))))) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/inclusive_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.inclusive-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as cp])) 4 | 5 | (defn nodes [pag] 6 | (mapv :node (:edges pag))) 7 | 8 | (deftest inclusive?-after-test 9 | (let [data [{:id 1} 10 | {:id 2} 11 | {:id 3}] 12 | data2 [{:id 0} 13 | {:id 1} 14 | {:id 2} 15 | {:id 3}] 16 | {:keys [pageInfo]} (cp/paginate data 17 | [:id] 18 | identity 19 | {:first 2})] 20 | (is (false? (:hasPrevPage pageInfo))) 21 | (is (= [{:id 1} 22 | {:id 2}] 23 | (nodes (cp/paginate data [:id] identity {:first 2 :after (:startCursor pageInfo)} :inclusive? true)))) 24 | (is (= [{:id 1} 25 | {:id 2}] 26 | (nodes (cp/paginate data2 [:id] identity {:first 2 :after (:startCursor pageInfo)} :inclusive? true)))) 27 | (is (true? (:hasPrevPage (:pageInfo (cp/paginate data2 [:id] identity {:first 2 :after (:startCursor pageInfo)} :inclusive? true))))))) 28 | 29 | 30 | (deftest inclusive?-before-test 31 | (let [data [{:id 1} 32 | {:id 2} 33 | {:id 3}] 34 | data2 [{:id 1} 35 | {:id 2} 36 | {:id 3} 37 | {:id 4}] 38 | {:keys [pageInfo] :as con} (cp/paginate data 39 | [:id] 40 | identity 41 | {:last 2})] 42 | (is (= [{:id 2} {:id 3}] (nodes con))) 43 | (is (false? (:hasNextPage pageInfo))) 44 | (is (= [{:id 2} 45 | {:id 3}] 46 | (nodes (cp/paginate data [:id] identity {:last 2 :before (:endCursor pageInfo)} :inclusive? true)))) 47 | (is (= [{:id 2} 48 | {:id 3}] 49 | (nodes (cp/paginate data2 [:id] identity {:last 2 :before (:endCursor pageInfo)} :inclusive? true)))) 50 | (is (true? (:hasNextPage (:pageInfo (cp/paginate data2 [:id] identity {:last 2 :before (:startCursor pageInfo)} :inclusive? true))))))) 51 | 52 | (defn startCursor [conn] 53 | (:startCursor (:pageInfo conn))) 54 | 55 | (defn endCursor [conn] 56 | (:endCursor (:pageInfo conn))) 57 | 58 | (defn node-ids [pag] 59 | (mapv :id (mapv :node (:edges pag)))) 60 | 61 | (deftest inclusive?-before-test-2 62 | (let [data (mapv (fn [x] {:id (inc x)}) (range 10)) 63 | conn (cp/paginate data [:id] identity {:last 5})] 64 | (is (= [6 7 8 9 10] (node-ids conn))) 65 | (is (= [6 7 8 9 10] (node-ids (cp/paginate data [:id] identity {:last 5 :before (endCursor conn)} :inclusive? true)))) 66 | (is (= [1 2 3 4 5] (node-ids (cp/paginate data [:id] identity {:last 5 :before (startCursor conn)})))))) 67 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/multi_sort_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.multi-sort-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as cp])) 4 | 5 | (def data [{:id 1 :date #inst"2000"} 6 | {:id 2 :date #inst"2001"} 7 | {:id 3 :date #inst"2002"} 8 | {:id 4 :date #inst"2002"}]) 9 | 10 | (deftest bad-input-not-asc-or-desc 11 | (try 12 | (cp/paginate 13 | data 14 | [[:id :asc] [:date :bad]] 15 | identity 16 | {:first 2}) 17 | (is (= 0 1)) 18 | (catch Exception e 19 | (is (= 1 1))))) 20 | 21 | (defn nodes [conn] (mapv (comp :id :node) (:edges conn))) 22 | 23 | (deftest basic 24 | (is (= [4 3] (nodes (cp/paginate 25 | (vec (shuffle data)) 26 | [[:id :desc]] 27 | identity 28 | {:first 2})))) 29 | (is (= [1 2] (nodes (cp/paginate 30 | (vec (shuffle data)) 31 | [[:id :asc]] 32 | identity 33 | {:first 2}))))) 34 | 35 | (deftest multi 36 | (is (= [3 4] (nodes (cp/paginate 37 | (vec (shuffle data)) 38 | [[:date :desc] [:id :asc]] 39 | identity 40 | {:first 2})))) 41 | (is (= [4 3] (nodes (cp/paginate 42 | (vec (shuffle data)) 43 | [[:date :desc] [:id :desc]] 44 | identity 45 | {:first 2}))))) 46 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/or_filter_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.or-filter-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as cp])) 4 | 5 | 6 | (def data 7 | (group-by :status 8 | [{:inst 0 :status :init} 9 | {:inst 1 :status :pending} 10 | {:inst 2 :status :done} 11 | {:inst 3 :status :error} 12 | {:inst 4 :status :done} 13 | {:inst 5 :status :done} 14 | {:inst 6 :status :init}])) 15 | 16 | (deftest basic-filtering 17 | (let [conn (cp/paginate 18 | data 19 | :inst 20 | identity 21 | {:first 2 22 | :filter [:done]})] 23 | (is (= [2 4] (mapv (comp :inst :node) (:edges conn)))) 24 | (is (= [5] (mapv (comp :inst :node) 25 | (:edges (cp/paginate 26 | data 27 | :inst 28 | identity 29 | {:first 2 30 | :after (get-in conn [:pageInfo :endCursor])}))))))) 31 | 32 | 33 | (deftest missing-filter->include-everything 34 | (let [conn (cp/paginate 35 | data 36 | :inst 37 | identity 38 | {:first 2})] 39 | (is (= [0 1] (mapv (comp :inst :node) (:edges conn)))) 40 | (is (= [2 3] (mapv (comp :inst :node) 41 | (:edges (cp/paginate 42 | data 43 | :inst 44 | identity 45 | {:first 2 46 | :after (get-in conn [:pageInfo :endCursor])}))))))) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/paginate_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.paginate-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate.impl.pag-first-map :as pf] 4 | [com.github.ivarref.clj-paginate.impl.pag-last-map :as pl] 5 | [com.github.ivarref.clj-paginate.stacktrace] 6 | [clojure.set :as set]) 7 | (:import (clojure.lang ILookup IFn IDeref))) 8 | 9 | 10 | (defn nodes [res] 11 | (mapv (comp :inst :node) (get res :edges))) 12 | 13 | 14 | (defn after [res] 15 | (->> res 16 | :pageInfo 17 | :endCursor)) 18 | 19 | 20 | (defn before [res] 21 | (->> res 22 | :pageInfo 23 | :startCursor)) 24 | 25 | 26 | (defn pagg2 [data sort-attrs fetch-opts] 27 | (let [data (atom (vec (sort-by (apply juxt sort-attrs) data))) 28 | fetch-opts (atom fetch-opts) 29 | conn (atom nil)] 30 | (reify 31 | ILookup 32 | (valAt [_ attr] 33 | (cond (contains? #{:hasNextPage :hasPrevPage :totalCount :endCursor :startCursor} attr) 34 | (get-in @conn [:pageInfo attr]) 35 | 36 | (= :after attr) 37 | (after @conn) 38 | 39 | (= :before attr) 40 | (before @conn) 41 | 42 | :else 43 | (get @conn attr))) 44 | IFn 45 | (invoke [this s] 46 | (cond (vector? s) 47 | (do (reset! data (vec (sort-by (apply juxt sort-attrs) s))) 48 | this))) 49 | 50 | IDeref 51 | (deref [_] 52 | (when (:first @fetch-opts) 53 | (reset! conn (pf/paginate-first 54 | {"default" @data} 55 | (merge 56 | @fetch-opts 57 | {:sort-attrs sort-attrs} 58 | (set/rename-keys @fetch-opts {:first :max-items})) 59 | (when-let [conn @conn] 60 | (after conn))))) 61 | (when (:last @fetch-opts) 62 | (reset! conn (pl/paginate-last 63 | {"default" @data} 64 | (merge 65 | @fetch-opts 66 | {:sort-attrs sort-attrs} 67 | (set/rename-keys @fetch-opts {:last :max-items})) 68 | (when-let [conn @conn] 69 | (before conn))))) 70 | (nodes @conn))))) 71 | 72 | 73 | (deftest invokes-f 74 | (let [conn (pagg2 75 | (shuffle 76 | [{:inst 0} {:inst 1} {:inst 2} {:inst 3} {:inst 4}]) 77 | [:inst] 78 | {:first 2 79 | :f (fn [{:keys [inst]}] 80 | {:inst (inc inst)})})] 81 | (is (= [1 2] @conn)))) 82 | 83 | 84 | (deftest invokes-f-on-last 85 | (let [conn (pagg2 86 | (shuffle 87 | [{:inst 0} {:inst 1} {:inst 2} {:inst 3} 88 | {:inst 4}]) 89 | [:inst] 90 | {:last 2 91 | :f (fn [{:keys [inst]}] 92 | {:inst (inc inst)})})] 93 | (is (= [4 5] @conn)))) 94 | 95 | 96 | (deftest invokes-f-not-necessary 97 | (let [cnt (atom 0) 98 | conn (pagg2 99 | (shuffle 100 | [{:inst 0} {:inst 1} {:inst 2} {:inst 3} 101 | {:inst 4}]) 102 | [:inst] 103 | {:first 2 104 | :f (fn [{:keys [inst]}] 105 | (swap! cnt inc) 106 | {:inst (inc inst)})})] 107 | (is (= [1 2] @conn)) 108 | (is (= 2 @cnt)))) 109 | 110 | 111 | (deftest can-create-map-tree-paginator 112 | (let [conn (pagg2 113 | (shuffle 114 | [{:inst 0} {:inst 1} {:inst 2} {:inst 3} 115 | {:inst 4}]) 116 | [:inst] 117 | {:first 2})] 118 | (is (= [0 1] @conn)) 119 | (is (false? (:hasPrevPage conn))) 120 | (is (true? (:hasNextPage conn))) 121 | 122 | (is (= [2 3] @conn)) 123 | (is (true? (:hasPrevPage conn))) 124 | (is (true? (:hasNextPage conn))) 125 | 126 | (is (= [4] @conn)) 127 | (is (true? (:hasPrevPage conn))) 128 | (is (false? (:hasNextPage conn))) 129 | 130 | (is (= [] @conn)) 131 | (is (true? (:hasPrevPage conn))) 132 | (is (false? (:hasNextPage conn))) 133 | 134 | (is (= [] @conn)) 135 | (is (true? (:hasPrevPage conn))) 136 | (is (false? (:hasNextPage conn))) 137 | 138 | ; add more data 139 | (conn [{:inst 0} {:inst 1} {:inst 2} {:inst 3} 140 | {:inst 4} {:inst 5} {:inst 6} {:inst 7}]) 141 | 142 | (is (= [5 6] @conn)) 143 | (is (true? (:hasPrevPage conn))) 144 | (is (true? (:hasNextPage conn))) 145 | 146 | (is (= [7] @conn)) 147 | (is (true? (:hasPrevPage conn))) 148 | (is (false? (:hasNextPage conn))) 149 | 150 | (is (= [] @conn)) 151 | (is (true? (:hasPrevPage conn))) 152 | (is (false? (:hasNextPage conn))) 153 | 154 | (is (= [] @conn)) 155 | (is (true? (:hasPrevPage conn))) 156 | (is (false? (:hasNextPage conn))))) 157 | 158 | 159 | (deftest paginate-last-test 160 | (let [conn (pagg2 161 | [{:inst 1 :group :a} 162 | {:inst 2 :group :a} 163 | {:inst 3 :group :b} 164 | {:inst 4 :group :b}] 165 | [:inst :group] 166 | {:last 3})] 167 | (is (= [2 3 4] @conn)) 168 | (is (true? (:hasPrevPage conn))) 169 | (is (false? (:hasNextPage conn))) 170 | 171 | (is (= [1] @conn)) 172 | (is (false? (:hasPrevPage conn))) 173 | (is (true? (:hasNextPage conn))) 174 | 175 | (is (= [] @conn)) 176 | (is (false? (:hasPrevPage conn))) 177 | (is (true? (:hasNextPage conn))) 178 | 179 | (is (= [] @conn)) 180 | (is (false? (:hasPrevPage conn))) 181 | (is (true? (:hasNextPage conn))) 182 | 183 | (conn [{:inst 1 :group :a} 184 | {:inst 2 :group :a} 185 | {:inst 3 :group :a} 186 | {:inst 4 :group :b} 187 | {:inst 5 :group :b} 188 | {:inst 6 :group :b}]) 189 | 190 | (is (= [] @conn)))) 191 | 192 | 193 | (deftest first-global-sorting 194 | (let [conn (pagg2 195 | [{:inst 0 :group :a} 196 | {:inst 2 :group :a} 197 | {:inst 1 :group :b} 198 | {:inst 3 :group :b}] 199 | [:inst] 200 | {:first 4})] 201 | (is (= [0 1 2 3] @conn)) 202 | 203 | (conn [{:inst 0 :group :a} 204 | {:inst 2 :group :a} 205 | {:inst 4 :group :a} 206 | {:inst 6 :group :a} 207 | {:inst 1 :group :b} 208 | {:inst 3 :group :b} 209 | {:inst 5 :group :b} 210 | {:inst 7 :group :b}]) 211 | (is (= [4 5 6 7] @conn)))) 212 | 213 | 214 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/perf2_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.perf2-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate.ticker :as ticker] 4 | [com.github.ivarref.clj-paginate.impl.pag-first-map :as pm]) 5 | (:import (java.lang AutoCloseable) 6 | (java.util ArrayList Random Collections Collection) 7 | (clojure.lang RT))) 8 | 9 | (defn deterministic-shuffle 10 | [^Collection coll seed] 11 | (let [al (ArrayList. coll) 12 | rng (Random. seed)] 13 | (Collections/shuffle al rng) 14 | (RT/vector (.toArray al)))) 15 | 16 | (deftest perftest2 17 | (let [n 1e5 18 | total-n (* n 10) 19 | total-vec (deterministic-shuffle (vec (range total-n)) 789) 20 | items (atom total-vec) 21 | eat! (fn [] 22 | (let [r (take (int n) @items)] 23 | (swap! items (fn [old-items] (vec (drop (int n) old-items)))) 24 | (mapv #(assoc {} :inst %) (sort r)))) 25 | data {"1" (eat!) 26 | "2" (eat!) 27 | "3" (eat!) 28 | "4" (eat!) 29 | "5" (eat!) 30 | "6" (eat!) 31 | "7" (eat!) 32 | "8" (eat!) 33 | "9" (eat!) 34 | "10" (eat!)} 35 | opts {:sort-attrs [:inst] 36 | :max-items 1000} 37 | all-items (with-open [^AutoCloseable tick (ticker/ticker total-n)] 38 | (loop [so-far [] 39 | conn (pm/paginate-first data opts nil)] 40 | (if-let [edges (not-empty (:edges conn))] 41 | (do 42 | (tick (count edges)) 43 | (recur (into so-far (mapv (comp :inst :node) edges)) 44 | (pm/paginate-first data opts (:cursor (last edges))))) 45 | so-far)))] 46 | (is (= all-items (vec (range total-n)))))) 47 | ; [########################################] 100% done, 2383 µs/iter 48 | 49 | (comment 50 | (do 51 | ; echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid 52 | (require '[clj-async-profiler.core :as prof]) 53 | #_(prof/serve-files 8080) 54 | (doseq [f (->> (file-seq (clojure.java.io/file "/tmp/clj-async-profiler/results")) 55 | (remove #(.isDirectory %)))] 56 | (.delete f)) 57 | (prof/profile (clojure.test/test-var #'perftest2)))) 58 | 59 | (comment 60 | (prof/serve-files 8080)) 61 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/perf3_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.perf3-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate.ticker :as ticker] 4 | [com.github.ivarref.clj-paginate.impl.pag-last-map :as pm]) 5 | (:import (java.lang AutoCloseable) 6 | (java.util ArrayList Random Collections Collection) 7 | (clojure.lang RT))) 8 | 9 | (defn deterministic-shuffle 10 | [^Collection coll seed] 11 | (let [al (ArrayList. coll) 12 | rng (Random. seed)] 13 | (Collections/shuffle al rng) 14 | (RT/vector (.toArray al)))) 15 | 16 | (deftest perftest3 17 | (let [n 1e5 18 | total-n (* n 10) 19 | total-vec (deterministic-shuffle (vec (range total-n)) 789) 20 | items (atom total-vec) 21 | eat! (fn [] 22 | (let [r (take (int n) @items)] 23 | (swap! items (fn [old-items] (vec (drop (int n) old-items)))) 24 | (mapv #(assoc {} :inst %) (sort r)))) 25 | data {"1" (eat!) 26 | "2" (eat!) 27 | "3" (eat!) 28 | "4" (eat!) 29 | "5" (eat!) 30 | "6" (eat!) 31 | "7" (eat!) 32 | "8" (eat!) 33 | "9" (eat!) 34 | "10" (eat!)} 35 | opts {:sort-attrs [:inst] 36 | :max-items 1000} 37 | all-items (with-open [^AutoCloseable tick (ticker/ticker total-n)] 38 | (loop [so-far [] 39 | conn (pm/paginate-last data opts nil)] 40 | (if-let [edges (not-empty (:edges conn))] 41 | (do 42 | (tick (count edges)) 43 | (recur (conj so-far (mapv (comp :inst :node) edges)) 44 | (pm/paginate-last data opts (:cursor (first edges))))) 45 | so-far)))] 46 | (is (= (reduce into [] (reverse all-items)) 47 | (vec (range total-n)))))) 48 | ; [########################################] 100% done, 2398 µs/iter -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/perf_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.perf-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate.paginate-test :as bmt] 4 | [com.github.ivarref.clj-paginate.ticker :as ticker]) 5 | (:import (java.lang AutoCloseable))) 6 | 7 | (deftest perftest 8 | (let [n 10e5 9 | total-vec (vec (range n)) 10 | conn (bmt/pagg2 (mapv #(assoc {} :inst %) total-vec) 11 | [:inst] 12 | {:first 1000}) 13 | all-items (with-open [^AutoCloseable tick (ticker/ticker n)] 14 | (loop [so-far []] 15 | (if-let [new-items (not-empty @conn)] 16 | (do 17 | (tick (count new-items)) 18 | (recur (into so-far new-items))) 19 | so-far)))] 20 | (is (= all-items total-vec)))) 21 | 22 | (comment 23 | (do 24 | ; echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid 25 | (require '[clj-async-profiler.core :as prof]) 26 | (prof/profile 27 | (clojure.test/test-var #'perftest)))) 28 | 29 | (comment 30 | (do 31 | (require '[clj-async-profiler.core :as prof]) 32 | (doseq [f (->> (file-seq (clojure.java.io/file "/tmp/clj-async-profiler/results")) 33 | (remove #(.isDirectory %)))] 34 | (.delete f)) 35 | (prof/serve-files 8080))) 36 | 37 | (comment 38 | (def vv (time (mapv #(assoc {} :inst %) (range 10e6))))) 39 | 40 | (comment 41 | (require '[com.github.ivarref.clj-paginate :as pg]) 42 | (time (pg/prepare-paginate))) 43 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/readme.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.readme 2 | (:require [com.github.ivarref.clj-paginate :as cp])) 3 | 4 | (def data 5 | (group-by :status 6 | [{:inst 0 :id 1 :status :init} 7 | {:inst 1 :id 2 :status :pending} 8 | {:inst 2 :id 3 :status :done} 9 | {:inst 3 :id 4 :status :error} 10 | {:inst 4 :id 5 :status :done}])) 11 | 12 | (defn http-post-handler 13 | [response data http-body] 14 | (assoc response 15 | :status 200 16 | :body (cp/paginate 17 | data 18 | :inst 19 | (fn [{:keys [inst id] :as node}] 20 | (Thread/sleep 10) ; Do some heavy work. 21 | (assoc node :value-from-db 1)) 22 | 23 | ; Assume that the HTTP endpoint accepts a parameter `:statuses` for the body, 24 | ; and that when present, this is a vector such as `[:init :pending :done :error]` or similar, 25 | ; i.e. the keys of `data` that we want to filter on. 26 | ; 27 | ; Paginate's `opts` accepts a key `:filter` that does exactly this for data maps. 28 | ; Thus we can simply rename `:statuses` to `:filter` in the http body. 29 | ; clj-paginate takes care of storing the value of `:filter` in the cursor 30 | ; for subsequent queries. 31 | (clojure.set/rename-keys http-body {:statuses :filter})))) 32 | 33 | (let [conn (cp/paginate 34 | data 35 | :inst 36 | identity 37 | {:first 1 38 | :filter [:done]})] 39 | (println (mapv :node (:edges conn))) 40 | 41 | (println (mapv :node (:edges (cp/paginate 42 | data 43 | :inst 44 | identity 45 | {:first 1 46 | :after (get-in conn [:pageInfo :endCursor])}))))) 47 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/reverse_test.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.reverse-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.github.ivarref.clj-paginate :as cp])) 4 | 5 | (def sort-fn (juxt (comp - :foo) :bar)) 6 | 7 | (def data (->> [{:foo 2 :bar 11} 8 | {:foo 2 :bar 55} 9 | {:foo 1 :bar 77} 10 | {:foo 1 :bar 99}] 11 | (shuffle) 12 | (sort-by sort-fn) 13 | (vec))) 14 | 15 | (deftest first-reverse-test 16 | (let [conn (cp/paginate data 17 | [:foo :bar] 18 | identity 19 | {:first 2} 20 | :sort-fn sort-fn)] 21 | (is (= [{:foo 2, :bar 11} {:foo 2, :bar 55}] 22 | (mapv :node (:edges conn)))) 23 | (is (= [{:foo 1 :bar 77} {:foo 1 :bar 99}] 24 | (mapv :node (:edges (cp/paginate data 25 | [:foo :bar] 26 | identity 27 | {:first 2 :after (get-in conn [:pageInfo :endCursor])} 28 | :sort-fn sort-fn))))))) 29 | 30 | (deftest first-2-reverse-test 31 | (let [conn (cp/paginate data 32 | [:bar :foo] 33 | identity 34 | {:first 2} 35 | :sort-fn sort-fn)] 36 | (is (= [{:foo 2, :bar 11} {:foo 2, :bar 55}] 37 | (mapv :node (:edges conn)))) 38 | (is (= [{:foo 1 :bar 77} {:foo 1 :bar 99}] 39 | (mapv :node (:edges (cp/paginate data 40 | [:bar :foo] 41 | identity 42 | {:first 2 :after (get-in conn [:pageInfo :endCursor])} 43 | :sort-fn sort-fn))))))) 44 | 45 | (deftest last-reverse-test 46 | (let [conn (cp/paginate data 47 | [:foo :bar] 48 | identity 49 | {:last 2} 50 | :sort-fn sort-fn)] 51 | (is (= [{:foo 1 :bar 77} {:foo 1 :bar 99}] 52 | (mapv :node (:edges conn)))) 53 | (is (= [{:foo 2 :bar 11} {:foo 2 :bar 55}] 54 | (mapv :node (:edges (cp/paginate data 55 | [:foo :bar] 56 | identity 57 | {:last 2 :before (get-in conn [:pageInfo :startCursor])} 58 | :sort-fn sort-fn))))))) 59 | -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/stacktrace.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.stacktrace 2 | (:require [io.aviso.exception :as exception] 3 | [io.aviso.repl :as repl])) 4 | 5 | 6 | (alter-var-root 7 | #'exception/*fonts* 8 | (constantly nil)) 9 | 10 | (alter-var-root 11 | #'exception/*default-frame-rules* 12 | (constantly [[:package "clojure.lang" :omit] 13 | [:name #"clojure\.test.*" :hide] 14 | [:name #"clojure\.core.*" :hide] 15 | [:name #"clojure\.main.*" :hide] 16 | [:file "REPL Input" :hide] 17 | [:name #"nrepl.*" :hide] 18 | [:name "" :hide] 19 | [:name #"clojure\.main/repl/read-eval-print.*" :hide]])) 20 | 21 | (repl/install-pretty-exceptions) -------------------------------------------------------------------------------- /test/com/github/ivarref/clj_paginate/ticker.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.clj-paginate.ticker 2 | (:require [clojure.string :as str]) 3 | (:import (clojure.lang IFn) 4 | (java.lang AutoCloseable))) 5 | 6 | (def is-cursive? 7 | (try 8 | (require '[cursive.repl.runtime]) 9 | true 10 | (catch Exception _ 11 | false))) 12 | 13 | (defn ticker [total] 14 | (let [total (long total) 15 | start-time (atom (System/nanoTime)) 16 | last-tick (atom 0) 17 | cnt (atom 0) 18 | inv-count (atom 0)] 19 | (reify 20 | AutoCloseable 21 | (close [_] 22 | (when-not is-cursive? 23 | (println ""))) 24 | IFn 25 | (invoke [_ n] 26 | (let [n (long n) 27 | now (System/nanoTime) 28 | inv-count (swap! inv-count inc) 29 | spent-time (- now @start-time) 30 | time-since-last-tick (double (/ (- now @last-tick) 1e6)) 31 | [old new-count] (swap-vals! cnt (fn [old] (+ old n))) 32 | cr (if is-cursive? 33 | (str \u001b "[1K") 34 | "\r") 35 | clear-to-end-of-line (str \u001b "[0K") 36 | width 40 37 | fill-count (int (Math/ceil (* width 38 | (/ new-count total))))] 39 | (when (or (and (false? is-cursive?) 40 | (>= time-since-last-tick 333)) 41 | (>= time-since-last-tick 1000) 42 | (= new-count total)) 43 | (reset! last-tick now) 44 | (let [s (str (when (and (false? is-cursive?) (not= old 0)) cr) 45 | "[" 46 | (str/join "" (repeat fill-count "#")) 47 | (str/join "" (repeat (- width fill-count) ".")) 48 | "] " 49 | (format "%.0f%% done" (double (/ (* 100 new-count) total))) 50 | ", " 51 | (format "%.0f µs/iter" (double (/ (/ spent-time 1000) inv-count))) 52 | clear-to-end-of-line)] 53 | (if is-cursive? 54 | (println s) 55 | (do 56 | (print s) 57 | (flush))))) 58 | nil))))) -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:plugins [:kaocha.plugin/cloverage]} --------------------------------------------------------------------------------