Initial commit

This commit is contained in:
George Narroway 2019-11-14 10:01:38 +08:00
commit 33bf023a88
9 changed files with 1342 additions and 0 deletions

28
.circleci/config.yml Normal file
View file

@ -0,0 +1,28 @@
version: 2.1
jobs:
build:
docker:
- image: circleci/clojure:openjdk-11-lein
steps:
- checkout
- run: lein test
# publish:
# docker:
# - image: circleci/clojure:openjdk-11-lein
# steps:
# - checkout
# - run: lein deploy clojars
workflows:
version: 2
build-publish:
jobs:
- build
# - publish:
# requires:
# - build
# filters:
# branches:
# only: master

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
/target
/classes
/checkouts
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
.hgignore
.hg/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 George Simon Narroway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# mongo-driver-3
A Mongo client for clojure, lightly wrapping 3.11+ versions of the [MongoDB Java Driver](https://mongodb.github.io/mongo-java-driver/)
In general, it will feel familiar to users of mongo clients like [monger](https://github.com/michaelklishin/monger).
Like our HTTP/2 client [hato](https://github.com/gnarroway/hato), the API is designed to be idiomatic and to make common
tasks convenient, whilst still allowing the underlying client to be configured via native Java objects.
It was developed with the following goals:
- Up to date with the latest driver versions
- Minimal layer that doesn't block any functionality
- Consistent API across all functions
- Configuration over macros
- Simple
## Status
mongo-driver-3 is under active development but the existing API is unlikely to break.
Please try it out and raise any issues you may find.
## Usage
For Leinengen, add this to your project.clj:
```clojure
;; The underlying driver -- any newer version can also be used
[org.mongodb/mongodb-driver-sync "3.11.0"]
;; This wrapper library
[com.gnarroway/mongo-driver-3 "0.1.0-SNAPSHOT"]
```
## License
Released under the MIT License: http://www.opensource.org/licenses/mit-license.php

14
project.clj Normal file
View file

@ -0,0 +1,14 @@
(defproject mongo-driver-3 "0.1.0-SNAPSHOT"
:description "A Clojure wrapper for the Java MongoDB driver 3.x."
:url "https://github.com/gnarroway/mongo-driver-3"
:license {:name "The MIT License"
:url "http://opensource.org/licenses/mit-license.php"
:distribution :repo}
:deploy-repositories [["clojars" {:url "https://clojars.org/repo"
:username :env/clojars_user
:password :env/clojars_pass
:sign-releases false}]]
:plugins [[lein-cljfmt "0.6.4"]]
:profiles {:dev {:dependencies [[org.clojure/clojure "1.10.1"]
[org.mongodb/mongodb-driver-sync "3.11.0"]]}})

View file

@ -0,0 +1,42 @@
(ns mongo-driver-3.client
(:refer-clojure :exclude [find])
(:import (com.mongodb.client MongoClients MongoClient)
(com.mongodb ConnectionString)))
;;; Core
(defn create
"Creates a connection to a MongoDB
`connection-string` is a mongo connection string, e.g. mongodb://localhost:27107
If a connecting string is not passed in, it will connect to the default localhost instance."
([] (MongoClients/create))
([^String connection-string]
(MongoClients/create connection-string)))
(defn get-db
"Gets a database by name
`client` is a MongoClient, e.g. resulting from calling `connect`
`name` is the name of the database to get."
[^MongoClient client ^String name]
(.getDatabase client name))
(defn close
"Close a MongoClient and release all resources"
[^MongoClient client]
(.close client))
;;; Utility
(defn connect-to-db
"Connects to a MongoDB database using a URI, returning the client and database as a map with :client and :db.
This is useful to get a db from a single call, instead of having to create a client and get a db manually."
[connection-string]
(let [uri (ConnectionString. connection-string)
client (MongoClients/create uri)]
(if-let [db-name (.getDatabase uri)]
{:client client :db (.getDatabase client db-name)}
(throw (IllegalArgumentException. "No database name specified in URI. connect-to-db requires database to be explicitly configured.")))))

View file

@ -0,0 +1,706 @@
(ns mongo-driver-3.collection
(:refer-clojure :exclude [find empty? drop])
(:import (clojure.lang Ratio Keyword Named IPersistentMap)
(com.mongodb ReadConcern ReadPreference WriteConcern MongoNamespace)
(com.mongodb.client MongoDatabase MongoCollection TransactionBody MongoCursor)
(com.mongodb.client.model InsertOneOptions InsertManyOptions DeleteOptions FindOneAndUpdateOptions ReturnDocument FindOneAndReplaceOptions CountOptions CreateCollectionOptions RenameCollectionOptions IndexOptions IndexModel UpdateOptions ReplaceOptions)
(java.util List Collection)
(java.util.concurrent TimeUnit)
(org.bson Document)
(org.bson.types Decimal128)))
;;; Conversions
(defprotocol ConvertToDocument
(^Document document [input] "Convert some clojure to a Mongo Document"))
(extend-protocol ConvertToDocument
nil
(document [_]
nil)
Ratio
(document [^Ratio input]
(double input))
Keyword
(document [^Keyword input]
(.getName input))
Named
(document [^Named input]
(.getName input))
IPersistentMap
(document [^IPersistentMap input]
(let [o (Document.)]
(doseq [[k v] input]
(.append o (document k) (document v)))
o))
Collection
(document [^Collection input]
(map document input))
Object
(document [input]
input))
(defprotocol ConvertFromDocument
(from-document [input keywordize] "Converts given Document to Clojure"))
(extend-protocol ConvertFromDocument
nil
(from-document [input _]
input)
Object
(from-document [input _] input)
Decimal128
(from-document [^Decimal128 input _]
(.bigDecimalValue input))
List
(from-document [^List input keywordize]
(vec (map #(from-document % keywordize) input)))
Document
(from-document [^Document input keywordize]
(reduce (if keywordize
(fn [m ^String k]
(assoc m (keyword k) (from-document (.get input k) true)))
(fn [m ^String k]
(assoc m k (from-document (.get input k) false))))
{} (.keySet input))))
;;; Collection
(def kw->ReadConcern
{:available (ReadConcern/AVAILABLE)
:default (ReadConcern/DEFAULT)
:linearizable (ReadConcern/LINEARIZABLE)
:local (ReadConcern/LOCAL)
:majority (ReadConcern/MAJORITY)
:snapshot (ReadConcern/SNAPSHOT)})
(defn ->ReadConcern
"Coerce `rc` into a ReadConcern if not nil.
Accepts a ReadConcern or kw corresponding to one:
[:available, :default, :linearizable, :local, :majority, :snapshot]
Invalid values will throw an exception."
[rc]
(when rc
(if (instance? ReadConcern rc)
rc
(or (kw->ReadConcern rc) (throw (IllegalArgumentException.
(str "No match for read concern of " (name rc))))))))
(defn ->ReadPreference
"Coerce `rp` into a ReadPreference if not nil.
Accepts a ReadPreference or a kw corresponding to one:
[:primary, :primaryPreferred, :secondary, :secondaryPreferred, :nearest]
Invalid values will throw an exception."
[rp]
(when rp
(if (instance? ReadPreference rp)
rp
(ReadPreference/valueOf (name rp)))))
(defn ->WriteConcern
"Coerce write-concern related options to a WriteConcern.
Accepts an options map:
:write-concern A WriteConcern or kw corresponding to one:
[:acknowledged, :journaled, :majority, :unacknowledged, :w1, :w2, :w3],
defaulting to :acknowledged, if some invalid option is provided.
:write-concern/w an int >= 0, controlling the number of replicas to acknowledge
:write-concern/w-timeout-ms How long to wait for secondaries to acknowledge before failing,
in milliseconds (0 means indefinite).
:write-concern/journal? If true, block until write operations have been committed to the journal."
[{:keys [write-concern write-concern/w write-concern/w-timeout-ms write-concern/journal?]}]
(when (some some? [write-concern w w-timeout-ms journal?])
(let [wc (when write-concern
(if (instance? WriteConcern write-concern)
write-concern
(WriteConcern/valueOf (name write-concern))))]
(-> (or wc (WriteConcern/ACKNOWLEDGED))
(#(if (some? w) (.withW % w) %))
(#(if (some? w-timeout-ms) (.withWTimeout % w-timeout-ms (TimeUnit/MILLISECONDS)) %))
(#(if (some? journal?) (.withJournal % journal?) %))))))
(defn collection
"Coerces `coll` to a MongoCollection with some options.
`db` is a MongoDatabase
`coll` is a collection name or a MongoCollection. This is to provide flexibility in the use of
higher-level fns (e.g. `find-maps`), either in reuse of instances or in some more complex
configuration we do not directly support.
Accepts an options map:
:read-preference
:read-concern
:write-concern
:write-concern/w
:write-concern/w-timeout-ms
:write-concern/journal?
See respective coercion functions for details (->ReadPreference, ->ReadConcern, ->WriteConcern)."
([^MongoDatabase db coll]
(collection db coll {}))
([^MongoDatabase db coll opts]
(let [coll' (if (instance? MongoCollection coll) coll (.getCollection db coll))
{:keys [read-concern read-preference]} opts]
(-> coll'
(#(if-let [rp (->ReadPreference read-preference)] (.withReadPreference % rp) %))
(#(if-let [rc (->ReadConcern read-concern)] (.withReadConcern % rc) %))
(#(if-let [wc (->WriteConcern opts)] (.withWriteConcern % wc) %))))))
;;; CRUD functions
(defn ->CountOptions
"Coerce options map into CountOptions. See `count-documents` for usage."
[{:keys [count-options hint limit max-time-ms skip]}]
(let [opts (or count-options (CountOptions.))]
(when hint (.hint opts (document hint)))
(when limit (.limit opts limit))
(when max-time-ms (.maxTime opts max-time-ms (TimeUnit/MILLISECONDS)))
(when skip (.skip opts skip))
opts))
(defn count-documents
"Count documents in a collection, optionally matching a filter query `q`.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query
Takes an options map:
-- query options
:hint an index name (string) hint or specification (map)
:max-time-ms max amount of time to allow the query to run, in milliseconds
:skip number of documents to skip before counting
:limit max number of documents to count
:count-options a CountOptions, for configuring directly. If specified, any
other query options will be applied to it.
-- other options
:session a ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll]
(.countDocuments (collection db coll {})))
([^MongoDatabase db coll q]
(count-documents db coll q {}))
([^MongoDatabase db coll q opts]
(let [opts' (->CountOptions opts)]
(if-let [session (:session opts)]
(.countDocuments (collection db coll opts) session (document q) opts')
(.countDocuments (collection db coll opts) (document q) opts')))))
(defn ->DeleteOptions
"Coerce options map into DeleteOptions. See `delete-one` and `delete-many` for usage."
[{:keys [delete-options]}]
(let [opts (or delete-options (DeleteOptions.))]
opts))
(defn delete-one
"Deletes a single document from a collection and returns a DeletedResult.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to match documents to delete.
Takes an options map:
-- query options
:delete-options a DeleteOptions for configuring directly.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll q]
(delete-one db coll q {}))
([^MongoDatabase db coll q opts]
(if-let [session (:session opts)]
(.deleteOne (collection db coll opts) session (document q) (->DeleteOptions opts))
(.deleteOne (collection db coll opts) (document q) (->DeleteOptions opts)))))
(defn delete-many
"Deletes multiple documents from a collection and returns a DeleteResult.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to match documents to delete.
Takes an options map:
-- query options
:delete-options A DeleteOptions for configuring directly.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`"
([^MongoDatabase db coll q]
(delete-many db coll q {}))
([^MongoDatabase db coll q opts]
(if-let [session (:session opts)]
(.deleteMany (collection db coll opts) session (document q) (->DeleteOptions opts))
(.deleteMany (collection db coll opts) (document q) (->DeleteOptions opts)))))
(defn find
"Finds documents and returns a FindIterable.
This is a low level function that returns the result directly from the underling driver.
Use `find-maps` to do some post-processing, e.g. returning a lazy seq
of clojure maps with keyword keys.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query.
Takes an options map:
:limit Number of results, e.g. 1
:sort document representing sort order, e.g. {:timestamp -1}
:projection document representing fields to return, e.g. {:_id 0}"
([^MongoDatabase db coll q]
(find db coll q {}))
([^MongoDatabase db coll q opts]
(let [{:keys [limit sort projection session]} opts]
(-> (if session
(.find (collection db coll opts) session (document q))
(.find (collection db coll opts) (document q)))
(#(if limit (.limit % limit) %))
(#(if sort (.sort % (document sort)) %))
(#(if projection (.projection % (document projection)) %))))))
(defn find-maps
"Finds documents and returns them as a clojure seq of maps.
Takes the same options as `find`, as well as:
:keywordize keywordize the keys of returns results, default: true
:session a ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll q]
(find-maps db coll q {}))
([^MongoDatabase db coll q opts]
(let [{:keys [keywordize] :or {keywordize true}} opts]
(with-open [^MongoCursor iterator (.iterator (find db coll q opts))]
(doall (map (fn [x] (from-document x keywordize)) (iterator-seq iterator)))))))
(defn find-one-as-map
"Finds a single document and returns it as a clojure map, or nil if not found.
Takes the same options as `find`, as well as:
:keywordize keywordize the keys of returns results, default: true
:session a ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll q]
(find-one-as-map db coll q {}))
([^MongoDatabase db coll q opts]
(let [{:keys [keywordize] :or {keywordize true}} opts]
(-> (find db coll q opts)
(.first)
(from-document keywordize)))))
(defn ->FindOneAndUpdateOptions
"Coerce options map into FindOneAndUpdateOptions. See `find-one-and-update` for usage."
[{:keys [find-one-and-update-options upsert? return-new? sort projection]}]
(let [opts (or find-one-and-update-options (FindOneAndUpdateOptions.))]
(when (some? upsert?) (.upsert opts upsert?))
(when return-new? (.returnDocument opts (ReturnDocument/AFTER)))
(when sort (.sort opts (document sort)))
(when projection (.projection opts (document projection)))
opts))
(defn find-one-and-update
"Atomically find a document (at most one) and modify it.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to find the document to update
`update` is a map representing an update. The update to apply must include only update operators.
Takes an options map:
-- query options
:upsert? whether to insert a new document if nothing is found, default: false
:return-new? whether to return the document after update (insead of its state before the update), default: false
:sort map representing sort order, e.g. {:timestamp -1}
:projection map representing fields to return, e.g. {:_id 0}
:find-one-and-update-options A FindOneAndUpdateOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:keywordize keywordize the keys of returns results, default: true
:session a ClientSession
Additionally takes options specified in `collection`"
([^MongoDatabase db coll q update]
(find-one-and-update db coll q update {}))
([^MongoDatabase db coll q update opts]
(let [{:keys [keywordize session] :or {keywordize true}} opts
opts' (->FindOneAndUpdateOptions opts)]
(-> (if session
(.findOneAndUpdate (collection db coll opts) session (document q) (document update) opts')
(.findOneAndUpdate (collection db coll opts) (document q) (document update) opts'))
(from-document keywordize)))))
(defn ->FindOneAndReplaceOptions
"Coerce options map into FindOneAndReplaceOptions. See `find-one-and-replace` for usage."
[{:keys [find-one-and-replace-options upsert? return-new? sort projection]}]
(let [opts (or find-one-and-replace-options (FindOneAndReplaceOptions.))]
(when (some? upsert?) (.upsert opts upsert?))
(when return-new? (.returnDocument opts (ReturnDocument/AFTER)))
(when sort (.sort opts (document sort)))
(when projection (.projection opts (document projection)))
opts))
(defn find-one-and-replace
"Atomically find a document (at most one) and replace it.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to find the document to update
`doc` is a new document to add.
Takes an options map:
-- query options
:upsert? whether to insert a new document if nothing is found, default: false
:return-new? whether to return the document after update (insead of its state before the update), default: false
:sort map representing sort order, e.g. {:timestamp -1}
:projection map representing fields to return, e.g. {:_id 0}
:find-one-and-replace-options A FindOneAndReplaceOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:keywordize keywordize the keys of returns results, default: true
:session a ClientSession
Additionally takes options specified in `collection`"
([^MongoDatabase db coll q doc]
(find-one-and-replace db coll q doc {}))
([^MongoDatabase db coll q doc opts]
(let [{:keys [keywordize session] :or {keywordize true}} opts
opts' (->FindOneAndReplaceOptions opts)]
(-> (if session
(.findOneAndReplace (collection db coll opts) session (document q) (document doc) opts')
(.findOneAndReplace (collection db coll opts) (document q) (document doc) opts'))
(from-document keywordize)))))
(defn ->InsertOneOptions
"Coerce options map into InsertOneOptions. See `insert-one` for usage."
[{:keys [insert-one-options bypass-document-validation?]}]
(let [opts (or insert-one-options (InsertOneOptions.))]
(when (some? bypass-document-validation?) (.bypassDocumentValidation opts bypass-document-validation?))
opts))
(defn insert-one
"Inserts a single document into a collection.
If the document does not have an _id field, it will be auto-generated by the underlying driver.
`db` is a MongoDatabase
`coll` is a collection name
`doc` is a map to insert
Takes an options map:
-- query options
:bypass-document-validation? Boolean
:insert-one-options An InsertOneOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll doc]
(insert-one db coll doc {}))
([^MongoDatabase db coll doc opts]
(let [opts' (->InsertOneOptions opts)]
(if-let [session (:session opts)]
(.insertOne (collection db coll opts) session (document doc) opts')
(.insertOne (collection db coll opts) (document doc) opts')))))
(defn ->InsertManyOptions
"Coerce options map into InsertManyOptions. See `insert-many` for usage."
[{:keys [insert-many-options bypass-document-validation? ordered?]}]
(let [opts (or insert-many-options (InsertManyOptions.))]
(when (some? bypass-document-validation?) (.bypassDocumentValidation opts bypass-document-validation?))
(when (some? ordered?) (.ordered opts ordered?))
opts))
(defn insert-many
"Inserts multiple documents into a collection.
If a document does not have an _id field, it will be auto-generated by the underlying driver.
`db` is a MongoDatabase
`coll` is a collection name
`docs` is a collection of maps to insert
Takes an options map:
-- query options
:bypass-document-valiation? Boolean
:ordered? Boolean whether serve should insert documents in order provided (default true)
:insert-many-options An InsertManyOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`"
([^MongoDatabase db coll docs]
(insert-many db coll docs {}))
([^MongoDatabase db coll docs opts]
(let [opts' (->InsertManyOptions opts)]
(if-let [session (:session opts)]
(.insertMany (collection db coll opts) session (map document docs) opts')
(.insertMany (collection db coll opts) (map document docs) opts')))))
(defn ->ReplaceOptions
"Coerce options map into ReplaceOptions. See `replace-one` and `replace-many` for usage."
[{:keys [replace-options upsert? bypass-document-validation?]}]
(let [opts (or replace-options (ReplaceOptions.))]
(when (some? upsert?) (.upsert opts upsert?))
(when (some? bypass-document-validation?) (.bypassDocumentValidation opts bypass-document-validation?))
opts))
(defn replace-one
"Replace a single document.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to find the document to update
`doc` is a new document to add.
Takes an options map:
-- query options
:upsert? whether to insert a new document if nothing is found, default: false
:bypass-document-validation?
:eplace-options A ReplaceOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:session a ClientSession
Additionally takes options specified in `collection`"
([^MongoDatabase db coll q doc]
(find-one-and-replace db coll q doc {}))
([^MongoDatabase db coll q doc opts]
(if-let [session (:session opts)]
(.replaceOne (collection db coll opts) session (document q) (document doc) (->ReplaceOptions opts))
(.replaceOne (collection db coll opts) (document q) (document doc) (->ReplaceOptions opts)))))
(defn ->UpdateOptions
"Coerce options map into UpdateOptions. See `updatee-one` and `update-many` for usage."
[{:keys [update-options upsert? bypass-document-validation?]}]
(let [opts (or update-options (UpdateOptions.))]
(when (some? upsert?) (.upsert opts upsert?))
(when (some? bypass-document-validation?) (.bypassDocumentValidation opts bypass-document-validation?))
opts))
(defn update-one
"Updates a single document from a collection and returns a UpdateResult.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to match documents to update.
`update` is a map representing an update. The update to apply must include only update operators.
Takes an options map:
-- query options
:upsert? Boolean whether to insert a new document if there are no matches to the query
:bypass-document-validation? Boolean
:update-options an UpdateOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll q update]
(update-one db coll q update {}))
([^MongoDatabase db coll q update opts]
(if-let [session (:session opts)]
(.updateOne (collection db coll opts) session (document q) (document update) (->UpdateOptions opts))
(.updateOne (collection db coll opts) (document q) (document update) (->UpdateOptions opts)))))
(defn update-many
"Updates many documents from a collection and returns a UpdateResult.
`db` is a MongoDatabase
`coll` is a collection name
`q` is a map representing a query to match documents to update.
`update` is a map representing an update. The update to apply must include only update operators.
Takes an options map:
-- query options
:upsert? Boolean whether to insert a new document if there are no matches to the query
:bypass-document-validation? Boolean
:update-options an UpdateOptions for configuring directly. If specified,
any other query options will be applied to it.
-- other options
:session A ClientSession
Additionally takes options specified in `collection`."
([^MongoDatabase db coll q update]
(update-many db coll q {}))
([^MongoDatabase db coll q update opts]
(if-let [session (:session opts)]
(.updateMany (collection db coll opts) session (document q) (document update) (->UpdateOptions opts))
(.updateMany (collection db coll opts) (document q) (document update) (->UpdateOptions opts)))))
;;; Admin functions
(defn ->CreateCollectionOptions
"Coerce options map into CreateCollectionOptions. See `create` usage."
[{:keys [create-collection-options capped? max-documents max-size-bytes]}]
(let [opts (or create-collection-options (CreateCollectionOptions.))]
(when (some? capped?) (.capped opts capped?))
(when max-documents (.maxDocuments opts max-documents))
(when max-size-bytes (.sizeInBytes opts max-size-bytes))
opts))
(defn create
"Creates a collection
Takes an options map:
-- query options
:capped? Boolean whether to create a capped collection
:max-documents max documents for a capped collection
:max-size-bytes max collection size in bytes for a capped collection
:create-collection-options A CreateCollectionOptions for configuring directly. If specified,
any other query options will be applied to it"
([^MongoDatabase db coll]
(create db coll {}))
([^MongoDatabase db coll opts]
(let [opts' (->CreateCollectionOptions opts)]
(.createCollection db coll opts'))))
(defn rename
"Renames `coll` to `new-coll` in the same DB."
[^MongoDatabase db coll new-coll opts]
(let [{:keys [drop-target?]} opts
opts' (RenameCollectionOptions.)]
(when drop-target? (.dropTarget opts' true))
(.renameCollection (collection db coll opts)
(MongoNamespace. (.getName db) new-coll)
opts')))
(defn drop
"Drops a collection from a database."
[^MongoDatabase db coll]
(.drop (collection db coll)))
(defn ->IndexOptions
"Coerces an options map into an IndexOptions.
See `create-index` for usage"
[{:keys [index-options name sparse? unique?]}]
(let [opts (or index-options (IndexOptions.))]
(when name (.name opts name))
(when (some? sparse?) (.sparse opts sparse?))
(when (some? unique?) (.unique opts unique?))
opts))
(defn create-index
"Creates an index
`db` is a MongoDatabase
`coll` is a collection name
`keys` is a document representing index keys, e.g. {:a 1}
Takes an options map:
-- query options
:name
:sparse?
:unique?
:index-options An IndexOptions for configuring directly. If specified,
any other query options will be applied to it"
([^MongoDatabase db coll keys]
(create-index db coll keys {}))
([^MongoDatabase db coll keys opts]
(.createIndex (collection db coll opts) (document keys) (->IndexOptions opts))))
(defn create-indexes
"Creates many indexes.
`db` is a MongoDatabase
`coll` is a collection name
`indexes` is a collection of maps with the following attributes:
-- required
:keys a document representing index keys, e.g. {:a 1}
-- optional
any attributes available in `->IndexOptions`"
[^MongoDatabase db coll indexes]
(->> indexes
(map (fn [x] (IndexModel. (document (:keys x)) (->IndexOptions x))))
(.createIndexes (collection db coll))))
(defn list-indexes
"Lists indexes."
[^MongoDatabase db coll]
(->> (.listIndexes (collection db coll))
(map #(from-document % true))))
;;; Utility functions
(defn empty?
"Returns true if a collection is empty"
[^MongoDatabase db coll]
(zero? (count-documents db coll)))
(defn exists?
"Returns true if collection exists"
[^MongoDatabase db coll]
(some #(= coll %) (.listCollectionNames db)))
(defn with-transaction
"Executes `body` in a transaction.
`body` should be a fn with one or more mongo operations in it.
Ensure `session` is passed as an option to each operation.
e.g.
(def s (.startSession conn))
(with-transaction s
(fn []
(insert-one my-db \"coll\" {:name \"hello\"} {:session s})
(insert-one my-db \"coll\" {:name \"world\"} {:session s})))"
[session body]
(.withTransaction session (reify TransactionBody
(execute [_] body))))

View file

@ -0,0 +1,21 @@
(ns mongo-driver-3.client-test
(:require [clojure.test :refer :all]
[mongo-driver-3.client :as mg])
(:import (com.mongodb.client MongoClient MongoDatabase)))
;;; Integration
; docker run -it --rm -p 27017:27017 mongo:4.2
(def mongo-host (or (System/getenv "MONGO_HOST") "mongodb://localhost:27017"))
(deftest test-create
(is (instance? MongoClient (mg/create)))
(is (instance? MongoClient (mg/create mongo-host))))
(deftest test-connect-to-db
(is (thrown? IllegalArgumentException (mg/connect-to-db mongo-host)))
(let [res (mg/connect-to-db (str mongo-host "/my-db"))]
(is (instance? MongoClient (:client res)))
(is (instance? MongoDatabase (:db res)))
(is (= "my-db" (.getName (:db res))))))

View file

@ -0,0 +1,462 @@
(ns mongo-driver-3.collection-test
(:require [clojure.test :refer :all]
[mongo-driver-3.client :as mg]
[mongo-driver-3.collection :as mc])
(:import (com.mongodb ReadConcern ReadPreference WriteConcern)
(java.util.concurrent TimeUnit)
(com.mongodb.client.model InsertOneOptions InsertManyOptions DeleteOptions FindOneAndUpdateOptions ReturnDocument FindOneAndReplaceOptions CountOptions UpdateOptions ReplaceOptions IndexOptions CreateCollectionOptions)
(java.time ZoneId LocalDate LocalTime LocalDateTime)
(java.util Date UUID)))
;;; Unit
(deftest test->ReadConcern
(is (nil? (mc/->ReadConcern nil)))
(is (thrown? IllegalArgumentException (mc/->ReadConcern "invalid")))
(is (instance? ReadConcern (mc/->ReadConcern :available))))
(deftest test->ReadPreference
(is (nil? (mc/->ReadPreference nil)))
(is (thrown? IllegalArgumentException (mc/->ReadPreference "invalid")))
(is (instance? ReadPreference (mc/->ReadPreference :primary))))
(deftest test->WriteConcern
(is (= (WriteConcern/W1) (mc/->WriteConcern {:write-concern :w1})) "accepts kw")
(is (= (WriteConcern/W1) (mc/->WriteConcern {:write-concern (WriteConcern/W1)})) "accepts WriteConcern")
(is (= (WriteConcern/ACKNOWLEDGED) (mc/->WriteConcern {:write-concern "invalid"})) "defaults to acknowledged")
(is (= 1 (.getW (mc/->WriteConcern {:write-concern/w 1}))) "set w")
(is (= 2 (.getW (mc/->WriteConcern {:write-concern (WriteConcern/W2)}))))
(is (= 1 (.getW (mc/->WriteConcern {:write-concern (WriteConcern/W2) :write-concern/w 1}))) "prefer granular option")
(is (true? (.getJournal (mc/->WriteConcern {:write-concern/journal? true}))) "can set journal")
(is (= 77 (.getWTimeout (mc/->WriteConcern {:write-concern/w-timeout-ms 77}) (TimeUnit/MILLISECONDS))) "can set timeout"))
(deftest test->InsertOneOptions
(is (instance? InsertOneOptions (mc/->InsertOneOptions {})))
(is (true? (.getBypassDocumentValidation (mc/->InsertOneOptions {:bypass-document-validation? true}))))
(is (true? (.getBypassDocumentValidation (mc/->InsertOneOptions
{:insert-one-options (.bypassDocumentValidation (InsertOneOptions.) true)})))
"configure directly")
(is (false? (.getBypassDocumentValidation (mc/->InsertOneOptions
{:insert-one-options (.bypassDocumentValidation (InsertOneOptions.) true)
:bypass-document-validation? false})))
"can override"))
(deftest test->ReplaceOptions
(is (instance? ReplaceOptions (mc/->ReplaceOptions {})))
(are [expected arg]
(= expected (.isUpsert (mc/->ReplaceOptions {:upsert? arg})))
true true
false false
false nil) (is (true? (.getBypassDocumentValidation (mc/->ReplaceOptions {:bypass-document-validation? true}))))
(is (true? (.getBypassDocumentValidation (mc/->ReplaceOptions
{:replace-options (.bypassDocumentValidation (ReplaceOptions.) true)})))
"configure directly")
(is (false? (.getBypassDocumentValidation (mc/->ReplaceOptions
{:replace-options (.bypassDocumentValidation (ReplaceOptions.) true)
:bypass-document-validation? false})))
"can override"))
(deftest test->UpdateOptions
(is (instance? UpdateOptions (mc/->UpdateOptions {})))
(are [expected arg]
(= expected (.isUpsert (mc/->UpdateOptions {:upsert? arg})))
true true
false false
false nil)
(is (true? (.getBypassDocumentValidation (mc/->UpdateOptions {:bypass-document-validation? true}))))
(is (true? (.getBypassDocumentValidation (mc/->UpdateOptions
{:update-options (.bypassDocumentValidation (UpdateOptions.) true)})))
"configure directly")
(is (false? (.getBypassDocumentValidation (mc/->UpdateOptions
{:update-options (.bypassDocumentValidation (UpdateOptions.) true)
:bypass-document-validation? false})))
"can override"))
(deftest test->InsertManyOptions
(is (instance? InsertManyOptions (mc/->InsertManyOptions {})))
(are [expected arg]
(= expected (.getBypassDocumentValidation (mc/->InsertManyOptions {:bypass-document-validation? arg})))
true true
false false
nil nil)
(are [expected arg]
(= expected (.isOrdered (mc/->InsertManyOptions {:ordered? arg})))
true true
false false
true nil)
(is (true? (.getBypassDocumentValidation (mc/->InsertManyOptions
{:insert-many-options (.bypassDocumentValidation (InsertManyOptions.) true)})))
"configure directly")
(is (false? (.getBypassDocumentValidation (mc/->InsertManyOptions
{:insert-one-options (.bypassDocumentValidation (InsertManyOptions.) true)
:bypass-document-validation? false})))
"can override"))
(deftest test->DeleteOptions
(is (instance? DeleteOptions (mc/->DeleteOptions {})))
(let [opts (DeleteOptions.)]
(is (= opts (mc/->DeleteOptions {:delete-options opts})) "configure directly")))
(deftest test->FindOneAndUpdateOptions
(is (instance? FindOneAndUpdateOptions (mc/->FindOneAndUpdateOptions {})))
(let [opts (FindOneAndUpdateOptions.)]
(is (= opts (mc/->FindOneAndUpdateOptions {:find-one-and-update-options opts})) "configure directly"))
(are [expected arg]
(= expected (.isUpsert (mc/->FindOneAndUpdateOptions {:upsert? arg})))
true true
false false
false nil)
(are [expected arg]
(= expected (.getReturnDocument (mc/->FindOneAndUpdateOptions {:return-new? arg})))
(ReturnDocument/AFTER) true
(ReturnDocument/BEFORE) false
(ReturnDocument/BEFORE) nil)
(is (= {"_id" 1} (.getSort (mc/->FindOneAndUpdateOptions {:sort {:_id 1}}))))
(is (= {"_id" 1} (.getProjection (mc/->FindOneAndUpdateOptions {:projection {:_id 1}})))))
(deftest test->FindOneAndReplaceOptions
(is (instance? FindOneAndReplaceOptions (mc/->FindOneAndReplaceOptions {})))
(let [opts (FindOneAndReplaceOptions.)]
(is (= opts (mc/->FindOneAndReplaceOptions {:find-one-and-replace-options opts})) "configure directly"))
(are [expected arg]
(= expected (.isUpsert (mc/->FindOneAndReplaceOptions {:upsert? arg})))
true true
false false
false nil)
(are [expected arg]
(= expected (.getReturnDocument (mc/->FindOneAndReplaceOptions {:return-new? arg})))
(ReturnDocument/AFTER) true
(ReturnDocument/BEFORE) false
(ReturnDocument/BEFORE) nil)
(is (= {"_id" 1} (.getSort (mc/->FindOneAndReplaceOptions {:sort {:_id 1}}))))
(is (= {"_id" 1} (.getProjection (mc/->FindOneAndReplaceOptions {:projection {:_id 1}})))))
(deftest test->CountOptions
(is (instance? CountOptions (mc/->CountOptions {})))
(let [opts (CountOptions.)]
(is (= opts (mc/->CountOptions {:count-options opts})) "configure directly"))
(is (= {"a" 1} (.getHint (mc/->CountOptions {:hint {:a 1}}))))
(is (= 7 (.getLimit (mc/->CountOptions {:limit 7}))))
(is (= 2 (.getSkip (mc/->CountOptions {:skip 2}))))
(is (= 42 (.getMaxTime (mc/->CountOptions {:max-time-ms 42}) (TimeUnit/MILLISECONDS)))))
(deftest test->IndexOptions
(is (instance? IndexOptions (mc/->IndexOptions {})))
(are [expected arg]
(= expected (.isSparse (mc/->IndexOptions {:sparse? arg})))
true true
false false
false nil)
(are [expected arg]
(= expected (.isUnique (mc/->IndexOptions {:unique? arg})))
true true
false false
false nil)
(let [opts (IndexOptions.)]
(is (= opts (mc/->IndexOptions {:index-options opts})) "configure directly")))
(deftest test->CreateCollectionOptions
(are [expected arg]
(= expected (.isCapped (mc/->CreateCollectionOptions {:capped? arg})))
true true
false false
false nil)
(is (= 7 (.getMaxDocuments (mc/->CreateCollectionOptions {:max-documents 7}))))
(is (= 42 (.getSizeInBytes (mc/->CreateCollectionOptions {:max-size-bytes 42}))))
(let [opts (-> (CreateCollectionOptions.) (.maxDocuments 5))]
(is (= opts (mc/->CreateCollectionOptions {:create-collection-options opts})) "configure directly")
(is (= 5 (.getMaxDocuments (mc/->CreateCollectionOptions {:create-collection-options opts}))))
(is (= 7 (.getMaxDocuments (mc/->CreateCollectionOptions {:create-collection-options opts :max-documents 7})))
"can override")))
;;; Integration
; docker run -it --rm -p 27017:27017 mongo:4.2
(def client (atom nil))
(defn- setup-connections [f]
(reset! client (mg/create))
(f)
(mg/close @client))
(use-fixtures :once setup-connections)
(defn new-db
[client]
(mg/get-db client (.toString (UUID/randomUUID))))
(deftest ^:integration test-insert-one
(testing "basic insertion"
(let [db (new-db @client)
doc {:hello "world"}
_ (mc/insert-one db "test" doc)
res (mc/find-maps db "test" {})]
(is (= 1 (count res)))
(is (= doc (select-keys (first res) [:hello])))))
(testing "conversion round trip"
(let [db (new-db @client)
doc {:nil nil
:string "string"
:int 1
:float 1.1
:ratio 1/2
:list ["moo" "cow"]
:set #{1 2}
:map {:moo "cow"}
:kw :keyword
:bool true
:date (Date.)
:localdate (LocalDate/now)
:localdatetime (LocalDateTime/now)
:localtime (LocalTime/now)}
_ (mc/insert-one db "test" doc)
res (mc/find-one-as-map db "test" {} {:projection {:_id 0}})]
(is (= {:nil nil
:string "string"
:int 1
:float 1.1
:ratio 0.5
:list ["moo" "cow"]
:set [1 2]
:map {:moo "cow"}
:kw "keyword"
:bool true
:date (:date doc)
:localdate (Date/from (.toInstant (.atStartOfDay (:localdate doc) (ZoneId/of "UTC"))))
:localdatetime (Date/from (.toInstant (.atZone (:localdatetime doc) (ZoneId/of "UTC"))))
:localtime (Date/from (.toInstant (.atZone (LocalDateTime/of (LocalDate/EPOCH) (:localtime doc)) (ZoneId/of "UTC"))))} res)))))
(deftest ^:integration test-insert-many
(testing "basic insertions"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1} {:id 2}])
res (mc/find-maps db "test" {})]
(is (= 2 (count res)))
(is (= [1 2] (map :id res)))))
(testing "no docs"
(let [db (new-db @client)]
(is (thrown? IllegalArgumentException (mc/insert-many db "test" [])))
(is (thrown? IllegalArgumentException (mc/insert-many db "test" nil))))))
(deftest ^:integration test-delete-one
(testing "not exist"
(let [db (new-db @client)
res (mc/delete-one db "test" {:id :a})]
(is (= 0 (.getDeletedCount res)))))
(testing "one at a time"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:v 1} {:v 1}])]
(is (= 1 (.getDeletedCount (mc/delete-one db "test" {:v 1}))))
(is (= 1 (.getDeletedCount (mc/delete-one db "test" {:v 1}))))
(is (= 0 (.getDeletedCount (mc/delete-one db "test" {:v 1})))))))
(deftest ^:integration test-delete-many
(testing "not exist"
(let [db (new-db @client)
res (mc/delete-many db "test" {:id :a})]
(is (= 0 (.getDeletedCount res)))))
(testing "all together"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:v 1} {:v 1}])]
(is (= 2 (.getDeletedCount (mc/delete-many db "test" {:v 1}))))
(is (= 0 (.getDeletedCount (mc/delete-many db "test" {:v 1})))))))
(deftest ^:integration test-find-maps
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :a 1 :v 2} {:id 2 :a 1 :v 3} {:id 3 :v 1}])]
(testing "query"
(are [ids q] (= ids (map :id (mc/find-maps db "test" q)))
[1 2 3] {}
[1] {:id 1}
[1 2] {:a {:$exists true}}
[2] {:v {:$gt 2}}))
(testing "sort"
(are [ids s] (= ids (map :id (mc/find-maps db "test" {} {:sort s})))
[1 2 3] {}
[3 1 2] {:v 1}
[2 1 3] {:v -1}))
(testing "limit"
(are [cnt n] (= cnt (count (mc/find-maps db "test" {} {:limit n})))
1 1
2 2
3 3
3 4))
(testing "projection"
(are [ks p] (= ks (keys (first (mc/find-maps db "test" {} {:projection p}))))
[:_id :id :a :v] {}
[:_id :a] {:a 1}
[:id :a :v] {:_id 0}))))
(deftest ^:integration test-find-one-as-map
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :a 1 :v 2} {:id 2 :a 1 :v 3} {:id 3 :v 1}])]
(testing "query"
(are [id q] (= id (:id (mc/find-one-as-map db "test" q)))
1 {}
2 {:id 2}
1 {:a {:$exists true}}
2 {:v {:$gt 2}}))
(testing "sort"
(are [id s] (= id (:id (mc/find-one-as-map db "test" {} {:sort s})))
1 {}
3 {:v 1}
2 {:v -1}))
(testing "projection"
(are [ks p] (= ks (keys (mc/find-one-as-map db "test" {} {:projection p})))
[:_id :id :a :v] {}
[:_id :a] {:a 1}
[:id :a :v] {:_id 0}))))
(deftest ^:integration test-count-documents
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :a 1 :v 2} {:id 2 :a 1 :v 3} {:id 3 :v 1}])]
(testing "all"
(is (= 3 (mc/count-documents db "test"))))
(testing "query"
(are [id q] (= id (mc/count-documents db "test" q))
3 {}
1 {:id 2}
2 {:a {:$exists true}}
1 {:v {:$gt 2}}))
(testing "skip"
(are [id s] (= id (mc/count-documents db "test" {} {:skip s}))
3 nil
3 0
2 1
1 2
0 3
0 4))
(testing "limit"
(are [id s] (= id (mc/count-documents db "test" {} {:limit s}))
3 nil
3 0
1 1
2 2
3 3
3 4))))
(deftest ^:integration test-find-one-and-update
(testing "return new"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :v 1} {:id 2 :v 1}])]
(is (= {:id 1 :v 1} (dissoc (mc/find-one-and-update db "test" {:id 1} {:$set {:r 1} :$inc {:v 1}}) :_id)))
(is (= {:id 2 :v 2 :r 1} (dissoc (mc/find-one-and-update db "test" {:id 2} {:$set {:r 1} :$inc {:v 1}} {:return-new? true}) :_id)))))
(testing "upsert"
(let [db (new-db @client)]
(is (nil? (dissoc (mc/find-one-and-update db "test" {:id 1} {:$set {:r 1}} {:return-new? true}) :_id)))
(is (= {:id 1 :r 1} (dissoc (mc/find-one-and-update db "test" {:id 1} {:$set {:r 1}} {:return-new? true :upsert? true}) :_id))))))
(deftest ^:integration test-find-one-and-replace
(testing "return new"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :v 1} {:id 2 :v 1}])]
(is (= {:id 1 :v 1} (dissoc (mc/find-one-and-replace db "test" {:id 1} {:id 1 :v 2}) :_id)))
(is (= {:id 2 :v 2} (dissoc (mc/find-one-and-replace db "test" {:id 2} {:id 2 :v 2} {:return-new? true}) :_id)))))
(testing "upsert"
(let [db (new-db @client)]
(is (nil? (dissoc (mc/find-one-and-replace db "test" {:id 1} {:id 1 :v 2} {:return-new? true}) :_id)))
(is (= {:id 1 :v 2} (dissoc (mc/find-one-and-replace db "test" {:id 1} {:id 1 :v 2} {:return-new? true :upsert? true}) :_id))))))
(deftest ^:integration test-replace-one
(testing "existing doc"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :v 1} {:id 2 :v 1}])
r1 (mc/replace-one db "test" {:v 1} {:v 2} {})
r2 (mc/replace-one db "test" {:v 1} {:v 2} {})
r3 (mc/replace-one db "test" {:v 1} {:v 2} {})]
;; replace-one will match at most 1
(is (= 1 (.getMatchedCount r1)))
(is (= 1 (.getModifiedCount r1)))
(is (= 1 (.getMatchedCount r2)))
(is (= 1 (.getModifiedCount r2)))
(is (= 0 (.getMatchedCount r3)))
(is (= 0 (.getModifiedCount r3)))))
(testing "upsert"
(let [db (new-db @client)]
(let [res (mc/replace-one db "test" {:id 1} {:v 2} {})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (nil? (.getUpsertedId res))))
(let [res (mc/replace-one db "test" {:id 1} {:v 2} {:upsert? true})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (some? (.getUpsertedId res)))))))
(deftest ^:integration test-update-one
(testing "existing doc"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :v 1} {:id 2 :v 1}])
r1 (mc/update-one db "test" {:v 1} {:$set {:v 2}} {})
r2 (mc/update-one db "test" {:v 1} {:$set {:v 2}} {})
r3 (mc/update-one db "test" {:v 1} {:$set {:v 2}} {})]
;; update-one will match at most 1
(is (= 1 (.getMatchedCount r1)))
(is (= 1 (.getModifiedCount r1)))
(is (= 1 (.getMatchedCount r2)))
(is (= 1 (.getModifiedCount r2)))
(is (= 0 (.getMatchedCount r3)))
(is (= 0 (.getModifiedCount r3)))))
(testing "upsert"
(let [db (new-db @client)]
(let [res (mc/update-one db "test" {:id 1} {:$set {:r 1}} {})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (nil? (.getUpsertedId res))))
(let [res (mc/update-one db "test" {:id 1} {:$set {:r 1}} {:upsert? true})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (some? (.getUpsertedId res)))))))
(deftest ^:integration test-update-many
(testing "existing doc"
(let [db (new-db @client)
_ (mc/insert-many db "test" [{:id 1 :v 1} {:id 2 :v 1}])
r1 (mc/update-many db "test" {:v 1} {:$set {:v 2}} {})
r2 (mc/update-many db "test" {:v 1} {:$set {:v 2}} {})]
(is (= 2 (.getMatchedCount r1)))
(is (= 2 (.getModifiedCount r1)))
(is (= 0 (.getMatchedCount r2)))
(is (= 0 (.getModifiedCount r2)))))
(testing "upsert"
(let [db (new-db @client)]
(let [res (mc/update-many db "test" {:id 1} {:$set {:r 1}} {})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (nil? (.getUpsertedId res))))
(let [res (mc/update-many db "test" {:id 1} {:$set {:r 1}} {:upsert? true})]
(is (= 0 (.getMatchedCount res)))
(is (= 0 (.getModifiedCount res)))
(is (some? (.getUpsertedId res)))))))