├── .github └── FUNDING.yml ├── src ├── errors.cr ├── moongoon.cr ├── config.cr ├── models │ ├── database │ │ ├── database.cr │ │ ├── methods │ │ │ ├── post.cr │ │ │ ├── delete.cr │ │ │ ├── patch.cr │ │ │ └── get.cr │ │ ├── internal.cr │ │ ├── hooks.cr │ │ ├── indexes.cr │ │ ├── relationships.cr │ │ └── versioning.cr │ └── models.cr └── database │ ├── scripts.cr │ └── database.cr ├── .gitignore ├── .editorconfig ├── shard.yml ├── .travis.yml ├── spec ├── index_spec.cr ├── scripts_spec.cr ├── spec_helper.cr ├── relationships_spec.cr ├── aggregation_spec.cr ├── versioning_spec.cr ├── hooks_spec.cr └── models_spec.cr ├── LICENSE ├── docs ├── Moongoon │ ├── Error.html │ ├── Error │ │ └── NotFound.html │ ├── Database │ │ ├── Scripts.html │ │ └── Scripts │ │ │ └── Base │ │ │ └── Action.html │ ├── Config.html │ └── Database.html ├── Moongoon.html └── css │ └── style.css ├── README.md └── icon.svg /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: elbywan 2 | custom: ["https://www.paypal.me/elbywan"] 3 | -------------------------------------------------------------------------------- /src/errors.cr: -------------------------------------------------------------------------------- 1 | module Moongoon::Error 2 | end 3 | 4 | # Raised when a query fails to retrieve documents. 5 | class Moongoon::Error::NotFound < Exception 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | *.dwarf 5 | /data 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in applications that use them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/moongoon.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "cryomongo" 3 | 4 | require "./config" 5 | require "./errors" 6 | require "./database" 7 | require "./models" 8 | 9 | # Moongoon is a MongoDB object-document mapper library. 10 | module Moongoon 11 | Log = ::Log.for(self) 12 | 13 | extend Moongoon::Database 14 | end 15 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: moongoon 2 | version: 0.7.4 3 | 4 | authors: 5 | - elbywan 6 | 7 | crystal: ">= 0.35.0, < 2.0.0" 8 | 9 | description: | 10 | An object-document mapper (ODM) for MongoDB. 11 | 12 | targets: 13 | moongoon: 14 | main: src/moongoon.cr 15 | 16 | dependencies: 17 | cryomongo: 18 | github: elbywan/cryomongo 19 | version: ~> 0.3.0 20 | 21 | license: MIT 22 | -------------------------------------------------------------------------------- /src/config.cr: -------------------------------------------------------------------------------- 1 | module Moongoon 2 | class Config 3 | @@instance : self = self.new 4 | 5 | def self.reset 6 | @@instance = self.new 7 | end 8 | 9 | def self.singleton 10 | @@instance 11 | end 12 | 13 | @unset_nils : Bool 14 | property unset_nils 15 | 16 | def initialize 17 | @unset_nils = false 18 | end 19 | end 20 | 21 | def self.configure 22 | yield Config.singleton 23 | end 24 | 25 | def self.config 26 | Config.singleton 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: minimal 3 | 4 | env: 5 | matrix: 6 | - MONGODB=3.6.23 7 | - MONGODB=4.0.23 8 | - MONGODB=4.2.13 9 | - MONGODB=4.4.5 10 | 11 | install: 12 | - curl -fsSL https://crystal-lang.org/install.sh | sudo bash 13 | - wget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1804-${MONGODB}.tgz 14 | - tar xzf mongodb-linux-x86_64-ubuntu1804-${MONGODB}.tgz 15 | - ${PWD}/mongodb-linux-x86_64-ubuntu1804-${MONGODB}/bin/mongod --version 16 | 17 | before_script: 18 | - shards install 19 | - mkdir ${PWD}/mongodb-linux-x86_64-ubuntu1804-${MONGODB}/data 20 | - ${PWD}/mongodb-linux-x86_64-ubuntu1804-${MONGODB}/bin/mongod --dbpath ${PWD}/mongodb-linux-x86_64-ubuntu1804-${MONGODB}/data --logpath ${PWD}/mongodb-linux-x86_64-ubuntu1804-${MONGODB}/mongodb.log --fork 21 | 22 | script: 23 | - crystal spec -------------------------------------------------------------------------------- /spec/index_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Moongoon::Collection do 4 | it "should define indexes" do 5 | IndexModel.collection.list_indexes.each_with_index { |index, i| 6 | case i 7 | when 0 8 | index["name"].should eq "_id_" 9 | index["key"].as(BSON).to_json.should eq ({_id: 1}).to_json 10 | when 1 11 | index["name"].should eq "a_desc" 12 | index["key"].as(BSON).to_json.should eq ({a: -1}).to_json 13 | when 2 14 | index["name"].should eq "_id_1_a_1" 15 | index["key"].as(BSON).to_json.should eq ({_id: 1, a: 1}).to_json 16 | index["unique"].should eq true 17 | when 3 18 | index["name"].should eq "_id_1_$**_text" 19 | index["key"].as(BSON).to_json.should eq ({_id: 1, _fts: "text", _ftsx: 1}).to_json 20 | when 4 21 | index["name"].should eq "index_name" 22 | index["key"].as(BSON).to_json.should eq ({b: 1}).to_json 23 | index["unique"].should eq true 24 | end 25 | } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 elbywan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/scripts_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Moongoon::Database::Scripts do 4 | it "should process scripts in order" do 5 | collection = Moongoon.database["scripts_collection"] 6 | collection.count_documents.should eq 1 7 | collection.find_one.not_nil!.["value"].should eq 3 8 | end 9 | 10 | it "should mark as retryable" do 11 | collection = Moongoon.database["scripts"] 12 | scripts = collection.find.to_a 13 | scripts.each { |script| 14 | case script["name"] 15 | when "Moongoon::Database::Scripts::One" 16 | script["status"].should eq "done" 17 | script["retry"].should eq false 18 | script["error"]?.should be_nil 19 | when "Moongoon::Database::Scripts::Two" 20 | script["status"].should eq "error" 21 | script["retry"].should eq true 22 | script["error"].should eq "Error raised" 23 | when "Moongoon::Database::Scripts::Three" 24 | script["status"].should eq "done" 25 | script["retry"].should eq true 26 | script["error"]?.should be_nil 27 | else 28 | raise "Invalid script name." 29 | end 30 | } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/models/database/database.cr: -------------------------------------------------------------------------------- 1 | require "./*" 2 | require "./methods/*" 3 | 4 | # :nodoc: 5 | module Moongoon::Traits 6 | end 7 | 8 | # :nodoc: 9 | module Moongoon::Traits::Database::Full 10 | macro included 11 | include ::Moongoon::Traits::Database::Hooks 12 | include ::Moongoon::Traits::Database::Relationships 13 | include ::Moongoon::Traits::Database::Indexes 14 | include ::Moongoon::Traits::Database::Methods::Get 15 | include ::Moongoon::Traits::Database::Methods::Post 16 | include ::Moongoon::Traits::Database::Methods::Patch 17 | include ::Moongoon::Traits::Database::Methods::Delete 18 | include ::Moongoon::Traits::Database::Internal 19 | end 20 | end 21 | 22 | # :nodoc: 23 | module Moongoon::Traits::Database::Update 24 | macro included 25 | include ::Moongoon::Traits::Database::Hooks 26 | include ::Moongoon::Traits::Database::Relationships 27 | include ::Moongoon::Traits::Database::Indexes 28 | include ::Moongoon::Traits::Database::Methods::Patch 29 | include ::Moongoon::Traits::Database::Internal 30 | end 31 | end 32 | 33 | # :nodoc: 34 | module Moongoon::Traits::Database::Read 35 | macro included 36 | include ::Moongoon::Traits::Database::Hooks 37 | include ::Moongoon::Traits::Database::Relationships 38 | include ::Moongoon::Traits::Database::Indexes 39 | include ::Moongoon::Traits::Database::Methods::Get 40 | include ::Moongoon::Traits::Database::Internal 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /src/models/database/methods/post.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Methods::Post 3 | macro included 4 | 5 | # Inserts this model instance in the database. 6 | # 7 | # The `_id` field is generated during the insertion process. 8 | # 9 | # ``` 10 | # user = User.new name: "John", age: 25 11 | # user.insert 12 | # ``` 13 | def insert(no_hooks = false, **args) : self 14 | self._id = BSON::ObjectId.new 15 | self.class.before_insert_call(self) unless no_hooks 16 | self.class.collection.insert_one(self.to_bson, **args) 17 | self.class.after_insert_call(self) unless no_hooks 18 | self 19 | end 20 | 21 | # Inserts multiple model instances in the database. 22 | # 23 | # The `_id` field is generated during the insertion process. 24 | # 25 | # ``` 26 | # john = User.new name: "John", age: 25 27 | # jane = User.new name: "Jane", age: 22 28 | # User.bulk_insert [john, jane] 29 | # ``` 30 | def self.bulk_insert(self_array : Indexable(self), no_hooks = false, **args) : Indexable(self) 31 | bulk = self.collection.bulk(**args) 32 | self_array.each { |model| 33 | model._id = BSON::ObjectId.new 34 | self.before_insert_call(model) unless no_hooks 35 | bulk.insert_one(model.to_bson) 36 | } 37 | bulk.execute 38 | unless no_hooks 39 | self_array.each { |model| 40 | self.after_insert_call(model) 41 | } 42 | end 43 | self_array 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/moongoon" 3 | 4 | # Define scripts and indexes before connection 5 | class Moongoon::Database::Scripts::One < Moongoon::Database::Scripts::Base 6 | order 1 7 | on_error :discard 8 | 9 | def process(db : Mongo::Database) 10 | db["scripts_collection"].insert_one({value: 1}) 11 | end 12 | end 13 | 14 | class Moongoon::Database::Scripts::Two < Moongoon::Database::Scripts::Base 15 | order 2 16 | on_error :retry 17 | 18 | def process(db : Mongo::Database) 19 | db["scripts_collection"].replace_one({value: 1}, {value: 2}) 20 | raise "Error raised" 21 | end 22 | end 23 | 24 | class Moongoon::Database::Scripts::Three < Moongoon::Database::Scripts::Base 25 | order 3 26 | on_success :retry 27 | 28 | def process(db : Mongo::Database) 29 | db["scripts_collection"].replace_one({value: 2}, {value: 3}) 30 | end 31 | end 32 | 33 | class IndexModel < Moongoon::Collection 34 | collection "index_models" 35 | 36 | property a : String 37 | property b : Int32 38 | 39 | index keys: {a: -1}, name: "a_desc" 40 | index keys: {_id: 1, a: 1}, options: {unique: true} 41 | index keys: {"_id" => 1, "$**" => "text"} 42 | index keys: {"b" => 1}, name: "index_name", options: {"unique" => true} 43 | end 44 | 45 | ::Moongoon.after_connect_before_scripts { 46 | ::Moongoon.database.command(Mongo::Commands::DropDatabase) 47 | } 48 | 49 | if override_url = ENV["MONGO_URL"]? 50 | ::Moongoon.connect( 51 | override_url, 52 | database_name: "moongoon_test" 53 | ) 54 | else 55 | ::Moongoon.connect( 56 | database_name: "moongoon_test" 57 | ) 58 | end 59 | -------------------------------------------------------------------------------- /src/models/database/internal.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Internal 3 | extend self 4 | 5 | # Query builders # 6 | 7 | protected def self.format_aggregation(query, stages, fields = nil, order_by = nil, skip = 0, limit : Int? = nil) 8 | pipeline = query && !query.empty? ? [ 9 | BSON.new({"$match": BSON.new(query)}), 10 | ] : [] of BSON 11 | 12 | stages.each { |stage| 13 | pipeline << BSON.new(stage) 14 | } 15 | if fields 16 | pipeline << BSON.new({"$project": BSON.new(fields)}) 17 | end 18 | if order_by 19 | pipeline << BSON.new({"$sort": BSON.new(order_by)}) 20 | end 21 | if skip > 0 22 | pipeline << BSON.new({"$skip": skip.to_i32}) 23 | end 24 | if (l = limit) && l > 0 25 | pipeline << BSON.new({"$limit": l.to_i32}) 26 | end 27 | pipeline 28 | end 29 | 30 | protected def self.concat_id_filter(query, id : BSON::ObjectId | String | Nil) 31 | BSON.new({"_id": self.bson_id(id)}).append(BSON.new(query)) 32 | end 33 | 34 | protected def self.concat_ids_filter(query, ids : Array(BSON::ObjectId?) | Array(String?)) 35 | BSON.new({ 36 | "_id" => { 37 | "$in" => ids.map { |id| 38 | self.bson_id(id) 39 | }.compact, 40 | }, 41 | }).append(BSON.new(query)) 42 | end 43 | 44 | protected def self.filter_bson(bson : BSON, fields) 45 | filtered_bson = BSON.new 46 | bson.each { |key, value| 47 | if key.in?(fields.to_s) 48 | filtered_bson[key] = value 49 | end 50 | } 51 | filtered_bson 52 | end 53 | 54 | # Validation helpers # 55 | 56 | # Raises if the Model has a nil id field. 57 | private def id_check! 58 | raise ::Moongoon::Error::NotFound.new unless self._id 59 | end 60 | 61 | protected def self.bson_id(id : String | BSON::ObjectId | Nil) 62 | case id 63 | when String 64 | id.blank? ? nil : BSON::ObjectId.new(id) 65 | when BSON::ObjectId 66 | id 67 | when Nil 68 | nil 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /src/models/database/hooks.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | # Adds a combination of hooks called while interacting the database. 3 | module Moongoon::Traits::Database::Hooks 4 | macro included 5 | {% verbatim do %} 6 | macro inherited 7 | # :nodoc: 8 | alias SelfCallback = Proc(self, Nil) 9 | # :nodoc: 10 | alias ClassCallback = Proc(BSON, Nil) 11 | # :nodoc: 12 | alias ClassUpdateCallback = Proc(BSON, BSON, BSON?) 13 | 14 | {% events = %w(insert update remove) %} 15 | {% prefixes = %w(before after) %} 16 | {% suffixes = [nil, "static"] %} 17 | 18 | {% for event in events %} 19 | {% for prefix in prefixes %} 20 | {% for suffix in suffixes %} 21 | {% identifier = prefix + "_" + event + (suffix ? ("_" + suffix) : "") %} 22 | {% callback_type = "SelfCallback" %} 23 | {% if suffix == "static" %} 24 | {% if event == "update" %} 25 | {% callback_type = "ClassUpdateCallback" %} 26 | {% else %} 27 | {% callback_type = "ClassCallback" %} 28 | {% end %} 29 | {% end %} 30 | 31 | {{ identifier.upcase.id }} = [] of {{ callback_type.id }} 32 | 33 | # Registers a hook that will be called **{{prefix.id}}** an **{{event.id}}** operation is performed on a `{{@type}}` instance. 34 | # 35 | # The hook registered the last will run first. 36 | # 37 | # {% if !suffix %}NOTE: This hook will trigger when the `Models::Collection#{{event.id}}` method is called.{% end %} 38 | # {% if suffix == "static" %}NOTE: This hook will trigger when the `Models::Collection.{{event.id}}` method is called.{% end %} 39 | def self.{{identifier.id}}(&cb : {{ callback_type.id }}) 40 | {{ identifier.upcase.id }}.unshift cb 41 | end 42 | 43 | {% if callback_type == "ClassUpdateCallback" %} 44 | protected def self.{{identifier.id}}_call(query, update) 45 | {{ identifier.upcase.id }}.reduce(update) { |update, cb| 46 | cb.call(query, update) || update 47 | } 48 | end 49 | {% else %} 50 | protected def self.{{identifier.id}}_call(*args) 51 | {{ identifier.upcase.id }}.each { |cb| 52 | cb.call(*args) 53 | } 54 | end 55 | {% end %} 56 | {% end %} 57 | {% end %} 58 | {% end %} 59 | end 60 | {% end %} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/models/database/methods/delete.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Methods::Delete 3 | macro included 4 | 5 | # Removes one document having the same id as this model. 6 | # 7 | # Matches on `self.id`. 8 | # 9 | # ``` 10 | # user = User.find_by_id 123456 11 | # user.remove 12 | # ``` 13 | # 14 | # It is possible to add query filters to conditionally prevent removal. 15 | # 16 | # ``` 17 | # # Remove the user only if he/she is named John 18 | # user.remove({ name: "John" }) 19 | # ``` 20 | def remove(query = BSON.new, no_hooks = false, **args) : Mongo::Commands::Common::DeleteResult? 21 | id_check! 22 | full_query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id!) 23 | self.class.before_remove_call(self) unless no_hooks 24 | result = self.class.collection.delete_one(full_query, **args) 25 | @removed = true 26 | self.class.after_remove_call(self) unless no_hooks 27 | result 28 | end 29 | 30 | # Removes one or more documents from the collection. 31 | # 32 | # ``` 33 | # User.remove({ name: { "$in": ["John", "Jane"] }}) 34 | # ``` 35 | def self.remove(query = BSON.new, no_hooks = false, **args) : Mongo::Commands::Common::DeleteResult? 36 | self.before_remove_static_call(BSON.new query) unless no_hooks 37 | result = self.collection.delete_many(query, **args) 38 | self.after_remove_static_call(BSON.new query) unless no_hooks 39 | result 40 | end 41 | 42 | # Removes one document by id. 43 | # 44 | # ``` 45 | # id = 123456 46 | # User.remove_by_id id 47 | # ``` 48 | # 49 | # It is possible to add query filters to conditionally prevent removal. 50 | # 51 | # ``` 52 | # # Remove the user only if he/she is named John 53 | # User.remove id, query: { name: "John" } 54 | # ``` 55 | def self.remove_by_id(id, query = BSON.new, **args) : Mongo::Commands::Common::DeleteResult? 56 | full_query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 57 | remove(full_query) 58 | end 59 | 60 | # Removes one or more documents from the collection by their ids. 61 | # ``` 62 | # ids = ["1", "2", "3"] 63 | # User.remove_by_ids ids 64 | # ``` 65 | # 66 | # It is possible to add query filters to conditionally prevent removal. 67 | # 68 | # ``` 69 | # # Remove the users only if they are named John 70 | # User.remove_by_ids ids , query: { name: "John" } 71 | # ``` 72 | def self.remove_by_ids(ids, query = BSON.new, **args) : Mongo::Commands::Common::DeleteResult? 73 | full_query = ::Moongoon::Traits::Database::Internal.concat_ids_filter(query, ids) 74 | remove(full_query) 75 | end 76 | 77 | # Clears the collection. 78 | # 79 | # NOTE: **Use with caution!** 80 | # 81 | # Will remove all the documents in the collection. 82 | def self.clear : Mongo::Commands::Common::DeleteResult? 83 | self.collection.delete_many(BSON.new) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /src/models/database/indexes.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Indexes 3 | extend self 4 | 5 | @@indexes = Array({BSON, Proc(Tuple(String, String))}).new 6 | 7 | # :nodoc: 8 | def add_index(index : BSON, callback : Proc(Tuple(String, String))) 9 | @@indexes << {index, callback} 10 | end 11 | 12 | ::Moongoon.after_connect do 13 | index_hash = Hash(String, Hash(String, Array(BSON))).new 14 | 15 | @@indexes.each { |index, cb| 16 | database, collection = cb.call 17 | index_hash[database] = Hash(String, Array(BSON)).new unless index_hash[database]? 18 | index_hash[database][collection] = Array(BSON).new unless index_hash[database][collection]? 19 | index_hash[database][collection] << index 20 | } 21 | 22 | index_hash.each { |database, coll_hash| 23 | coll_hash.each { |collection, indexes| 24 | begin 25 | ::Moongoon.connection_with_lock "indexes_#{database}_#{collection}", abort_if_locked: true { |client| 26 | ::Moongoon::Log.info { "Creating indexes for collection #{collection} (db: #{database})." } 27 | client[database][collection].create_indexes(models: indexes) 28 | } 29 | rescue e 30 | ::Moongoon::Log.error { "Error while creating indexes for collection #{collection}.\n#{e}\n#{indexes.to_json}" } 31 | end 32 | } 33 | } 34 | end 35 | 36 | macro included 37 | {% verbatim do %} 38 | 39 | # Defines an index that will be applied to this Model's underlying mongo collection. 40 | # 41 | # **Note that the order of fields do matter.** 42 | # 43 | # If not provided the driver will generate the name of the index from the keys names and order. 44 | # 45 | # Please have a look at the [MongoDB documentation](https://docs.mongodb.com/manual/reference/command/createIndexes/) 46 | # for more details about index creation and the list of available index options. 47 | # 48 | # ``` 49 | # # Specify one or more fields with a type (ascending or descending order, text indexing…) 50 | # index keys: { field1: 1, field2: -1 } 51 | # # Set the unique argument to create a unique index. 52 | # index keys: { field: 1 }, options: { unique: true } 53 | # ``` 54 | def self.index( 55 | keys : NamedTuple, 56 | collection : String? = nil, 57 | database : String? = nil, 58 | options = NamedTuple.new, 59 | name : String? = nil 60 | ) : Nil 61 | index = BSON.new({ 62 | keys: keys, 63 | options: !name ? options : options.merge({ name: name }) 64 | }) 65 | cb = ->{ {(database || self.database_name).not_nil!, (collection || self.collection_name).not_nil!} } 66 | ::Moongoon::Traits::Database::Indexes.add_index(index, cb) 67 | end 68 | 69 | # Same as `self.index` but with hash arguments. 70 | # 71 | # ``` 72 | # index ({ "a" => 1 }), name: "index_name", options: { "unique" => true } 73 | # ``` 74 | def self.index( 75 | keys : Hash(String, BSON::Value), 76 | collection : String? = nil, 77 | database : String? = nil, 78 | options = Hash(String, BSON::Value).new, 79 | name : String? = nil 80 | ) : Nil 81 | index = BSON.new({ 82 | "keys" => keys, 83 | "options" => !name ? options : options.merge({ "name" => name }) 84 | }) 85 | cb = ->{ {(database || self.database_name).not_nil!, (collection || self.collection_name).not_nil!} } 86 | ::Moongoon::Traits::Database::Indexes.add_index(index, cb) 87 | end 88 | 89 | {% end %} 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/relationships_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper.cr" 2 | 3 | private class ParentModel < Moongoon::Collection 4 | collection "parents" 5 | 6 | reference single_child, model: SingleChildModel, delete_cascade: true, clear_reference: true, back_reference: parent_id 7 | reference children, model: ChildrenModel, many: true, delete_cascade: true, clear_reference: true, back_reference: parent_id 8 | end 9 | 10 | private class SingleChildModel < Moongoon::Collection 11 | collection "single_children" 12 | 13 | property parent_id : String 14 | end 15 | 16 | private class ChildrenModel < Moongoon::Collection 17 | collection "children" 18 | 19 | property parent_id : String 20 | end 21 | 22 | describe Moongoon::Traits::Database::Relationships do 23 | it "should back reference another collection" do 24 | parent = ParentModel.new.insert 25 | 26 | parent.single_child.should be_nil 27 | single_child = SingleChildModel.new(parent_id: parent.id!).insert 28 | parent = parent.fetch 29 | parent.single_child.should eq single_child._id.to_s 30 | 31 | parent.children.should eq [] of String 32 | children = 3.times.map { 33 | ChildrenModel.new(parent_id: parent.id!).insert 34 | }.to_a 35 | parent = parent.fetch 36 | parent.children.size.should eq 3 37 | parent.children.each_with_index { |id, i| 38 | id.should eq children[i].id! 39 | } 40 | end 41 | 42 | context "should perform cascading deletes when the reference is" do 43 | it "single" do 44 | parent = ParentModel.new.insert 45 | parent.single_child.should be_nil 46 | single_child = SingleChildModel.new(parent_id: parent.id!).insert 47 | 48 | parent.remove 49 | 50 | expect_raises(Moongoon::Error::NotFound) { 51 | single_child.fetch 52 | } 53 | end 54 | 55 | it "single with a static deletion" do 56 | parent = ParentModel.new.insert 57 | parent.single_child.should be_nil 58 | single_child = SingleChildModel.new(parent_id: parent.id!).insert 59 | 60 | ParentModel.remove_by_id parent.id! 61 | 62 | expect_raises(Moongoon::Error::NotFound) { 63 | single_child.fetch 64 | } 65 | end 66 | 67 | it "many" do 68 | parent = ParentModel.new.insert 69 | children = 3.times.map { 70 | ChildrenModel.new(parent_id: parent.id!).insert 71 | }.to_a 72 | 73 | parent.remove 74 | 75 | children.each { |child| 76 | expect_raises(Moongoon::Error::NotFound) { 77 | child.fetch 78 | } 79 | } 80 | end 81 | 82 | it "many with a static deletion" do 83 | parent = ParentModel.new.insert 84 | children = 3.times.map { 85 | ChildrenModel.new(parent_id: parent.id!).insert 86 | }.to_a 87 | 88 | ParentModel.remove_by_id parent.id! 89 | 90 | children.each { |child| 91 | expect_raises(Moongoon::Error::NotFound) { 92 | child.fetch 93 | } 94 | } 95 | end 96 | end 97 | 98 | context "should clear reference on target deletion when the reference is" do 99 | it "single" do 100 | parent = ParentModel.new.insert 101 | single_child = SingleChildModel.new(parent_id: parent.id!).insert 102 | single_child.remove 103 | parent.fetch.single_child.should be_nil 104 | end 105 | 106 | it "single with a static deletion" do 107 | parent = ParentModel.new.insert 108 | single_child = SingleChildModel.new(parent_id: parent.id!).insert 109 | SingleChildModel.remove_by_id single_child.id! 110 | parent.fetch.single_child.should be_nil 111 | end 112 | 113 | it "many" do 114 | parent = ParentModel.new.insert 115 | children = 3.times.map { 116 | ChildrenModel.new(parent_id: parent.id!).insert 117 | }.to_a 118 | 119 | parent.fetch.children.size.should eq 3 120 | 121 | 2.downto 0 { |i| 122 | children[i].remove 123 | parent.fetch.children.size.should eq i 124 | } 125 | end 126 | 127 | it "many with a static deletion" do 128 | parent = ParentModel.new.insert 129 | children = 3.times.map { 130 | ChildrenModel.new(parent_id: parent.id!).insert 131 | }.to_a 132 | 133 | parent.fetch.children.size.should eq 3 134 | ChildrenModel.remove_by_ids children.map(&.id!) 135 | parent.fetch.children.size.should eq 0 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/aggregation_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper.cr" 2 | 3 | private class AggregatedModel < Moongoon::Collection 4 | collection "aggregated_models" 5 | 6 | property array : Array(Int32)? 7 | property size : Int32? 8 | 9 | aggregation_pipeline( 10 | { 11 | "$addFields": { 12 | size: { 13 | "$size": "$array", 14 | }, 15 | }, 16 | }, 17 | { 18 | "$project": { 19 | array: 0, 20 | }, 21 | } 22 | ) 23 | 24 | def self.insert_models(models) 25 | bulk_insert(models.map { |model| from_json(model.to_json) }) 26 | end 27 | 28 | def format 29 | self.dup.tap { |copy| 30 | copy.size = self.array.try(&.size) 31 | copy.array = nil 32 | } 33 | end 34 | end 35 | 36 | describe Moongoon::Collection do 37 | raw_models = [ 38 | {array: [] of Int32}, 39 | {array: [1, 2, 3]}, 40 | {array: [1, 2, 3]}, 41 | {array: [1]}, 42 | ] 43 | 44 | before_each { 45 | AggregatedModel.clear 46 | } 47 | 48 | describe "Get" do 49 | it "#self.find" do 50 | models = AggregatedModel.insert_models raw_models 51 | 52 | results = AggregatedModel.find({array: [] of Int32}) 53 | results.size.should eq 1 54 | 55 | results = AggregatedModel.find({array: [1, 2, 3]}, order_by: {"_id": 1}) 56 | results.size.should eq 2 57 | results.to_json.should eq [models[1], models[2]].map(&.format).to_json 58 | end 59 | 60 | it "#self.find!" do 61 | models = AggregatedModel.insert_models raw_models 62 | 63 | expect_raises(Moongoon::Error::NotFound) { 64 | AggregatedModel.find!({name: "invalid name"}) 65 | } 66 | results = AggregatedModel.find!({array: [1, 2, 3]}, order_by: {"_id": 1}) 67 | results.to_json.should eq [models[1], models[2]].map(&.format).to_json 68 | end 69 | 70 | it "#self.find_one" do 71 | models = AggregatedModel.insert_models raw_models 72 | 73 | result = AggregatedModel.find_one({array: [1, 2, 3]}, order_by: {"_id": 1}) 74 | result.to_json.should eq models[1].format.to_json 75 | end 76 | 77 | it "#self.find_one!" do 78 | models = AggregatedModel.insert_models raw_models 79 | 80 | expect_raises(Moongoon::Error::NotFound) { 81 | AggregatedModel.find_one!({name: "invalid name"}) 82 | } 83 | 84 | result = AggregatedModel.find_one!({array: [1, 2, 3]}, order_by: {"_id": -1}) 85 | result.to_json.should eq models[2].format.to_json 86 | end 87 | 88 | it "#self.find_by_id" do 89 | models = AggregatedModel.insert_models raw_models 90 | 91 | result = AggregatedModel.find_by_id(models[2].id!) 92 | result.to_json.should eq models[2].format.to_json 93 | end 94 | 95 | it "#self.find_by_id!" do 96 | models = AggregatedModel.insert_models raw_models 97 | 98 | expect_raises(Moongoon::Error::NotFound) { 99 | AggregatedModel.find_by_id!("507f1f77bcf86cd799439011") 100 | } 101 | 102 | result = AggregatedModel.find_by_id!(models[2].id!) 103 | result.to_json.should eq models[2].format.to_json 104 | end 105 | 106 | it "#self.find_by_ids" do 107 | models = AggregatedModel.insert_models raw_models 108 | 109 | results = AggregatedModel.find_by_ids([models[1], models[2]].map(&.id!), order_by: {_id: 1}) 110 | results.to_json.should eq [models[1], models[2]].map(&.format).to_json 111 | end 112 | 113 | it "#self.find_by_ids!" do 114 | models = AggregatedModel.insert_models raw_models 115 | 116 | expect_raises(Moongoon::Error::NotFound) { 117 | AggregatedModel.find_by_ids!(["507f1f77bcf86cd799439011"]) 118 | } 119 | 120 | results = AggregatedModel.find_by_ids!([models[1], models[2]].map(&.id!), order_by: {_id: 1}) 121 | results.to_json.should eq [models[1], models[2]].map(&.format).to_json 122 | end 123 | 124 | it "#self.count" do 125 | AggregatedModel.insert_models raw_models 126 | 127 | count = AggregatedModel.count({array: [1, 2, 3]}) 128 | count.should eq 2 129 | end 130 | 131 | it "#self.exist!" do 132 | AggregatedModel.insert_models raw_models 133 | 134 | expect_raises(Moongoon::Error::NotFound) { 135 | AggregatedModel.exist!({array: [0]}) 136 | } 137 | 138 | AggregatedModel.exist!({array: [1]}).should be_true 139 | end 140 | 141 | it "#self.exist_by_id!" do 142 | models = AggregatedModel.insert_models raw_models 143 | 144 | expect_raises(Moongoon::Error::NotFound) { 145 | AggregatedModel.exist_by_id!("507f1f77bcf86cd799439011") 146 | } 147 | 148 | AggregatedModel.exist_by_id!(models[0].id!).should be_true 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/versioning_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper.cr" 2 | 3 | private class AutoVersionModel < Moongoon::Collection 4 | include Versioning 5 | 6 | collection "model_autoversions" 7 | versioning auto: true, ref_field: "original_id" { |v, o| 8 | self.transform(v, o) 9 | } 10 | 11 | property one : String = "One" 12 | property two : Int64? = nil 13 | property three : Int64? = nil 14 | 15 | def self.transform(versioned_model, original_model) : BSON 16 | if versioned_model.has_key? "two" 17 | versioned_model["three"] = original_model["two"].as(Int64) * 2_i64 18 | end 19 | versioned_model 20 | end 21 | end 22 | 23 | private class VersionModel < Moongoon::Collection 24 | include Versioning 25 | collection "model_versions" 26 | versioning 27 | property key : String = "value" 28 | end 29 | 30 | describe Moongoon::Traits::Database::Versioning do 31 | it "should version documents automatically" do 32 | history_collection = AutoVersionModel.history_collection 33 | model = AutoVersionModel.new 34 | model.count_versions.should eq 0 35 | 36 | # Insertion 37 | model.insert 38 | model.count_versions.should eq 1 39 | version = model.find_latest_version.not_nil! 40 | version.one.should eq "One" 41 | version.two.should be_nil 42 | version.three.should be_nil 43 | bson_version = history_collection.find_one({_id: version._id}).not_nil! 44 | bson_version["original_id"].should eq model._id.to_s 45 | 46 | # Update 47 | model.two = 2 48 | model.update 49 | model.count_versions.should eq 2 50 | version = model.find_latest_version.not_nil! 51 | version.one.should eq "One" 52 | version.two.should eq 2 53 | version.three.should eq 4 54 | 55 | # Static update 56 | AutoVersionModel.update({one: "One"}, {"$set": {two: 3_i64}}) 57 | model.count_versions.should eq 3 58 | version = model.find_latest_version.not_nil! 59 | version.one.should eq "One" 60 | version.two.should eq 3 61 | version.three.should eq 6 62 | end 63 | 64 | context "methods" do 65 | it "#find_latest_version" do 66 | model = VersionModel.new.insert 67 | model.find_latest_version.should be_nil 68 | model.create_version 69 | model.key = "value2" 70 | model.update 71 | model.create_version 72 | version = model.find_latest_version.not_nil! 73 | version.key.should eq "value2" 74 | end 75 | 76 | it "#find_all_versions" do 77 | model = VersionModel.new.insert 78 | model.find_all_versions.should eq [] of VersionModel 79 | 3.times { |i| 80 | model.key = "value#{i}" 81 | model.update 82 | model.create_version 83 | } 84 | versions = model.find_all_versions(order_by: {_id: 1}) 85 | versions.size.should eq 3 86 | versions.each_with_index { |v, i| 87 | v.key.should eq "value#{i}" 88 | } 89 | end 90 | 91 | it "#count_versions" do 92 | model = VersionModel.new.insert 93 | model.count_versions.should eq 0 94 | 3.times { 95 | model.create_version 96 | } 97 | model.count_versions.should eq 3 98 | end 99 | 100 | it "#create_version &block" do 101 | model = VersionModel.new.insert 102 | model.create_version { |version| 103 | version.key += "2" 104 | version 105 | } 106 | model.find_latest_version.not_nil!.key.should eq "value2" 107 | end 108 | 109 | it "#self.find_latest_version_by_id" do 110 | model = VersionModel.new.insert 111 | model.create_version 112 | model.key = "value2" 113 | model.update 114 | model.create_version 115 | 116 | version = VersionModel.find_latest_version_by_id(model.id!).not_nil! 117 | version.key.should eq "value2" 118 | end 119 | 120 | it "#self.find_specific_version" do 121 | model = VersionModel.new.insert 122 | version_id = model.create_version 123 | model.create_version 124 | VersionModel.find_specific_version(version_id).not_nil!.id.should eq version_id 125 | end 126 | 127 | it "#self.find_specific_versions" do 128 | model = VersionModel.new.insert 129 | version_ids = 5.times.map { 130 | model.create_version.not_nil! 131 | }.to_a 132 | versions = VersionModel.find_specific_versions(version_ids[2..], order_by: {_id: 1}) 133 | versions.size.should eq 3 134 | versions.each_with_index { |v, i| 135 | v.id.should eq version_ids[i + 2] 136 | } 137 | end 138 | 139 | it "#self.find_all_versions" do 140 | model = VersionModel.new.insert 141 | version_ids = 5.times.map { 142 | model.create_version.not_nil! 143 | }.to_a 144 | versions = VersionModel.find_all_versions(model.id!, order_by: {_id: 1}) 145 | versions.size.should eq 5 146 | versions.each_with_index { |v, i| 147 | v.id.should eq version_ids[i] 148 | } 149 | end 150 | 151 | it "#self.count_versions" do 152 | model = VersionModel.new.insert 153 | counter = VersionModel.count_versions(model.id!) 154 | 5.times { model.create_version } 155 | VersionModel.count_versions(model.id!).should eq counter + 5 156 | end 157 | 158 | it "#self.clear_history" do 159 | VersionModel.history_collection.count_documents.should_not eq 0 160 | VersionModel.clear_history 161 | VersionModel.history_collection.count_documents.should eq 0 162 | end 163 | 164 | it "#self.create_version_by_id" do 165 | model = VersionModel.new.insert 166 | VersionModel.create_version_by_id(model.id!) { |version| 167 | version.key += "2" 168 | version 169 | } 170 | model.find_latest_version.not_nil!.key.should eq "value2" 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /src/database/scripts.cr: -------------------------------------------------------------------------------- 1 | # This module handles database migration scripts. 2 | module Moongoon::Database::Scripts 3 | SCRIPT_CLASSES = [] of Class 4 | 5 | # Scripts inherit from this class. 6 | # 7 | # ### Example 8 | # 9 | # ``` 10 | # class Moongoon::Database::Scripts::Test < Moongoon::Database::Scripts::Base 11 | # # Scripts run in ascending order. 12 | # # Default order if not specified is 1. 13 | # order Time.utc(2020, 3, 11).to_unix 14 | # 15 | # # Use :retry to retry the script next time moongoon connects if the script raises. 16 | # on_error :discard 17 | # 18 | # def process(db : Mongo::Database) 19 | # # Dummy code that will add a ban flag for users that are called 'John'. 20 | # # This code uses the `cryomongo` syntax, but Models could 21 | # # be used for convenience despite a performance overhead. 22 | # db["users"].update_many( 23 | # filter: {name: "John"}, 24 | # update: {"$set": {"banned": true}}, 25 | # ) 26 | # end 27 | # end 28 | # ``` 29 | # 30 | # ### Usage 31 | # 32 | # **Any class that inherits from `Moongoon::Database::Scripts::Base` will be registered as a script.** 33 | # 34 | # Scripts are run when calling `Moongoon::Database.connect` and after a successful database connection. 35 | # They are run a single time and the outcome is be written in the `scripts` collection. 36 | # 37 | # If multiple instances of the server are started simultaneously they will wait until all the scripts 38 | # are processed before resuming execution. 39 | abstract class Base 40 | # The order in which the scripts are run. 41 | class_property order : Int64 = 1 42 | # The action to perform on failure. 43 | # Set to *:retry* to run the script again the next time the program starts. 44 | class_property on_error : Action = :discard 45 | # The action to perform on success. 46 | # Set to *:retry* to run the script again the next time the program starts. 47 | class_property on_success : Action = :discard 48 | 49 | # Action to perform when a script fails. 50 | enum Action 51 | Discard 52 | Retry 53 | end 54 | 55 | # Will be executed once after a successful database connection and 56 | # if it has never been run against the target database before. 57 | abstract def process(db : Mongo::Database) 58 | 59 | macro inherited 60 | {% verbatim do %} 61 | {% Moongoon::Database::Scripts::SCRIPT_CLASSES << @type %} 62 | 63 | private macro order(nb) 64 | @@order : Int64 = {{nb}} 65 | end 66 | 67 | private macro on_error(action) 68 | @@on_error : Action = {{action}} 69 | end 70 | 71 | private macro on_success(action) 72 | @@on_success : Action = {{action}} 73 | end 74 | 75 | # Process a registered script. 76 | def self.process(db : Mongo::Database) : Nil 77 | script_class_name = {{ @type.stringify }} 78 | script_query = { name: script_class_name } 79 | majority_read_concern = Mongo::ReadConcern.new("majority") 80 | majority_write_concern = Mongo::WriteConcern.new(w: "majority") 81 | 82 | session = db.client.start_session 83 | 84 | script = db["scripts"].find_one( 85 | script_query, 86 | projection: { retry: 1 }, 87 | read_concern: majority_read_concern, 88 | session: session 89 | ) 90 | if script 91 | return unless script.try &.["retry"]? 92 | db["scripts"].delete_one( 93 | script_query, 94 | write_concern: majority_write_concern, 95 | session: session 96 | ) 97 | end 98 | 99 | ::Moongoon::Log.info { "Running script '#{script_class_name}'" } 100 | 101 | db["scripts"].insert_one( 102 | { name: script_class_name, date: Time.utc.to_rfc3339, status: "running" }, 103 | write_concern: majority_write_concern, 104 | session: session 105 | ) 106 | {{ @type }}.new.process(db) 107 | db["scripts"].update_one( 108 | script_query, 109 | { "$set": { status: "done", retry: @@on_success.retry? } }, 110 | write_concern: majority_write_concern, 111 | session: session 112 | ) 113 | rescue e 114 | ::Moongoon::Log.error { "Error while running script '#{script_class_name}'\n#{e.message.to_s}" } 115 | db["scripts"].update_one( 116 | script_query, 117 | { "$set": { status: "error", error: e.message.to_s, retry: @@on_error.retry? }}, 118 | write_concern: majority_write_concern, 119 | session: session 120 | ) 121 | ensure 122 | session.try &.end 123 | end 124 | {% end %} 125 | end 126 | end 127 | 128 | # :nodoc: 129 | # Process every registered script. 130 | # 131 | # For each registered script the following steps will be executed: 132 | # 133 | # - Perform a lookup in the `scripts` collection to check whether the script has already been executed. 134 | # - If not, then attempt to run it. 135 | # - If another process is running the script already, will wait for completion and resume execution. 136 | # - Log the result of the script in the database and go on. 137 | def self.process 138 | ::Moongoon::Log.info { "Processing database scripts…" } 139 | callbacks = [] of {Int64, Proc(Mongo::Database, Nil)} 140 | {% for script in SCRIPT_CLASSES %} 141 | callbacks << { {{script}}.order, ->{{script}}.process(Mongo::Database) } 142 | {% end %} 143 | callbacks.sort! { |a, b| a[0] <=> b[0] } 144 | callbacks.each { |_, cb| 145 | ::Moongoon.connection_with_lock "scripts" { |_, db| 146 | cb.call(db) 147 | } 148 | } 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /docs/Moongoon/Error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Error - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | module Moongoon::Error 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 |

190 | 191 | 194 | 195 | Defined in: 196 |

197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 |
212 | 213 |
214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |
224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /spec/hooks_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | last_query : BSON? = nil 4 | last_update : BSON? = nil 5 | 6 | private abstract class HooksModel < Moongoon::Collection 7 | collection "hooks_models" 8 | 9 | property index : Int32 = 0 10 | 11 | def self.increment(model : self) 12 | model.index += 1 13 | model 14 | end 15 | end 16 | 17 | private class HooksBefore < HooksModel 18 | before_insert { |model| 19 | ->self.increment(self).call(model) 20 | } 21 | before_update { |model| 22 | ->self.increment(self).call(model) 23 | } 24 | before_remove { |model| 25 | ->self.increment(self).call(model) 26 | } 27 | before_update_static { |query, update| 28 | self.update({index: 0}, {"$inc": {index: 1}}, no_hooks: true) 29 | last_query = query 30 | last_update = update 31 | } 32 | before_remove_static { |query| 33 | self.update(query, {"$inc": {index: 1}}, no_hooks: true) 34 | last_query = query 35 | } 36 | end 37 | 38 | private class HooksAfter < HooksModel 39 | after_insert { |model| 40 | ->self.increment(self).call(model) 41 | } 42 | after_update { |model| 43 | ->self.increment(self).call(model) 44 | } 45 | after_remove { |model| 46 | ->self.increment(self).call(model) 47 | } 48 | after_update_static { |query, update| 49 | self.update({index: 1}, {"$inc": {index: 1}}, no_hooks: true) 50 | last_query = query 51 | last_update = update 52 | } 53 | after_remove_static { |query| 54 | self.update({index: 0}, {"$inc": {index: 1}}, no_hooks: true) 55 | last_query = query 56 | } 57 | end 58 | 59 | describe Moongoon::Traits::Database::Hooks do 60 | before_each { 61 | HooksBefore.clear 62 | HooksAfter.clear 63 | last_query = nil 64 | last_update = nil 65 | } 66 | 67 | describe "hooks" do 68 | describe "instance" do 69 | it "before_insert" do 70 | model = HooksBefore.new 71 | model.insert 72 | model.index.should eq 1 73 | HooksBefore.find_by_id!(model.id!).index.should eq 1 74 | end 75 | it "after_insert" do 76 | model = HooksAfter.new 77 | model.insert 78 | model.index.should eq 1 79 | HooksAfter.find_by_id!(model.id!).index.should eq 0 80 | end 81 | it "before_update" do 82 | model = HooksBefore.new(index: 0) 83 | model.insert # +1 84 | model.update # +1 85 | model.index.should eq 2 86 | HooksBefore.find_by_id!(model.id!).index.should eq 2 87 | end 88 | it "after_update" do 89 | model = HooksAfter.new(index: 0) 90 | model.insert 91 | model.update 92 | model.index.should eq 2 93 | HooksAfter.find_by_id!(model.id!).index.should eq 1 94 | end 95 | it "before_remove" do 96 | model = HooksBefore.new(index: 0) 97 | model.insert # +1 98 | model.remove({index: 0}) # +1 99 | model.index.should eq 2 100 | HooksBefore.find_by_id!(model.id!).index.should eq 1 101 | end 102 | it "after_remove" do 103 | model = HooksBefore.new(index: 0) 104 | model.insert 105 | model.remove({index: 0}) 106 | model.index.should eq 2 107 | HooksBefore.find_by_id!(model.id!).index.should eq 1 108 | end 109 | end 110 | describe "static" do 111 | it "before_update_static" do 112 | model = HooksBefore.new(index: 0) 113 | model.insert(no_hooks: true) 114 | query, update = {index: 1}, {"$inc": {index: 1}} 115 | HooksBefore.update(query, update) 116 | last_query.should eq BSON.new(query) 117 | last_update.should eq BSON.new(update) 118 | HooksBefore.find_by_id!(model.id!).index.should eq 2 119 | end 120 | it "before_update_static (find_and_modify)" do 121 | model = HooksBefore.new(index: 0) 122 | model.insert(no_hooks: true) 123 | query, update = {index: 1}, {"$inc": {index: 1}} 124 | HooksBefore.find_and_modify(query, update) 125 | last_query.should eq BSON.new(query) 126 | last_update.should eq BSON.new(update) 127 | HooksBefore.find_by_id!(model.id!).index.should eq 2 128 | end 129 | it "after_update_static" do 130 | model = HooksAfter.new(index: 0) 131 | model.insert(no_hooks: true) 132 | query, update = {index: 0}, {"$inc": {index: 1}} 133 | HooksAfter.update(query, update) 134 | last_query.should eq BSON.new(query) 135 | last_update.should eq BSON.new(update) 136 | HooksAfter.find_by_id!(model.id!).index.should eq 2 137 | end 138 | it "after_update_static (find_and_modify)" do 139 | model = HooksAfter.new(index: 0) 140 | model.insert(no_hooks: true) 141 | query, update = {index: 0}, {"$inc": {index: 1}} 142 | HooksAfter.find_and_modify(query, update) 143 | last_query.should eq BSON.new(query) 144 | last_update.should eq BSON.new(update) 145 | HooksAfter.find_by_id!(model.id!).index.should eq 2 146 | end 147 | it "before_remove_static" do 148 | model = HooksAfter.new(index: 0) 149 | model.insert(no_hooks: true) 150 | query = {index: 0} 151 | HooksBefore.remove(query) 152 | last_query.should eq BSON.new(query) 153 | HooksAfter.find_by_id!(model.id!).index.should eq 1 154 | end 155 | it "before_remove_static (find_and_modify)" do 156 | model = HooksAfter.new(index: 0) 157 | model.insert(no_hooks: true) 158 | query = {index: 0} 159 | HooksBefore.find_and_remove(query) 160 | last_query.should eq BSON.new(query) 161 | HooksAfter.find_by_id!(model.id!).index.should eq 1 162 | end 163 | it "after_remove_static" do 164 | model = HooksAfter.new(index: 0) 165 | model.insert(no_hooks: true) 166 | query = {index: 1} 167 | HooksAfter.remove(query) 168 | last_query.should eq BSON.new(query) 169 | HooksAfter.find_by_id!(model.id!).index.should eq 1 170 | end 171 | it "after_remove_static" do 172 | model = HooksAfter.new(index: 0) 173 | model.insert(no_hooks: true) 174 | query = {index: 1} 175 | HooksAfter.find_and_remove(query) 176 | last_query.should eq BSON.new(query) 177 | HooksAfter.find_by_id!(model.id!).index.should eq 1 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /src/database/database.cr: -------------------------------------------------------------------------------- 1 | require "./scripts" 2 | 3 | # Used to connect to a MongoDB database instance. 4 | module Moongoon::Database 5 | macro extended 6 | @@before_connect_blocks : Array(Proc(Nil)) = [] of Proc(Nil) 7 | @@after_connect_blocks : Array(Proc(Nil)) = [] of Proc(Nil) 8 | @@after_scripts_blocks : Array(Proc(Nil)) = [] of Proc(Nil) 9 | 10 | # Retrieves the mongodb driver client that can be used to perform low level queries 11 | # 12 | # See: [https://github.com/elbywan/cryomongo](https://github.com/elbywan/cryomongo) 13 | # 14 | # ``` 15 | # cursor = Moongoon.client["database"]["collection"].find({ "key": value }) 16 | # puts cursor.to_a 17 | # ``` 18 | class_getter! client : Mongo::Client 19 | 20 | # The name of the default database. 21 | class_getter! database_name : String 22 | 23 | # The default database instance that can be used to perform low level queries. 24 | # 25 | # See: [https://github.com/elbywan/cryomongo](https://github.com/elbywan/cryomongo) 26 | # 27 | # ``` 28 | # db = Moongoon.database 29 | # collection = db["some_collection"] 30 | # data = collection.find query 31 | # pp data 32 | # ``` 33 | class_getter database : Mongo::Database do 34 | client[database_name] 35 | end 36 | end 37 | 38 | # Acquires a database lock and yields the client and database objects. 39 | # 40 | # Will acquire a lock named *lock_name*, polling the DB every *delay* to check the lock status. 41 | # If *abort_if_locked* is true the block will not be executed and this method will return if the lock is acquired already. 42 | # 43 | # ``` 44 | # # If another connection uses the "query" lock, it will wait 45 | # # until this block has completed before perfoming its own work. 46 | # Moongoon.connection_with_lock "query" { |client, db| 47 | # collection = db["some_collection"] 48 | # data = collection.find query 49 | # pp data 50 | # } 51 | # ``` 52 | def connection_with_lock(lock_name : String, *, delay = 0.5.seconds, abort_if_locked = false, &block : Proc(Mongo::Client, Mongo::Database, Nil)) 53 | loop do 54 | begin 55 | # Acquire lock 56 | lock = database["_locks"].find_one_and_update( 57 | filter: {_id: lock_name}, 58 | update: {"$setOnInsert": {date: Time.utc}}, 59 | upsert: true, 60 | write_concern: Mongo::WriteConcern.new(w: "majority") 61 | ) 62 | return if abort_if_locked && lock 63 | break unless lock 64 | rescue 65 | # Possible upsert concurrency error 66 | end 67 | # Wait until the lock is released 68 | sleep delay 69 | end 70 | begin 71 | # Perform the operation 72 | block.call(client, database) 73 | ensure 74 | # Unlock 75 | database["_locks"].delete_one( 76 | {_id: lock_name}, 77 | write_concern: Mongo::WriteConcern.new(w: "majority") 78 | ) 79 | end 80 | end 81 | 82 | # Connects to MongoDB. 83 | # 84 | # Use an instance of `Mongo::Options` as the *options* argument to customize the `Mongo::Client` instance. 85 | # 86 | # Will retry up to *max_retries* times to connect to the database. 87 | # If *max_retries* is nil, will retry infinitely. 88 | # 89 | # Will sleep for *reconnection_delay* between attempts. 90 | # 91 | # ``` 92 | # # Arguments are all optional, their default values are the ones defined below: 93 | # Moongoon.connect("mongodb://localhost:27017", "database", options = nil, max_retries: nil, reconnection_delay: 5.seconds) 94 | # ``` 95 | def connect(database_url : String = "mongodb://localhost:27017", database_name : String = "database", *, options : Mongo::Options? = nil, max_retries = nil, reconnection_delay = 5.seconds) 96 | @@database_name = database_name 97 | @@before_connect_blocks.each &.call 98 | 99 | ::Moongoon::Log.info { "Connecting to MongoDB @ #{database_url}" } 100 | 101 | client = if options 102 | Mongo::Client.new(database_url, options: options) 103 | else 104 | Mongo::Client.new(database_url) 105 | end 106 | 107 | @@client = client 108 | 109 | retries = 0 110 | 111 | ::Moongoon::Log.info { "Using database #{database_name} as default." } 112 | loop do 113 | begin 114 | client.command(Mongo::Commands::Ping) 115 | # status = client.server_status 116 | # uptime = Time::Span.new seconds: status["uptime"].as(Float64).to_i32, nanoseconds: 0 117 | # ::Moongoon::Log.info { "Connected to MongoDB. Server version: #{status["version"]}, uptime: #{uptime}" } 118 | ::Moongoon::Log.info { "Connected to MongoDB." } 119 | break 120 | rescue error 121 | if max_retries.nil? || retries < max_retries 122 | retries += 1 123 | ::Moongoon::Log.error { "#{error}\nCould not connect to MongoDB, retrying in #{reconnection_delay} second(s)." } 124 | sleep reconnection_delay 125 | elsif max_retries && retries >= max_retries 126 | ::Moongoon::Log.error { "#{error}\nCould not connect to MongoDB, maximum number of retries exceeded (#{max_retries})." } 127 | raise error 128 | else 129 | ::Moongoon::Log.error { "#{error}\nCould not connect to MongoDB." } 130 | raise error 131 | end 132 | end 133 | end 134 | 135 | @@after_connect_blocks.each &.call 136 | Scripts.process 137 | @@after_scripts_blocks.each &.call 138 | end 139 | 140 | # Pass a block that will get executed before the server tries to connect to the database. 141 | # 142 | # ``` 143 | # Moongoon::Database.before_connect { 144 | # puts "Before connecting…" 145 | # } 146 | # ``` 147 | def before_connect(&block : Proc(Nil)) 148 | @@before_connect_blocks << block 149 | end 150 | 151 | # Pass a block that will get executed after the database has been successfully connected but before the scripts are run. 152 | # 153 | # ``` 154 | # Moongoon::Database.after_connect_before_scripts { 155 | # # ... # 156 | # } 157 | # ``` 158 | def after_connect_before_scripts(&block : Proc(Nil)) 159 | @@after_connect_blocks << block 160 | end 161 | 162 | # Pass a block that will get executed after the database has been successfully connected and after the scripts are run. 163 | # 164 | # ``` 165 | # Moongoon::Database.after_connect { 166 | # # ... # 167 | # } 168 | # ``` 169 | def after_connect(&block : Proc(Nil)) 170 | @@after_scripts_blocks << block 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /docs/Moongoon/Error/NotFound.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Error::NotFound - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | class Moongoon::Error::NotFound 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 |

179 | 180 | 183 | 184 | Overview 185 |

186 | 187 |

Raised when a query fails to retrieve documents.

188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 |

203 | 204 | 207 | 208 | Defined in: 209 |

210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 |
225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 |
257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 |
267 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /docs/Moongoon/Database/Scripts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Database::Scripts - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | module Moongoon::Database::Scripts 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 |

177 | 178 | 181 | 182 | Overview 183 |

184 | 185 |

This module handles database migration scripts.

186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |

201 | 202 | 205 | 206 | Defined in: 207 |

208 | 209 | 210 | 211 | 212 | 213 |

214 | 215 | 218 | 219 | Constant Summary 220 |

221 | 222 |
223 | 224 |
225 | SCRIPT_CLASSES = [] of Class 226 |
227 | 228 | 229 |
230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 |
242 | 243 |
244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /src/models/database/relationships.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Relationships 3 | macro included 4 | {% verbatim do %} 5 | # References one or more documents belonging to another collection. 6 | # 7 | # Creates a model field that will reference either one or multiple 8 | # foreign documents depending on the arguments provided. 9 | # 10 | # NOTE: This macro is useful when using named arguments to keep the reference 11 | # in sync when documents are added or removed from the other collection. 12 | # 13 | # ``` 14 | # class MyModel < Moongoon::Collection 15 | # # The following references are not kept in sync because extra named arguments are not used. 16 | # 17 | # # Reference a single user. 18 | # reference user_id, model: User 19 | # 20 | # # References multiple users. 21 | # reference user_ids, model: User, many: true 22 | # 23 | # reference user_ids, model: User, many: true 24 | # end 25 | # ``` 26 | # 27 | # **Named arguments** 28 | # 29 | # - *model*: The referenced model class. 30 | # - *many*: Set to true to reference multiple documents. 31 | # - *delete_cascade*: If true, removes the referenced document(s) when this model is removed. 32 | # - *clear_reference*: If true, sets the reference to nil (if referencing a single document), or removes the id from the 33 | # reference array (if referencing multiple documents) when the referenced document(s) are removed. 34 | # - *back_reference*: The name of the refence, if it exists, in the referenced model that back-references this model. 35 | # If set, when a referenced document gets inserted, this reference will be updated to add the newly created id. 36 | # 37 | # ``` 38 | # class MyModel < Moongoon::Collection 39 | # # Now some examples that are using extra arguments. 40 | # 41 | # # References a single user from the User model class. 42 | # # The user has a field that links to back to this model (best_friend_id). 43 | # # Whenever a user is inserted, the reference will get updated to point to the linked user. 44 | # reference user_id, model: User, back_reference: best_friend_id 45 | # 46 | # # References multiple pets. When this model is removed, all the pets 47 | # # referenced will be removed as well. 48 | # reference pet_ids, model: Pet, many: true, delete_cascade: true 49 | # 50 | # # Whenever a Pet is removed the reference will get updated and the 51 | # # id of the Pet will be removed from the array. 52 | # reference pet_id, model: Pet, many: true, clear_reference: true 53 | # end 54 | # ``` 55 | macro reference( 56 | # Name of the field in the model 57 | field, 58 | *, 59 | model, 60 | many = false, 61 | delete_cascade = false, 62 | clear_reference = false, 63 | # Set with the target collection field name (back-reference) to update when the referenced model gets inserted. 64 | # Field name must be equal to the instance variable referencing this model from the target model. 65 | back_reference = nil 66 | ) 67 | {% field_key = field.id %} 68 | {% model_class = model %} 69 | 70 | {% if many %} 71 | # References multiple documents 72 | property {{ field_key }} : Array(String) = [] of String 73 | 74 | {% if delete_cascade %} 75 | # Cascades on deletion 76 | 77 | self.before_remove { |model| 78 | model = model.fetch 79 | ids_to_remove = model.try &.{{ field_key }} 80 | if ids_to_remove.try(&.size) || 0 > 0 81 | {{ model_class }}.remove_by_ids ids_to_remove.not_nil! 82 | end 83 | } 84 | 85 | self.before_remove_static { |query| 86 | models = find query 87 | ids_to_remove = [] of String 88 | models.each { |model| 89 | model.{{ field_key }}.try &.each { |id| 90 | ids_to_remove << id 91 | } 92 | } 93 | if ids_to_remove.size > 0 94 | {{ model_class }}.remove_by_ids ids_to_remove 95 | end 96 | } 97 | {% end %} 98 | 99 | {% else %} 100 | # References a single item 101 | property {{ field_key }} : String? 102 | 103 | {% if delete_cascade %} 104 | # Cascades on deletion 105 | 106 | self.before_remove { |model| 107 | model = model.fetch 108 | link = model.try &.{{ field_key }} 109 | if link 110 | {{ model_class }}.remove_by_id link 111 | end 112 | } 113 | 114 | self.before_remove_static { |query| 115 | models = find query 116 | ids_to_remove = [] of String 117 | models.each { |model| 118 | if id_to_remove = model.{{ field_key }} 119 | ids_to_remove << id_to_remove 120 | end 121 | } 122 | if ids_to_remove.size > 0 123 | {{ model_class }}.remove_by_ids ids_to_remove 124 | end 125 | } 126 | {% end %} 127 | 128 | {% end %} 129 | 130 | {% if clear_reference %} 131 | # Updates the reference when the target gets deleted. 132 | {% if many %} 133 | {% mongo_op = "$pull" %} 134 | {% else %} 135 | {% mongo_op = "$unset" %} 136 | {% end %} 137 | 138 | {{ model_class }}.after_remove { |removed_model| 139 | items = self.find({ 140 | {{ field_key }}: removed_model.id 141 | }) 142 | if items.size > 0 143 | ids = items.map &.id! 144 | self.update_by_ids(ids, { 145 | {{ mongo_op }}: { 146 | {{ field_key }}: removed_model.id 147 | } 148 | }) 149 | end 150 | } 151 | {{ model_class }}.before_remove_static { |query| 152 | removed_models = {{ model_class }}.find query 153 | removed_models_ids = removed_models.map &.id! 154 | items = self.find({ 155 | {{ field_key }}: { 156 | "$in": removed_models_ids 157 | } 158 | }) 159 | if items.size > 0 160 | ids = items.map &.id! 161 | self.update_by_ids(ids, { 162 | {{ mongo_op }}: { 163 | {{ field_key }}: { 164 | "$in": removed_models_ids 165 | } 166 | } 167 | }) 168 | end 169 | } 170 | {% end %} 171 | 172 | {% if back_reference %} 173 | # Updates the reference when a target back-referencing this model gets inserted. 174 | {{ model_class }}.after_insert { |inserted_model| 175 | sync_field = inserted_model.{{back_reference.id}} 176 | if sync_field 177 | self.update_by_id(sync_field, { 178 | {% if many %} 179 | "$addToSet": { 180 | {{ field_key }}: inserted_model.id 181 | } 182 | {% else %} 183 | "$set": { 184 | {{ field_key }}: inserted_model.id 185 | } 186 | {% end %} 187 | }) 188 | end 189 | } 190 | {% end %} 191 | end 192 | {% end %} 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

moongoon

4 |

A MongoDB ODM written in Crystal.

5 | travis-badge 6 | GitHub tag (latest SemVer) 7 | GitHub 8 |
9 | 10 |
11 | 12 | #### An object-document mapper (ODM) library written in Crystal which makes interacting with MongoDB a breeze. 13 | 14 | This library relies on: 15 | - [`cryomongo`](https://github.com/elbywan/cryomongo) as the underlying MongoDB driver. 16 | - [`bson.cr`](https://github.com/elbywan/bson.cr) as the BSON implementation. 17 | 18 | *For the moongoon version relying on the [`mongo.cr`](https://github.com/elbywan/mongo.cr) driver, please check the [mongo.cr](https://github.com/elbywan/moongoon/tree/mongo.cr) branch.* 19 | 20 | ## Installation 21 | 22 | 1. Add the dependency to your `shard.yml`: 23 | 24 | ```yaml 25 | dependencies: 26 | moongoon: 27 | github: elbywan/moongoon 28 | ``` 29 | 30 | 2. Run `shards install` 31 | 32 | 3. Profit! 💰 33 | 34 | ## Usage 35 | 36 | ### Minimal working example 37 | 38 | ```crystal 39 | require "moongoon" 40 | 41 | # A Model inherits from `Moongoon::Collection` 42 | class User < Moongoon::Collection 43 | collection "users" 44 | 45 | index keys: { name: 1, age: 1 }, options: { unique: true } 46 | 47 | property name : String 48 | property age : Int32 49 | property pets : Array(Pet) 50 | 51 | # Nested models inherit from `Moongoon::Document` 52 | class Pet < Moongoon::Document 53 | property pet_name : String 54 | end 55 | end 56 | 57 | # Connect to the mongodb instance. 58 | Moongoon.connect("mongodb://localhost:27017", database_name: "my_database") 59 | 60 | # Initialize a model from arguments… 61 | user = User.new(name: "Eric", age: 10, pets: [ 62 | User::Pet.new(pet_name: "Mr. Kitty"), 63 | User::Pet.new(pet_name: "Fluffy") 64 | ]) 65 | # …or JSON data… 66 | user = User.from_json(%( 67 | "name": "Eric", 68 | "age": 10, 69 | "pets": [ 70 | { "pet_name": "Mr. Kitty" }, 71 | { "pet_name": "Fluffy" } 72 | ] 73 | )) 74 | # …or from querying the database. 75 | user = User.find_one!({ name: "Eric" }) 76 | 77 | # Insert a model in the database. 78 | user.insert 79 | 80 | # Modify it. 81 | user.name = "Kyle" 82 | user.update 83 | 84 | # Delete it. 85 | user.remove 86 | ``` 87 | 88 | ### Connecting 89 | 90 | [**API documentation**](https://elbywan.github.io/moongoon/Moongoon/Database.html) 91 | 92 | - [Initial connection](https://elbywan.github.io/moongoon/Moongoon/Database.html#connect(database_url:String="mongodb://localhost:27017",database_name:String="database",*,reconnection_delay=5.seconds)-instance-method) 93 | - [Hooks](https://elbywan.github.io/moongoon/Moongoon/Database.html#after_connect(&block:Proc(Nil))-instance-method) 94 | - [Low-level](https://elbywan.github.io/moongoon/Moongoon.html#client:Mongo::Client-class-method) 95 | 96 | ```crystal 97 | require "moongoon" 98 | 99 | Moongoon.before_connect { 100 | puts "Connecting…" 101 | } 102 | Moongoon.after_connect { 103 | puts "Connected!" 104 | } 105 | 106 | # … # 107 | 108 | Moongoon.connect( 109 | database_url: "mongodb://address:27017", 110 | database_name: "my_database" 111 | ) 112 | 113 | # In case you need to perform a low level query, use `Moongoon.client` or `Moongoon.database`. 114 | # Here, *db* is a `cryomongo` Mongo::Database. (For more details, check the `cryomongo` documentation) 115 | db = Moongoon.database 116 | cursor = db["my_collection"].list_indexes 117 | puts cursor.to_a.to_json 118 | ``` 119 | 120 | ### Models 121 | 122 | [**API documentation**](https://elbywan.github.io/moongoon/Moongoon/Collection.html) 123 | 124 | - [Indexes](https://elbywan.github.io/moongoon/Moongoon/Collection.html#index(collection:String?=nil,database:String?=nil,options=NamedTuple.new,name:String?=nil,**keys):Nil-class-method) 125 | - [Relationships](https://elbywan.github.io/moongoon/Moongoon/Collection.html#reference(field,*,model,many=false,delete_cascade=false,clear_reference=false,back_reference=nil)-macro) 126 | - [Aggregations](https://elbywan.github.io/moongoon/Moongoon/Collection.html#aggregation_pipeline(*args)-class-method) 127 | - [Versioning](https://elbywan.github.io/moongoon/Moongoon/Collection/Versioning.html#versioning(ref_field=nil,auto=false,&transform)-macro) 128 | 129 | ```crystal 130 | require "moongoon" 131 | 132 | class MyModel < Moongoon::Collection 133 | collection "models" 134 | 135 | # Note: the database can be changed - if different from the default one 136 | # database "database_name" 137 | 138 | # Define indexes 139 | index keys: { name: 1 } 140 | 141 | # Specify agregation pipeline stages that will automatically be used for queries. 142 | aggregation_pipeline( 143 | { 144 | "$addFields": { 145 | count: { 146 | "$size": "$array" 147 | } 148 | } 149 | }, 150 | { 151 | "$project": { 152 | array: 0 153 | } 154 | } 155 | ) 156 | 157 | # Collection fields 158 | property name : String 159 | property count : Int32? 160 | property array : Array(Int32)? = [1, 2, 3] 161 | end 162 | 163 | # …assuming moongoon is connected… # 164 | 165 | MyModel.clear 166 | 167 | model = MyModel.new( 168 | name: "hello" 169 | ).insert 170 | model_id = model.id! 171 | 172 | puts MyModel.find_by_id(model_id).to_json 173 | # => "{\"_id\":\"5ea052ce85ed2a2e1d0c87a2\",\"name\":\"hello\",\"count\":3}" 174 | 175 | model.name = "good night" 176 | model.update 177 | 178 | puts MyModel.find_by_id(model_id).to_json 179 | # => "{\"_id\":\"5ea052ce85ed2a2e1d0c87a2\",\"name\":\"good night\",\"count\":3}" 180 | 181 | model.remove 182 | puts MyModel.count 183 | # => 0 184 | ``` 185 | 186 | ### Running scripts 187 | 188 | [**API documentation**](https://elbywan.github.io/moongoon/Moongoon/Database/Scripts/Base.html) 189 | 190 | ```crystal 191 | # A script must inherit from `Moongoon::Database::Scripts::Base` 192 | # Requiring the script before connecting to the database should be all it takes to register it. 193 | # 194 | # Scripts are then processed automatically. 195 | class Moongoon::Database::Scripts::Test < Moongoon::Database::Scripts::Base 196 | # Scripts run in ascending order. 197 | # Default order if not specified is 1. 198 | order Time.utc(2020, 3, 11).to_unix 199 | 200 | def process(db : Mongo::Database) 201 | # Dummy code that will add a ban flag for users that are called 'John'. 202 | # This code uses the `cryomongo` syntax, but Models could 203 | # be used for convenience despite a small performance overhead. 204 | db["users"].update_many( 205 | filter: {name: "John"}, 206 | update: {"$set": {"banned": true}} 207 | ) 208 | end 209 | end 210 | ``` 211 | 212 | ## Contributing 213 | 214 | 1. Fork it () 215 | 2. Create your feature branch (`git checkout -b my-new-feature`) 216 | 3. Commit your changes (`git commit -am 'Add some feature'`) 217 | 4. Push to the branch (`git push origin my-new-feature`) 218 | 5. Create a new Pull Request 219 | 220 | ## Contributors 221 | 222 | See the [contributors page](https://github.com/elbywan/moongoon/graphs/contributors). 223 | 224 | ## Credit 225 | 226 | - Icon made by [Smashicons](https://www.flaticon.com/authors/smashicons) from [www.flaticon.com](https://www.flaticon.com). -------------------------------------------------------------------------------- /src/models/models.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "./database" 4 | 5 | # Models are classes used to store data and interact with the database. 6 | module Moongoon 7 | # Base model class. 8 | # 9 | # Contains helpers to (de)serialize data to json format and bson format. 10 | # 11 | # ``` 12 | # class Models::MyModel < Moongoon::Document 13 | # property name : String 14 | # property age : Int32 15 | # end 16 | # ``` 17 | abstract class Document 18 | include JSON::Serializable 19 | include BSON::Serializable 20 | 21 | # Creates a new instance of the class from variadic arguments. 22 | # 23 | # ``` 24 | # User.new first_name: "John", last_name: "Doe" 25 | # ``` 26 | # 27 | # NOTE: Only instance variables having associated setter methods will be initialized. 28 | def self.new(**args : **T) forall T 29 | instance = self.allocate 30 | {% begin %} 31 | {% for ivar in @type.instance_vars %} 32 | {% has_setter = @type.has_method? ivar.stringify + "=" %} 33 | {% default_value = ivar.default_value %} 34 | {% if has_setter && ivar.type.nilable? %} 35 | instance.{{ivar.id}} = args["{{ivar.id}}"]? {% if ivar.has_default_value? %}|| {{ default_value }}{% end %} 36 | {% elsif has_setter %} 37 | value = args["{{ivar.id}}"]? 38 | if value != nil 39 | instance.{{ivar.id}} = value 40 | {% if ivar.has_default_value? %} 41 | else 42 | instance.{{ivar.id}} = {{ default_value }} 43 | {% elsif !T[ivar.id] %} 44 | {% raise "Instance variable '" + ivar.stringify + "' cannot be initialized from " + T.stringify + "." %} 45 | {% end %} 46 | end 47 | {% elsif !ivar.has_default_value? %} 48 | {% raise "Instance variable '" + ivar.stringify + "' has no setter or default value." %} 49 | {% end %} 50 | {% end %} 51 | {% end %} 52 | instance 53 | end 54 | 55 | # Instantiate a named tuple from the model instance properties. 56 | # 57 | # ``` 58 | # user = User.new first_name: "John", last_name: "Doe" 59 | # pp user.to_tuple 60 | # # => { 61 | # # first_name: "John", 62 | # # last_name: "Doe", 63 | # # } 64 | # ``` 65 | # 66 | # NOTE: Only instance variables having associated getter methods will be returned. 67 | def to_tuple 68 | {% begin %} 69 | { 70 | {% for ivar in @type.instance_vars %} 71 | {% if @type.has_method? ivar.stringify + "?" %} 72 | "{{ ivar.name }}": self.{{ ivar.name }}?, 73 | {% elsif @type.has_method? ivar.stringify %} 74 | "{{ ivar.name }}": self.{{ ivar.name }}, 75 | {% end %} 76 | {% end %} 77 | } 78 | {% end %} 79 | end 80 | end 81 | 82 | # :nodoc: 83 | abstract class MongoBase < Document 84 | class_getter database_name : String do 85 | Moongoon.database_name 86 | end 87 | 88 | class_getter database : Mongo::Database do 89 | if @@database_name == Moongoon.database_name 90 | Moongoon.database 91 | else 92 | Moongoon.client[database_name] 93 | end 94 | end 95 | class_getter collection : Mongo::Collection do 96 | self.database[collection_name] 97 | end 98 | 99 | # Sets the MongoDB database name. 100 | private macro database(value) 101 | @@database_name = {{ value }} 102 | end 103 | 104 | # Sets the MongoDB collection name. 105 | private macro collection(value) 106 | class_getter collection_name : String = {{ value }} 107 | end 108 | 109 | # Returns true if the document has been removed from the db 110 | @[JSON::Field(ignore: true)] 111 | @[BSON::Field(ignore: true)] 112 | getter? removed = false 113 | 114 | # Returns true if the document has been inserted and not yet removed 115 | def persisted? 116 | self.inserted? && !self.removed? 117 | end 118 | 119 | # Returns true if the document has been inserted (i.e. has an id) 120 | def inserted? 121 | self._id != nil 122 | end 123 | 124 | # The MongoDB internal id representation. 125 | property _id : BSON::ObjectId? 126 | 127 | # Returns the MongoDB bson _id 128 | # 129 | # Will raise if _id is nil. 130 | def _id! 131 | self._id.not_nil! 132 | end 133 | 134 | # Set a MongoDB bson _id from a String. 135 | def id=(id : String) 136 | self._id = BSON::ObjectId.new id 137 | end 138 | 139 | # Converts the MongoDB bson _id to a String representation. 140 | def id 141 | self._id.to_s if self._id 142 | end 143 | 144 | # Converts the MongoDB bson _id to a String representation. 145 | # 146 | # Will raise if _id is nil. 147 | def id! 148 | self.id.not_nil! 149 | end 150 | 151 | # Copying and hacking BSON::Serializable for now - but ideally we'd just add more flexibility there? (options[force_emit_nil: true] or something?) 152 | def unsets_to_bson : BSON? 153 | bson = BSON.new 154 | {% begin %} 155 | {% global_options = @type.annotations(BSON::Options) %} 156 | {% camelize = global_options.reduce(false) { |_, a| a[:camelize] } %} 157 | {% for ivar in @type.instance_vars %} 158 | {% ann = ivar.annotation(BSON::Field) %} 159 | {% key = ivar.name %} 160 | {% bson_key = ann ? ann[:key].id : camelize ? ivar.name.camelcase(lower: camelize == "lower") : ivar.name %} 161 | {% unless ann && ann[:ignore] %} 162 | {% unless ann && ann[:emit_null] %} #confusing, but it will be picked up by normal to_bson so we don't need it here 163 | if self.{{ key }}.nil? 164 | bson["{{ bson_key }}"] = nil 165 | end 166 | {% end %} 167 | {% end %} 168 | {% end %} 169 | {% end %} 170 | bson.empty? ? nil : bson 171 | end 172 | end 173 | 174 | # Base model class for interacting with a MongoDB collection. 175 | # 176 | # This abstract class extends the `Moongoon::Base` class and enhances it with 177 | # utility methods and macros used to query, update and configure an 178 | # underlying MongoDB collection. 179 | # 180 | # ``` 181 | # class MyModel < Moongoon::Collection 182 | # collection "my_models" 183 | # 184 | # index keys: {name: 1}, options: {unique: true} 185 | # 186 | # property name : String 187 | # property age : Int32 188 | # end 189 | # ``` 190 | abstract class Collection < MongoBase 191 | include ::Moongoon::Traits::Database::Full 192 | 193 | # Include this module to enable resource versioning. 194 | module Versioning 195 | include ::Moongoon::Traits::Database::Versioning 196 | 197 | macro included 198 | extend Static 199 | 200 | {% base_versioning_name = @type.stringify.split("::")[-1].underscore %} 201 | @@versioning_id_field = {{(base_versioning_name + "_id")}} 202 | @@versioning_transform : Proc(BSON, BSON, BSON)? = nil 203 | 204 | # Get the versioning collection. 205 | def self.history_collection 206 | self.database["#{self.collection_name}_history"] 207 | end 208 | end 209 | end 210 | end 211 | 212 | # A limited model class for interacting with a MongoDB collection. 213 | # 214 | # NOTE: Similar to `Moongoon::Collection` but can only be used to update a MongoDB collection. 215 | # 216 | # ``` 217 | # class Partial < Moongoon::Collection::UpdateOnly 218 | # collection "my_models" 219 | # 220 | # property name : String? 221 | # property age : Int32? 222 | # end 223 | # ``` 224 | abstract class Collection::UpdateOnly < MongoBase 225 | include ::Moongoon::Traits::Database::Update 226 | end 227 | 228 | # A limited model class for interacting with a MongoDB collection. 229 | # 230 | # NOTE: Similar to `Moongoon::Collection` but can only be used to query a MongoDB collection. 231 | # 232 | # ``` 233 | # class ReadOnly < Models::Collection::ReadOnly 234 | # collection "my_models" 235 | # 236 | # property name : String? 237 | # property age : Int32? 238 | # end 239 | # ``` 240 | abstract class Collection::ReadOnly < MongoBase 241 | include ::Moongoon::Traits::Database::Read 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /src/models/database/methods/patch.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Methods::Patch 3 | macro included 4 | @@default_fields : BSON? = nil 5 | 6 | # Updates a document having the same id as this model with the data stored in `self`. 7 | # 8 | # Tries to match on `self.id`. 9 | # 10 | # ``` 11 | # user = User.new name: "John", age: 25 12 | # user.insert 13 | # user.age = 26 14 | # user.update 15 | # ``` 16 | # 17 | # It is possible to add *query* filters to conditionally prevent an update. 18 | # 19 | # ``` 20 | # user = User.new name: "John", locked: true 21 | # user.insert 22 | # user.name = "Igor" 23 | # # Prevents updating users that are locked. 24 | # user.update({ locked: false }) 25 | # pp User.find_by_id(user.id!).to_json 26 | # # => { "id": "some id", "name": "John", "locked": true } 27 | # ``` 28 | # 29 | # The update can be restricted to specific *fields*. 30 | # 31 | # ``` 32 | # user = User.new name: "John", age: 25 33 | # user.insert 34 | # user.age = 26 35 | # user.name = "Tom" 36 | # # Updates only the age field. 37 | # user.update(fields: {:age}) 38 | # ``` 39 | def update(query = BSON.new, **args) : self 40 | id_check! 41 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, _id!) 42 | self.update_query(query, **args) 43 | end 44 | 45 | # Update specific fields both in the model and in database. 46 | # 47 | # ``` 48 | # user = User.new name: "John", age: 25 49 | # user.insert 50 | # # Updates only the age field. 51 | # user.update({age: 26}) 52 | # ``` 53 | def update_fields(fields : F, **args) : self forall F 54 | {% verbatim do %} 55 | {% begin %} 56 | {% for k in F.keys %} 57 | self.{{k}} = fields[{{k.symbolize}}] 58 | {% end %} 59 | self.update(**args, fields: {{F.keys.map(&.symbolize)}}) 60 | {% end %} 61 | {% end %} 62 | end 63 | 64 | # Updates one or more documents in the underlying collection. 65 | # 66 | # Every document matching the *query* argument will be updated. 67 | # See the [MongoDB tutorial](https://docs.mongodb.com/v3.6/tutorial/update-documents/) 68 | # for more information about the syntax. 69 | # 70 | # ``` 71 | # # Rename every person named "John" to "Igor". 72 | # User.update(query: { name: "John" }, update: { "$set": { name: "Igor" } }) 73 | # ``` 74 | def self.update(query, update, no_hooks = false, **args) : Mongo::Commands::Common::UpdateResult? 75 | query, update = BSON.new(query), BSON.new(update) 76 | unless no_hooks 77 | new_update = self.before_update_static_call(query, update) 78 | update = new_update if new_update 79 | end 80 | result = self.collection.update_many(query, update, **args) 81 | self.after_update_static_call(query, update) unless no_hooks 82 | result 83 | end 84 | 85 | # Updates one or more documents with the data stored in `self`. 86 | # 87 | # Every document matching the *query* argument will be updated. 88 | # 89 | # ``` 90 | # user = User.new name: "John", age: 25 91 | # user = User.new name: "Jane", age: 30 92 | # user.insert 93 | # user.age = 40 94 | # # Updates both documents 95 | # user.update_query({ name: {"$in": ["John", "Jane"]} }) 96 | # ``` 97 | def update_query(query, fields = nil, no_hooks = false, **args) : self 98 | self.class.before_update_call(self) unless no_hooks 99 | changes = BSON.new 100 | 101 | sets = self.to_bson 102 | if fields 103 | sets = ::Moongoon::Traits::Database::Internal.filter_bson(sets, fields) 104 | end 105 | changes["$set"] = sets unless sets.empty? 106 | 107 | if ::Moongoon.config.unset_nils && (unsets = self.unsets_to_bson) 108 | if fields 109 | unsets = ::Moongoon::Traits::Database::Internal.filter_bson(unsets, fields) 110 | end 111 | changes["$unset"] = unsets unless unsets.empty? 112 | end 113 | self.class.collection.update_many( 114 | query, 115 | **args, 116 | update: changes 117 | ) 118 | self.class.after_update_call(self) unless no_hooks 119 | self 120 | end 121 | 122 | # Updates one document by id. 123 | # 124 | # Similar to `self.update`, except that a matching on the `_id` field will be added to the *query* argument. 125 | # 126 | # ``` 127 | # id = 123456 128 | # User.update_by_id(id, { "$set": { "name": "Igor" }}) 129 | # ``` 130 | # 131 | # It is possible to add query filters to conditionally prevent an update. 132 | # 133 | # ``` 134 | # # Updates the user only if he/she is named John. 135 | # User.update_by_id(id, query: { name: "John" }, update: { "$set": { name: "Igor" }}) 136 | # ``` 137 | def self.update_by_id(id : BSON::ObjectId | String, update, query = BSON.new, **args) : Mongo::Commands::Common::UpdateResult? 138 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 139 | update(query, update, **args) 140 | end 141 | 142 | # Updates one or multiple documents by their ids. 143 | # 144 | # Similar to `self.update`, except that a matching on multiple `_id` 145 | # fields will be added to the *query* argument. 146 | # 147 | # ``` 148 | # ids = ["1", "2", "3"] 149 | # User.update_by_ids(ids, { "$set": { "name": "Igor" }}) 150 | # ``` 151 | # 152 | # It is possible to add query filters. 153 | # 154 | # ``` 155 | # # Updates the users only if they are named John. 156 | # User.update_by_ids(ids, query: { name: "John" }, update: { "$set": { name: "Igor" }}) 157 | # ``` 158 | def self.update_by_ids(ids, update, query = BSON.new, **args) : Mongo::Commands::Common::UpdateResult? 159 | query = ::Moongoon::Traits::Database::Internal.concat_ids_filter(query, ids) 160 | update(query, update, **args) 161 | end 162 | 163 | # Modifies and returns a single document. 164 | # 165 | # See the [official documentation](https://docs.mongodb.com/v3.6/reference/command/findAndModify/). 166 | # 167 | # ``` 168 | # User.find_and_modify({ name: "John" }, { "$set": { "name": "Igor" }}) 169 | # ``` 170 | def self.find_and_modify(query, update, fields = @@default_fields, no_hooks = false, **args) 171 | query, update = BSON.new(query), BSON.new(update) 172 | unless no_hooks 173 | new_update = self.before_update_static_call(query, update) 174 | update = new_update if new_update 175 | end 176 | item = self.collection.find_one_and_update(query, update, **args, fields: fields) 177 | self.after_update_static_call(query, update) unless no_hooks 178 | self.new item if item 179 | end 180 | 181 | # Modifies and returns a single document. 182 | # 183 | # Similar to `self.find_and_modify`, except that a matching on the `_id` field will be added to the *query* argument. 184 | def self.find_and_modify_by_id(id : BSON::ObjectId | String, update, query = BSON.new, no_hooks = false, **args) 185 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 186 | find_and_modify(query, update, **args) 187 | end 188 | 189 | # Removes and returns a single document. 190 | # 191 | # See the [official documentation](https://docs.mongodb.com/v3.6/reference/command/findAndModify/). 192 | # 193 | # ``` 194 | # User.find_and_remove({ name: "John" }) 195 | # ``` 196 | def self.find_and_remove(query, fields = @@default_fields, no_hooks = false, **args) 197 | query = BSON.new(query) 198 | unless no_hooks 199 | new_update = self.before_remove_static_call(query) 200 | update = new_update if new_update 201 | end 202 | item = self.collection.find_one_and_delete(query, **args, fields: fields) 203 | self.after_remove_static_call(query) unless no_hooks 204 | self.new item if item 205 | end 206 | 207 | # Removes and returns a single document. 208 | # 209 | # Similar to `self.find_and_remove`, except that a matching on the `_id` field will be added to the *query* argument. 210 | def self.find_and_remove_by_id(id, query = BSON.new, no_hooks = false, **args) 211 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 212 | find_and_remove(query, update, **args) 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /docs/Moongoon/Database/Scripts/Base/Action.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Database::Scripts::Base::Action - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | enum Moongoon::Database::Scripts::Base::Action 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 |

177 | 178 | 181 | 182 | Overview 183 |

184 | 185 |

Action to perform when a script fails.

186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |

201 | 202 | 205 | 206 | Defined in: 207 |

208 | 209 | 210 | 211 | 212 | 213 |

214 | 215 | 218 | 219 | Enum Members 220 |

221 | 222 |
223 | 224 |
225 | Discard = 0 226 |
227 | 228 | 229 |
230 | Retry = 1 231 |
232 | 233 | 234 |
235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 |

243 | 244 | 247 | 248 | Instance Method Summary 249 |

250 |
    251 | 252 |
  • 253 | #discard? 254 | 255 |
  • 256 | 257 |
  • 258 | #retry? 259 | 260 |
  • 261 | 262 |
263 | 264 | 265 | 266 | 267 | 268 |
269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 |
311 | 312 | 313 | 314 | 315 | 316 | 317 |

318 | 319 | 322 | 323 | Instance Method Detail 324 |

325 | 326 |
327 |
328 | 329 | def discard? 330 | 331 | # 332 |
333 | 334 |
335 |
336 | 337 |
338 |
339 | 340 |
341 |
342 | 343 | def retry? 344 | 345 | # 346 |
347 | 348 |
349 |
350 | 351 |
352 |
353 | 354 | 355 | 356 | 357 | 358 |
359 | 360 | 361 | 362 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/models_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class Model < Moongoon::Collection 4 | collection "models" 5 | 6 | property name : String 7 | property age : Int32 8 | property humor : Int32? 9 | 10 | def self.insert_models(models) 11 | models.map { |model| 12 | from_json(model.to_json).insert 13 | } 14 | end 15 | end 16 | 17 | describe Moongoon::Collection do 18 | raw_models = [ 19 | {name: "one", age: 10, humor: nil}, 20 | {name: "two", age: 10, humor: 0}, 21 | {name: "three", age: 20, humor: 15}, 22 | ] 23 | 24 | before_each { 25 | Model.clear 26 | Moongoon::Config.reset 27 | } 28 | 29 | describe "Get" do 30 | it "#self.find" do 31 | models = Model.insert_models raw_models 32 | 33 | results = Model.find({age: 0}) 34 | results.size.should eq 0 35 | 36 | results = Model.find({age: 10}, order_by: {"_id": 1}) 37 | results.to_json.should eq [models[0], models[1]].to_json 38 | end 39 | 40 | it "#self.find!" do 41 | models = Model.insert_models raw_models 42 | 43 | expect_raises(Moongoon::Error::NotFound) { 44 | Model.find!({name: "invalid name"}) 45 | } 46 | results = Model.find!({age: 10}, order_by: {"_id": 1}) 47 | results.to_json.should eq [models[0], models[1]].to_json 48 | end 49 | 50 | it "#self.find_one" do 51 | models = Model.insert_models raw_models 52 | 53 | result = Model.find_one({age: 10}, order_by: {"_id": 1}) 54 | result.to_json.should eq models[0].to_json 55 | end 56 | 57 | it "#self.find_one!" do 58 | models = Model.insert_models raw_models 59 | 60 | expect_raises(Moongoon::Error::NotFound) { 61 | Model.find_one!({name: "invalid name"}) 62 | } 63 | 64 | result = Model.find_one!({age: 10}, order_by: {"_id": 1}) 65 | result.to_json.should eq models[0].to_json 66 | end 67 | 68 | it "#self.find_by_id" do 69 | models = Model.insert_models raw_models 70 | 71 | result = Model.find_by_id(models[2].id!) 72 | result.to_json.should eq models[2].to_json 73 | end 74 | 75 | it "#self.find_by_id!" do 76 | models = Model.insert_models raw_models 77 | 78 | expect_raises(Moongoon::Error::NotFound) { 79 | Model.find_by_id!("507f1f77bcf86cd799439011") 80 | } 81 | 82 | result = Model.find_by_id!(models[2].id!) 83 | result.to_json.should eq models[2].to_json 84 | end 85 | 86 | it "#self.find_by_ids" do 87 | models = Model.insert_models raw_models 88 | 89 | results = Model.find_by_ids([models[1], models[2]].map(&.id!), order_by: {_id: 1}) 90 | results.to_json.should eq [models[1], models[2]].to_json 91 | end 92 | 93 | it "#self.find_by_ids!" do 94 | models = Model.insert_models raw_models 95 | 96 | expect_raises(Moongoon::Error::NotFound) { 97 | Model.find_by_ids!(["507f1f77bcf86cd799439011"]) 98 | } 99 | 100 | results = Model.find_by_ids!([models[1], models[2]].map(&.id!), order_by: {_id: 1}) 101 | results.to_json.should eq [models[1], models[2]].to_json 102 | end 103 | 104 | it "#self.count" do 105 | Model.insert_models raw_models 106 | 107 | count = Model.count({age: 10}) 108 | count.should eq 2 109 | end 110 | 111 | it "#self.exist!" do 112 | Model.insert_models raw_models 113 | 114 | expect_raises(Moongoon::Error::NotFound) { 115 | Model.exist!({age: 100}) 116 | } 117 | 118 | Model.exist!({age: 10}).should be_true 119 | end 120 | 121 | it "#self.exist_by_id!" do 122 | models = Model.insert_models raw_models 123 | 124 | expect_raises(Moongoon::Error::NotFound) { 125 | Model.exist_by_id!("507f1f77bcf86cd799439011") 126 | } 127 | 128 | Model.exist_by_id!(models[0].id!).should be_true 129 | end 130 | end 131 | 132 | describe "Post" do 133 | it "#insert" do 134 | model = Model.from_json(raw_models[0].to_json).insert 135 | Model.find_by_id(model.id!).to_json.should eq model.to_json 136 | end 137 | 138 | it "#self.bulk_insert" do 139 | models = Model.bulk_insert raw_models.map { |m| Model.from_json(m.to_json) } 140 | Model.find(order_by: {_id: 1}).to_json.should eq models.to_json 141 | end 142 | end 143 | 144 | describe "Patch" do 145 | it "#update" do 146 | models = Model.insert_models raw_models 147 | model = models[2] 148 | model.age = 15 149 | model = model.update 150 | Model.find_by_id!(model.id!).age.should eq 15 151 | end 152 | 153 | it "#update (skip nils)" do 154 | models = Model.insert_models raw_models 155 | model = models[2] 156 | model.humor = nil 157 | model = model.update 158 | Model.find_by_id!(model.id!).humor.should eq 15 159 | end 160 | 161 | it "#update (specific fields)" do 162 | models = Model.insert_models raw_models 163 | model = models[2] 164 | 165 | model.age = 1 166 | model = model.update(fields: {:name}) 167 | Model.find_by_id!(model.id!).age.should eq 20 168 | model = model.update(fields: {:age}) 169 | Model.find_by_id!(model.id!).age.should eq 1 170 | 171 | Moongoon.configure do |config| 172 | config.unset_nils = true 173 | end 174 | 175 | model = models[1] 176 | model.humor = nil 177 | model = model.update(fields: {:name}) 178 | Model.find_by_id!(model.id!).humor.should eq 0 179 | model = model.update(fields: {:humor}) 180 | Model.find_by_id!(model.id!).humor.should be_nil 181 | end 182 | 183 | it "#update (unset nils)" do 184 | Moongoon.configure do |config| 185 | config.unset_nils = true 186 | end 187 | models = Model.insert_models raw_models 188 | model = models[2] 189 | model.humor = nil 190 | model = model.update 191 | Model.find_by_id!(model.id!).humor.should eq nil 192 | end 193 | 194 | it "#update_fields" do 195 | models = Model.insert_models raw_models 196 | model = models[2] 197 | 198 | model.humor = 1 199 | model = model.update_fields({age: 1}) 200 | Model.find_by_id!(model.id!).age.should eq 1 201 | Model.find_by_id!(model.id!).humor.should eq 15 202 | 203 | Moongoon.configure do |config| 204 | config.unset_nils = true 205 | end 206 | 207 | model = models[1] 208 | model.age = 20 209 | model = model.update_fields({humor: nil}) 210 | Model.find_by_id!(model.id!).humor.should be_nil 211 | Model.find_by_id!(model.id!).age.should eq 10 212 | end 213 | 214 | it "#self.update" do 215 | models = Model.insert_models raw_models 216 | model = models[1] 217 | Model.update({_id: model._id}, {"$set": {age: 15}}) 218 | Model.find_by_id!(model.id!).age.should eq 15 219 | end 220 | 221 | it "#update_query" do 222 | models = Model.insert_models raw_models 223 | model = models[1] 224 | model.age = 15 225 | model.update_query({_id: model._id}) 226 | Model.find_by_id!(model.id!).age.should eq 15 227 | end 228 | 229 | it "#update_query (skip nils)" do 230 | models = Model.insert_models raw_models 231 | model = models[1] 232 | model.humor = nil 233 | model.update_query({_id: model._id}) 234 | Model.find_by_id!(model.id!).humor.should eq 0 235 | end 236 | 237 | it "#update_query (unset nils)" do 238 | Moongoon.configure do |config| 239 | config.unset_nils = true 240 | end 241 | models = Model.insert_models raw_models 242 | model = models[1] 243 | model.humor = nil 244 | model.update_query({_id: model._id}) 245 | Model.find_by_id!(model.id!).humor.should eq nil 246 | end 247 | 248 | it "#self.update_by_id" do 249 | models = Model.insert_models raw_models 250 | model = models[0] 251 | Model.update_by_id(model.id!, {"$set": {age: 15}}) 252 | Model.find_by_id!(model.id!).age.should eq 15 253 | end 254 | 255 | it "#self.update_by_ids" do 256 | models = Model.insert_models raw_models 257 | Model.update_by_ids(models.map(&.id!), {"$set": {age: 15}}) 258 | Model.find.each(&.age.should eq 15) 259 | end 260 | 261 | it "#self.find_and_modify" do 262 | models = Model.insert_models raw_models 263 | model = Model.find_and_modify({_id: models[1]._id}, {"$set": {age: 15}}, new: true) 264 | model.not_nil!.age.should eq 15 265 | end 266 | 267 | it "#self.find_and_modify_by_id" do 268 | models = Model.insert_models raw_models 269 | model = Model.find_and_modify_by_id(models[1].id!, {"$set": {age: 15}}, new: true) 270 | model.not_nil!.age.should eq 15 271 | end 272 | end 273 | 274 | describe "Delete" do 275 | it "#remove" do 276 | models = Model.insert_models raw_models 277 | model = models[2] 278 | Model.count.should eq 3 279 | model.remove 280 | Model.count.should eq 2 281 | Model.find.map { |m| m.id.should_not eq model.id } 282 | end 283 | 284 | it "#self.remove" do 285 | models = Model.insert_models raw_models 286 | model = models[1] 287 | Model.count.should eq 3 288 | Model.remove({_id: model._id}) 289 | Model.count.should eq 2 290 | Model.find.map { |m| m.id.should_not eq model.id } 291 | end 292 | 293 | it "#self.remove_by_id" do 294 | models = Model.insert_models raw_models 295 | model = models[1] 296 | Model.count.should eq 3 297 | Model.remove_by_id(model.id!) 298 | Model.count.should eq 2 299 | Model.find.map { |m| m.id.should_not eq model.id } 300 | end 301 | 302 | it "#self.remove_by_ids" do 303 | models = Model.insert_models raw_models 304 | models_subset = [models[2], models[1]] 305 | Model.count.should eq 3 306 | Model.remove_by_ids(models_subset.map(&.id!)) 307 | Model.count.should eq 1 308 | Model.find[0].to_json.should eq models[0].to_json 309 | end 310 | 311 | it "#self.clear" do 312 | Model.insert_models raw_models 313 | Model.count.should eq 3 314 | Model.clear 315 | Model.count.should eq 0 316 | end 317 | end 318 | end 319 | -------------------------------------------------------------------------------- /src/models/database/methods/get.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Methods::Get 3 | macro included 4 | @@aggregation_stages : Array(BSON)? = nil 5 | @@default_fields : BSON? = nil 6 | 7 | # Defines an [aggregation pipeline](https://docs.mongodb.com/v3.6/reference/operator/aggregation-pipeline/) that will be used instead of a plain find query. 8 | # 9 | # If this macro is used, the model will always use the [aggregate](https://docs.mongodb.com/v3.6/reference/command/aggregate/index.html) 10 | # method to query documents and will use the stages passed as arguments to aggregate the results. 11 | # 12 | # ``` 13 | # aggregation_pipeline( 14 | # { 15 | # "$addFields": { 16 | # count: { 17 | # "$size": "$array" 18 | # } 19 | # } 20 | # }, 21 | # { 22 | # "$project": { 23 | # array: 0 24 | # } 25 | # } 26 | # ) 27 | # ``` 28 | def self.aggregation_pipeline(*args) 29 | @@aggregation_stages = [] of BSON 30 | args.each { |arg| 31 | @@aggregation_stages.try { |a| a.<< BSON.new(arg) } 32 | } 33 | end 34 | 35 | # Set the `fields` value to use by default when calling `find` methods. 36 | # 37 | # ``` 38 | # default_fields({ ignored_field: 0 }) 39 | # ``` 40 | def self.default_fields(fields) 41 | @@default_fields = BSON.new(fields) 42 | end 43 | 44 | # Finds one or multiple documents and returns an array of `Moongoon::Collection` instances. 45 | # 46 | # NOTE: Documents are sorted by creation date in descending order. 47 | # 48 | # ``` 49 | # # Search for persons named Julien 50 | # users = User.find({ name: "Julien" }) 51 | # ``` 52 | # 53 | # It is possible to use optional arguments to order, paginate and control queries. 54 | # 55 | # ``` 56 | # # Order the results by birth_date 57 | # users = User.find({ name: "Julien" }, order_by: { birth: 1 }) 58 | # 59 | # # Paginate the results. 60 | # users = User.find({ name: "Julien" }, skip: 50, limit: 20) 61 | # 62 | # # Fetch only specific fields. 63 | # # Be extra careful to always fetch mandatory fields. 64 | # users = User.find({ name: "Julien" }, fields: { age: 1, name: 1 }) 65 | # ``` 66 | # 67 | # NOTE: Other arguments are available but will not be documented here. 68 | # For more details check out the underlying [`cryomongo`](https://github.com/elbywan/cryomongo) driver documentation and code. 69 | def self.find(query = BSON.new, order_by = { _id: -1 }, fields = @@default_fields, skip = 0, limit : Int? = nil, **args) : Array(self) 70 | items = [] of self 71 | 72 | if stages = @@aggregation_stages 73 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, order_by, skip, limit) 74 | self.collection.aggregate(pipeline, **args).try { |c| 75 | items = c.map{ |elt| self.from_bson(elt) }.to_a 76 | } 77 | else 78 | cursor = self.collection.find(query, **args, sort: order_by, projection: fields, skip: skip, limit: limit) 79 | items = cursor.map{ |elt| self.from_bson(elt) }.to_a 80 | end 81 | 82 | items 83 | end 84 | 85 | # NOTE: Similar to `self.find`, but raises when no documents are found. 86 | # 87 | # ``` 88 | # begin 89 | # users = User.find!({ name: "Julien" }) 90 | # rescue 91 | # raise "No one is named Julien." 92 | # end 93 | # ``` 94 | def self.find!(query, **args) : Array(self) 95 | items = self.find(query, **args) 96 | unless items.size > 0 97 | query_json = BSON.new(query).to_json 98 | ::Moongoon::Log.info { "[mongo][find!](#{self.collection_name}) No matches for query:\n#{query_json}" } 99 | raise ::Moongoon::Error::NotFound.new 100 | end 101 | items 102 | end 103 | 104 | # Finds a single document and returns a `Moongoon::Collection` instance. 105 | # 106 | # ``` 107 | # # Retrieve a single user named Julien 108 | # user = User.find_one({ name: "Julien" }) 109 | # ``` 110 | # 111 | # The following optional arguments are available. 112 | # 113 | # ``` 114 | # # Fetch only specific fields. 115 | # # Be extra careful to always fetch mandatory fields. 116 | # user = User.find_one({ name: "Julien" }, fields: { age: 1, name: 1 }) 117 | # 118 | # # Skip some results. Will return the 3rd user called Julien. 119 | # user = User.find_one({ name: "Julien"}, skip: 2) 120 | # ``` 121 | # 122 | # NOTE: Other arguments are available but will not be documented here. 123 | # For more details check out the underlying [`cryomongo`](https://github.com/elbywan/cryomongo) driver documentation and code. 124 | def self.find_one(query = BSON.new, fields = @@default_fields, order_by = { _id: -1 }, skip = 0, **args) : self? 125 | item = if stages = @@aggregation_stages 126 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, order_by, skip) 127 | cursor = self.collection.aggregate(pipeline, **args) 128 | cursor.try &.first? 129 | else 130 | self.collection.find_one(query, **args, sort: order_by, projection: fields, skip: skip) 131 | end 132 | self.new item if item 133 | end 134 | 135 | # NOTE: Similar to `self.find_one`, but raises when the document was not found. 136 | def self.find_one!(query, **args) : self 137 | item = self.find_one(query, **args) 138 | unless item 139 | query_json = BSON.new(query).to_json 140 | ::Moongoon::Log.info { "[mongo][find_one!](#{self.collection_name}) No matches for query:\n#{query_json}" } 141 | raise ::Moongoon::Error::NotFound.new 142 | end 143 | item 144 | end 145 | 146 | # Finds a single document by id and returns a `Moongoon::Collection` instance. 147 | # 148 | # Syntax is similar to `self.find_one`. 149 | # 150 | # ``` 151 | # user = User.find_by_id(123456) 152 | # ``` 153 | def self.find_by_id(id : BSON::ObjectId | String, query = BSON.new, order_by = { _id: -1 }, fields = @@default_fields, **args) : self? 154 | item = uninitialized BSON? 155 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 156 | item = if stages = @@aggregation_stages 157 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, order_by) 158 | cursor = self.collection.aggregate(pipeline, **args) 159 | cursor.try &.first? 160 | else 161 | self.collection.find_one(query, **args, sort: order_by, projection: fields, skip: 0) 162 | end 163 | self.new item if item 164 | end 165 | 166 | # NOTE: Similar to `self.find_by_id`, but raises when the document was not found. 167 | def self.find_by_id!(id, **args) : self 168 | item = self.find_by_id(id, **args) 169 | unless item 170 | ::Moongoon::Log.info { "[mongo][find_by_id!](#{self.collection_name}) Failed to fetch resource with id #{id}." } 171 | raise ::Moongoon::Error::NotFound.new 172 | end 173 | item.not_nil! 174 | end 175 | 176 | # Finds one or multiple documents by their ids and returns an array of `Moongoon::Collection` instances. 177 | # 178 | # Syntax is similar to `self.find`. 179 | # 180 | # ``` 181 | # ids = ["1", "2", "3"] 182 | # users = User.find_by_ids(ids) 183 | # ``` 184 | def self.find_by_ids(ids, query = BSON.new, order_by = { _id: -1 }, **args) : Array(self)? 185 | query = ::Moongoon::Traits::Database::Internal.concat_ids_filter(query, ids) 186 | self.find(query, order_by, **args) 187 | end 188 | 189 | # NOTE: Similar to `self.find_by_ids`, but raises when no documents are found. 190 | def self.find_by_ids!(ids, **args) : Array(self)? 191 | items = self.find_by_ids(ids, **args) 192 | unless items.size > 0 193 | ::Moongoon::Log.info { "[mongo][exists!](#{self.collection_name}) No matches for ids #{ids.to_json}." } 194 | raise ::Moongoon::Error::NotFound.new 195 | end 196 | items 197 | end 198 | 199 | # Finds ids for documents matching the *query* argument and returns them an array of strings. 200 | # 201 | # Syntax is similar to `self.find`. 202 | # 203 | # ``` 204 | # jane_ids = User.find_ids({ name: "Jane" }) 205 | # ``` 206 | def self.find_ids(query = BSON.new, order_by = { _id: -1 }, **args) : Array(String) 207 | ids = [] of String 208 | cursor = self.collection.find(query, **args, sort: order_by, projection: { _id: 1 }) 209 | while item = cursor.first? 210 | ids << item["_id"].to_s 211 | end 212 | ids 213 | end 214 | 215 | # Counts the number of documents in the collection for a given query. 216 | # 217 | # ``` 218 | # count = User.count({ name: "Julien" }) 219 | # ``` 220 | def self.count(query = BSON.new, **args) : Int32 221 | self.collection.count_documents(query, **args) 222 | end 223 | 224 | # Ensures that at least one document matches the query. 225 | # Will raise when there is no match. 226 | # 227 | # ``` 228 | # begin 229 | # User.exist!({ name: "Julien" }) 230 | # rescue e : Moongoon::Error::NotFound 231 | # # No user named Julien found 232 | # end 233 | # ``` 234 | def self.exist!(query = BSON.new, **args) : Bool 235 | count = self.count query, **args 236 | unless count > 0 237 | query_json = BSON.new(query).to_json 238 | ::Moongoon::Log.info { "[mongo][exists!](#{self.collection_name}) No matches for query:\n#{query_json}" } 239 | raise ::Moongoon::Error::NotFound.new 240 | end 241 | count > 0 242 | end 243 | 244 | # Same as `self.exist!` but for a single document given its id. 245 | # 246 | # ``` 247 | # begin 248 | # User.exist_by_id!("123456") 249 | # rescue e : Moongoon::Error::NotFound 250 | # # No user having _id "123456" found 251 | # end 252 | # ``` 253 | def self.exist_by_id!(id, query = BSON.new, **args) : Bool 254 | query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 255 | self.exist! query, **args 256 | end 257 | 258 | # Returns a fresh copy of this object that is fetched from the database. 259 | # 260 | # ``` 261 | # user = User.new(name: "John", age: 10) 262 | # User.update({ name: "John", age: 11 }) 263 | # puts user.age 264 | # # => 10 265 | # puts user.fetch.age 266 | # # => 11 267 | # ``` 268 | def fetch 269 | id_check! 270 | fresh_model = self.class.find_by_id! id! 271 | end 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /docs/Moongoon/Config.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Config - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | class Moongoon::Config 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 |

192 | 193 | 196 | 197 | Defined in: 198 |

199 | 200 | 201 | 202 | 203 | 204 | 205 |

206 | 207 | 210 | 211 | Constructors 212 |

213 |
    214 | 215 |
  • 216 | .new 217 | 218 |
  • 219 | 220 |
221 | 222 | 223 | 224 |

225 | 226 | 229 | 230 | Class Method Summary 231 |

232 |
    233 | 234 |
  • 235 | .reset 236 | 237 |
  • 238 | 239 |
  • 240 | .singleton 241 | 242 |
  • 243 | 244 |
245 | 246 | 247 | 248 |

249 | 250 | 253 | 254 | Instance Method Summary 255 |

256 | 269 | 270 | 271 | 272 | 273 | 274 |
275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 |
297 | 298 | 299 |

300 | 301 | 304 | 305 | Constructor Detail 306 |

307 | 308 |
309 |
310 | 311 | def self.new 312 | 313 | # 314 |
315 | 316 |
317 |
318 | 319 |
320 |
321 | 322 | 323 | 324 | 325 |

326 | 327 | 330 | 331 | Class Method Detail 332 |

333 | 334 |
335 |
336 | 337 | def self.reset 338 | 339 | # 340 |
341 | 342 |
343 |
344 | 345 |
346 |
347 | 348 |
349 |
350 | 351 | def self.singleton 352 | 353 | # 354 |
355 | 356 |
357 |
358 | 359 |
360 |
361 | 362 | 363 | 364 | 365 |

366 | 367 | 370 | 371 | Instance Method Detail 372 |

373 | 374 |
375 |
376 | 377 | def unset_nils : Bool 378 | 379 | # 380 |
381 | 382 |
383 |
384 | 385 |
386 |
387 | 388 |
389 |
390 | 391 | def unset_nils=(unset_nils) 392 | 393 | # 394 |
395 | 396 |
397 |
398 | 399 |
400 |
401 | 402 | 403 | 404 | 405 | 406 |
407 | 408 | 409 | 410 | -------------------------------------------------------------------------------- /docs/Moongoon.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | module Moongoon 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 |

177 | 178 | 181 | 182 | Overview 183 |

184 | 185 |

Moongoon is a MongoDB object-document mapper library.

186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 |

194 | 195 | 198 | 199 | Extended Modules 200 |

201 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 |

215 | 216 | 219 | 220 | Defined in: 221 |

222 | 223 | 224 | 225 | 226 | 227 |

228 | 229 | 232 | 233 | Constant Summary 234 |

235 | 236 |
237 | 238 |
239 | Log = ::Log.for(self) 240 |
241 | 242 | 243 |
244 | 245 | 246 | 247 | 248 | 249 |

250 | 251 | 254 | 255 | Class Method Summary 256 |

257 |
    258 | 259 |
  • 260 | .client : Mongo::Client 261 | 262 |

    Retrieves the mongodb driver client that can be used to perform low level queries

    263 | 264 |
  • 265 | 266 |
  • 267 | .client? : Mongo::Client? 268 | 269 |

    Retrieves the mongodb driver client that can be used to perform low level queries

    270 | 271 |
  • 272 | 273 |
  • 274 | .config 275 | 276 |
  • 277 | 278 |
  • 279 | .configure(&) 280 | 281 |
  • 282 | 283 |
  • 284 | .database 285 | 286 |

    The default database instance that can be used to perform low level queries.

    287 | 288 |
  • 289 | 290 |
  • 291 | .database_name : String 292 | 293 |

    The name of the default database.

    294 | 295 |
  • 296 | 297 |
  • 298 | .database_name? : String? 299 | 300 |

    The name of the default database.

    301 | 302 |
  • 303 | 304 |
305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 |
313 | 314 |
315 | 316 | 317 | 318 | 319 |

320 | 321 | 324 | 325 | Class Method Detail 326 |

327 | 328 |
329 |
330 | 331 | def self.client : Mongo::Client 332 | 333 | # 334 |
335 | 336 |
337 | 338 |

Retrieves the mongodb driver client that can be used to perform low level queries

339 | 340 |

See: https://github.com/elbywan/cryomongo

341 | 342 |
cursor = Moongoon.client["database"]["collection"].find({ "key": value })
343 | puts cursor.to_a
344 |
345 | 346 |
347 |
348 | 349 |
350 |
351 | 352 |
353 |
354 | 355 | def self.client? : Mongo::Client? 356 | 357 | # 358 |
359 | 360 |
361 | 362 |

Retrieves the mongodb driver client that can be used to perform low level queries

363 | 364 |

See: https://github.com/elbywan/cryomongo

365 | 366 |
cursor = Moongoon.client["database"]["collection"].find({ "key": value })
367 | puts cursor.to_a
368 |
369 | 370 |
371 |
372 | 373 |
374 |
375 | 376 |
377 |
378 | 379 | def self.config 380 | 381 | # 382 |
383 | 384 |
385 |
386 | 387 |
388 |
389 | 390 |
391 |
392 | 393 | def self.configure(&) 394 | 395 | # 396 |
397 | 398 |
399 |
400 | 401 |
402 |
403 | 404 |
405 |
406 | 407 | def self.database 408 | 409 | # 410 |
411 | 412 |
413 | 414 |

The default database instance that can be used to perform low level queries.

415 | 416 |

See: https://github.com/elbywan/cryomongo

417 | 418 |
db = Moongoon.database
419 | collection = db["some_collection"]
420 | data = collection.find query
421 | pp data
422 |
423 | 424 |
425 |
426 | 427 |
428 |
429 | 430 |
431 |
432 | 433 | def self.database_name : String 434 | 435 | # 436 |
437 | 438 |
439 | 440 |

The name of the default database.

441 |
442 | 443 |
444 |
445 | 446 |
447 |
448 | 449 |
450 |
451 | 452 | def self.database_name? : String? 453 | 454 | # 455 |
456 | 457 |
458 | 459 |

The name of the default database.

460 |
461 | 462 |
463 |
464 | 465 |
466 |
467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 |
475 | 476 | 477 | 478 | -------------------------------------------------------------------------------- /src/models/database/versioning.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Moongoon::Traits::Database::Versioning 3 | macro included 4 | {% verbatim do %} 5 | # Enable versioning for this collection. 6 | # 7 | # Manages a history in separate mongo collection and adds query methods. 8 | # The name of the versioning collection is equal to the name of the base 9 | # collection with a "_history" suffix appended. 10 | # 11 | # NOTE: Documents in the history collection will follow the same data model as 12 | # the base documents, except for an extra field that will contain a back 13 | # reference to the base document id. 14 | # 15 | # **Arguments** 16 | # 17 | # - *ref_field*: The name of the reference field that will point to the original document. 18 | # Defaults to the name of the Class in pascal_case with an "_id" suffix appended. 19 | # - *create_index*: if set to true, will create an index on the reference field in the history collection. 20 | # - *auto*: if the auto flag is true, every insertion and update will be recorded. 21 | # Without the auto flag, a version will only be created programatically when calling 22 | # the `create_version` methods. 23 | # - *transform*: a block that will be executed to transform the BSON document before insertion. 24 | # 25 | # ``` 26 | # class MyModel < Moongoon::Collection 27 | # include Versioning 28 | # 29 | # collection "my_model" 30 | # versioning auto: true 31 | # end 32 | # ``` 33 | macro versioning(*, ref_field = nil, auto = false, create_index = false, &transform) 34 | {% if ref_field %} 35 | @@versioning_id_field = {{ref_field.id.stringify}} 36 | {% end %} 37 | 38 | {% if transform %} 39 | @@versioning_transform = Proc(BSON, BSON, BSON).new {{transform}} 40 | {% end %} 41 | 42 | {% if create_index %} 43 | index keys: {@@versioning_id_field => 1}, collection: "#{@@collection_name}_history" 44 | {% end %} 45 | 46 | {% if auto %} 47 | # After an insertion, copy the document in the history collection. 48 | after_insert { |model| 49 | db = {{@type}}.database 50 | collection = {{@type}}.collection 51 | history_collection = {{@type}}.history_collection 52 | 53 | data = collection.find_one({_id: model._id }) 54 | 55 | if data 56 | updated_data = BSON.new 57 | data.each { |k, v| 58 | if k == "_id" 59 | updated_data[k] = BSON::ObjectId.new 60 | else 61 | updated_data[k] = v 62 | end 63 | } 64 | @@versioning_transform.try { |cb| 65 | updated_data = cb.call(updated_data, data) 66 | } 67 | updated_data[@@versioning_id_field] = data["_id"].to_s 68 | history_collection.insert_one(updated_data) 69 | end 70 | } 71 | 72 | # After an update, copy the updated document in the history collection. 73 | after_update { |model| 74 | db = {{@type}}.database 75 | collection = {{@type}}.collection 76 | history_collection = {{@type}}.history_collection 77 | 78 | data = collection.find_one({_id: model._id }) 79 | 80 | if data 81 | updated_data = BSON.new 82 | data.each { |k, v| 83 | if k == "_id" 84 | updated_data[k] = BSON::ObjectId.new 85 | else 86 | updated_data[k] = v 87 | end 88 | } 89 | @@versioning_transform.try { |cb| 90 | updated_data = cb.call(updated_data, data) 91 | } 92 | updated_data[@@versioning_id_field] = data["_id"].to_s 93 | history_collection.insert_one(updated_data) 94 | end 95 | } 96 | 97 | # After a static update, copy the document(s) in the history collection. 98 | after_update_static { |query, _| 99 | db = {{@type}}.database 100 | collection = {{@type}}.collection 101 | history_collection = {{@type}}.history_collection 102 | 103 | cursor = collection.find(query) 104 | bulk = history_collection.bulk(ordered: true) 105 | cursor.each do |model| 106 | updated_model = BSON.new 107 | model.each { |k, v| 108 | if k == "_id" 109 | updated_model[k] = BSON::ObjectId.new 110 | else 111 | updated_model[k] = v 112 | end 113 | } 114 | @@versioning_transform.try { |cb| 115 | updated_model = cb.call(updated_model, model) 116 | } 117 | updated_model[@@versioning_id_field] = model["_id"].to_s 118 | bulk.insert_one(updated_model) 119 | end 120 | bulk.execute 121 | nil 122 | } 123 | {% end %} 124 | end 125 | 126 | # Finds the latest version of a model and returns an instance of `Moongoon::Collection`. 127 | # 128 | # Same syntax as `Moongoon::Collection#find_by_id`, except that specifying the id is not needed. 129 | # 130 | # ``` 131 | # user = User.new 132 | # user.id = "123456" 133 | # user_version = user.find_latest_version 134 | # ``` 135 | def find_latest_version(**args) : self? 136 | id_check! 137 | self.class.find_latest_version_by_id(self.id, **args) 138 | end 139 | 140 | # Finds all versions of the model and returns an array of `Moongoon::Collection` instances. 141 | # 142 | # NOTE: Versions are sorted by creation date. 143 | # 144 | # ``` 145 | # user = User.new name: "Jane" 146 | # user.insert 147 | # user.create_version 148 | # versions = user.find_all_versions 149 | # ``` 150 | def find_all_versions(**args) : Array(self) 151 | id_check! 152 | self.class.find_all_versions(self.id, **args) 153 | end 154 | 155 | # Counts the number of versions associated with this model. 156 | # 157 | # ``` 158 | # user = User.new name: "Jane" 159 | # user.insert 160 | # user.create_version 161 | # nb_of_versions = User.count_versions user 162 | # ``` 163 | def count_versions(**args) : Int32 | Int64 164 | self.class.count_versions(self.id, **args) 165 | end 166 | 167 | # Saves a copy of the model in the history collection and returns the id of the copy. 168 | # 169 | # NOTE: Does not use the model data but reads the latest version from the database before copying. 170 | # 171 | # ``` 172 | # user = User.new name: "Jane" 173 | # user.insert 174 | # user.create_version 175 | # ``` 176 | def create_version : String? 177 | self.class.create_version_by_id(self.id!) 178 | end 179 | 180 | # Saves a copy with changes of the model in the history collection and 181 | # returns the id of the copy. 182 | # 183 | # The *block* argument can be used to alter the model before insertion. 184 | # 185 | # ``` 186 | # user = User.new name: "Jane", age: 20 187 | # user.insert 188 | # user.create_version &.tap { |data| 189 | # # "data" is the model representation of the document that gets copied. 190 | # data.key = data.key + 1 191 | # } 192 | # ``` 193 | def create_version(&block : self -> self) : String? 194 | self.class.create_version_by_id self.id!, &block 195 | end 196 | 197 | module Static 198 | # Finds the latest version of a model by id and returns an instance of `Moongoon::Collection`. 199 | # 200 | # Same syntax as `Moongoon::Collection.find_by_id`. 201 | # 202 | # ``` 203 | # # "123456" is an _id in the original collection. 204 | # user_version = user.find_latest_version_by_id "123456" 205 | # ``` 206 | def find_latest_version_by_id(id, fields = nil, **args) : self? 207 | history_collection = self.history_collection 208 | query = {@@versioning_id_field => id} 209 | order_by = {_id: -1} 210 | 211 | item = if stages = @@aggregation_stages 212 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, limit: 1) 213 | cursor = history_collection.aggregate(pipeline, **args) 214 | cursor.try &.first? 215 | else 216 | history_collection.find_one(query, **args, sort: order_by, skip: 0, projection: fields) 217 | end 218 | self.new item if item 219 | end 220 | 221 | # Finds a specific version of a model by id and returns an instance of `Moongoon::Collection`. 222 | # 223 | # Same syntax as `Moongoon::Collection.find_by_id`. 224 | # 225 | # ``` 226 | # # "123456" is an _id in the history collection. 227 | # user_version = user.find_specific_version "123456" 228 | # ``` 229 | def find_specific_version(id, query = BSON.new, fields = nil, skip = 0, **args) : self? 230 | history_collection = self.history_collection 231 | full_query = ::Moongoon::Traits::Database::Internal.concat_id_filter(query, id) 232 | 233 | item = if stages = @@aggregation_stages 234 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(full_query, stages, fields, skip: skip) 235 | cursor = history_collection.aggregate(pipeline, **args) 236 | cursor.try &.first? 237 | else 238 | history_collection.find_one(full_query, **args, projection: fields, skip: skip) 239 | end 240 | self.new item if item 241 | end 242 | 243 | # NOTE: Similar to `self.find_specific_version` but will raise if the version is not found. 244 | def find_specific_version!(id, **args) : self 245 | item = find_specific_version(id, **args) 246 | unless item 247 | ::Moongoon::Log.info { "[mongo][find_specific_version](#{self.collection_name}) Failed to fetch resource with id #{id}." } 248 | raise ::Moongoon::Error::NotFound.new 249 | end 250 | item 251 | end 252 | 253 | # Finds one or more versions by their ids and returns an array of `Moongoon::Collection` instances. 254 | # 255 | # NOTE: Versions are sorted by creation date in descending order. 256 | # 257 | # ``` 258 | # names = ["John", "Jane"] 259 | # ids = names.map { |name| 260 | # user = User.new name: name 261 | # user.insert 262 | # user.create_version 263 | # } 264 | # # Contains one version for both models. 265 | # versions = User.find_specific_versions ids 266 | # ``` 267 | def find_specific_versions(ids, query = BSON.new, fields = nil, skip = 0, limit = 0, order_by = {_id: -1}, **args) : Array(self) 268 | items = [] of self 269 | history_collection = self.history_collection 270 | query = ::Moongoon::Traits::Database::Internal.concat_ids_filter(query, ids) 271 | 272 | if stages = @@aggregation_stages 273 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, order_by, skip, limit) 274 | cursor = history_collection.aggregate(pipeline, **args) 275 | cursor.try { |c| items = c.map{|b| self.from_bson b}.to_a } 276 | else 277 | cursor = history_collection.find(query, **args, sort: order_by, projection: fields) 278 | items = cursor.map{|b| self.from_bson b}.to_a 279 | end 280 | 281 | items 282 | end 283 | 284 | # Finds all versions for a document matching has the *id* argument and returns an array of `Moongoon::Collection` instances. 285 | # 286 | # NOTE: Versions are sorted by creation date. 287 | # 288 | # ``` 289 | # user_id = "123456" 290 | # versions = User.find_all_versions user_id 291 | # ``` 292 | def find_all_versions(id, query = BSON.new, fields = nil, skip = 0, limit = 0, order_by = {_id: -1}, **args) : Array(self) 293 | items = [] of self 294 | history_collection = self.history_collection 295 | query = BSON.new({@@versioning_id_field => id}).append(BSON.new(query)) 296 | 297 | if stages = @@aggregation_stages 298 | pipeline = ::Moongoon::Traits::Database::Internal.format_aggregation(query, stages, fields, order_by, skip, limit) 299 | cursor = history_collection.aggregate(pipeline, **args) 300 | cursor.try { |c| items = c.map{|b| self.from_bson b}.to_a } 301 | else 302 | cursor = history_collection.find(query, **args, sort: order_by, projection: fields) 303 | items = cursor.map{|b| self.from_bson b}.to_a 304 | end 305 | 306 | items 307 | end 308 | 309 | # Counts the number of versions associated with a document that matches the *id* argument. 310 | # 311 | # ``` 312 | # user_id = "123456" 313 | # User.count_versions user_id 314 | # ``` 315 | def count_versions(id, query = BSON.new, **args) : Int32 | Int64 316 | history_collection = self.history_collection 317 | query = BSON.new({@@versioning_id_field => id}).append(BSON.new(query)) 318 | history_collection.count_documents(query, **args) 319 | end 320 | 321 | # Clears the history collection. 322 | # 323 | # NOTE: **Use with caution!** 324 | # 325 | # Will remove all the versions in the history collection. 326 | def clear_history : Nil 327 | self.history_collection.delete_many(BSON.new) 328 | end 329 | 330 | # Saves a copy of a document matching the *id* argument in the history 331 | # collection and returns the id of the copy. 332 | # 333 | # NOTE: similar to `create_version`. 334 | def create_version_by_id(id) : String? 335 | self.create_version_by_id(id) { |data| data } 336 | end 337 | 338 | # Saves a copy with changes of a document matching the *id* argument 339 | # in the history collection and returns the id of the copy. 340 | # 341 | # NOTE: similar to `create_version`. 342 | def create_version_by_id(id, &block : self -> self) : String? 343 | version_id : String? = nil 344 | original = self.find_by_id id 345 | history_collection = self.history_collection 346 | 347 | if original 348 | oid = BSON::ObjectId.new 349 | original_bson = original.to_bson 350 | original_oid = original._id 351 | version_id = oid.to_s 352 | original._id = oid 353 | original = yield original 354 | version_bson = original.to_bson 355 | updated_model = @@versioning_transform.try { |cb| version_bson = cb.call(version_bson, original_bson) } 356 | version_bson[@@versioning_id_field] = original_oid.to_s 357 | history_collection.insert_one(version_bson) 358 | end 359 | 360 | version_id 361 | end 362 | end 363 | {% end %} 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /docs/Moongoon/Database.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Moongoon::Database - moongoon master-dev 17 | 20 | 21 | 22 | 23 | 28 | 163 | 164 | 165 |
166 |

167 | 168 | module Moongoon::Database 169 | 170 |

171 | 172 | 173 | 174 | 175 | 176 |

177 | 178 | 181 | 182 | Overview 183 |

184 | 185 |

Used to connect to a MongoDB database instance.

186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |

201 | 202 | 205 | 206 | Defined in: 207 |

208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 |

219 | 220 | 223 | 224 | Instance Method Summary 225 |

226 | 264 | 265 | 266 | 267 | 268 | 269 |
270 | 271 |
272 | 273 | 274 | 275 | 276 | 277 | 278 |

279 | 280 | 283 | 284 | Instance Method Detail 285 |

286 | 287 |
288 |
289 | 290 | def after_connect(&block : Proc(Nil)) 291 | 292 | # 293 |
294 | 295 |
296 | 297 |

Pass a block that will get executed after the database has been successfully connected and after the scripts are run.

298 | 299 |
Moongoon::Database.after_connect {
300 |   # ... #
301 | }
302 |
303 | 304 |
305 |
306 | 307 |
308 |
309 | 310 |
311 |
312 | 313 | def after_connect_before_scripts(&block : Proc(Nil)) 314 | 315 | # 316 |
317 | 318 |
319 | 320 |

Pass a block that will get executed after the database has been successfully connected but before the scripts are run.

321 | 322 |
Moongoon::Database.after_connect_before_scripts {
323 |   # ... #
324 | }
325 |
326 | 327 |
328 |
329 | 330 |
331 |
332 | 333 |
334 |
335 | 336 | def before_connect(&block : Proc(Nil)) 337 | 338 | # 339 |
340 | 341 |
342 | 343 |

Pass a block that will get executed before the server tries to connect to the database.

344 | 345 |
Moongoon::Database.before_connect {
346 |   puts "Before connecting…"
347 | }
348 |
349 | 350 |
351 |
352 | 353 |
354 |
355 | 356 |
357 |
358 | 359 | def connect(database_url : String = "mongodb://localhost:27017", database_name : String = "database", *, options : Mongo::Options? = nil, max_retries = nil, reconnection_delay = 5.seconds) 360 | 361 | # 362 |
363 | 364 |
365 | 366 |

Connects to MongoDB.

367 | 368 |

Use an instance of Mongo::Options as the options argument to customize the Mongo::Client instance.

369 | 370 |

Will retry up to max_retries times to connect to the database. 371 | If max_retries is nil, will retry infinitely.

372 | 373 |

Will sleep for reconnection_delay between attempts.

374 | 375 |
# Arguments are all optional, their default values are the ones defined below:
376 | Moongoon.connect("mongodb://localhost:27017", "database", options = nil, max_retries: nil, reconnection_delay: 5.seconds)
377 |
378 | 379 |
380 |
381 | 382 |
383 |
384 | 385 |
386 |
387 | 388 | def connection_with_lock(lock_name : String, *, delay = 0.5.seconds, abort_if_locked = false, &block : Proc(Mongo::Client, Mongo::Database, Nil)) 389 | 390 | # 391 |
392 | 393 |
394 | 395 |

Acquires a database lock and yields the client and database objects.

396 | 397 |

Will acquire a lock named lock_name, polling the DB every delay to check the lock status. 398 | If abort_if_locked is true the block will not be executed and this method will return if the lock is acquired already.

399 | 400 |
# If another connection uses the "query" lock, it will wait
401 | # until this block has completed before perfoming its own work.
402 | Moongoon.connection_with_lock "query" { |client, db|
403 |   collection = db["some_collection"]
404 |   data = collection.find query
405 |   pp data
406 | }
407 |
408 | 409 |
410 |
411 | 412 |
413 |
414 | 415 | 416 | 417 | 418 | 419 |
420 | 421 | 422 | 423 | -------------------------------------------------------------------------------- /docs/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background: #FFFFFF; 3 | position: relative; 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | overflow: hidden; 9 | } 10 | 11 | body { 12 | font-family: "Avenir", "Tahoma", "Lucida Sans", "Lucida Grande", Verdana, Arial, sans-serif; 13 | color: #333; 14 | line-height: 1.5; 15 | } 16 | 17 | a { 18 | color: #263F6C; 19 | } 20 | 21 | a:visited { 22 | color: #112750; 23 | } 24 | 25 | h1, h2, h3, h4, h5, h6 { 26 | margin: 35px 0 25px; 27 | color: #444444; 28 | } 29 | 30 | h1.type-name { 31 | color: #47266E; 32 | margin: 20px 0 30px; 33 | background-color: #F8F8F8; 34 | padding: 10px 12px; 35 | border: 1px solid #EBEBEB; 36 | border-radius: 2px; 37 | } 38 | 39 | h2 { 40 | border-bottom: 1px solid #E6E6E6; 41 | padding-bottom: 5px; 42 | } 43 | 44 | body { 45 | display: flex; 46 | } 47 | 48 | .sidebar, .main-content { 49 | overflow: auto; 50 | } 51 | 52 | .sidebar { 53 | width: 30em; 54 | color: #F8F4FD; 55 | background-color: #2E1052; 56 | padding: 0 0 30px; 57 | box-shadow: inset -3px 0 4px rgba(0,0,0,.35); 58 | line-height: 1.2; 59 | z-index: 0; 60 | } 61 | 62 | .sidebar .search-box { 63 | padding: 13px 9px; 64 | } 65 | 66 | .sidebar input { 67 | display: block; 68 | box-sizing: border-box; 69 | margin: 0; 70 | padding: 5px; 71 | font: inherit; 72 | font-family: inherit; 73 | line-height: 1.2; 74 | width: 100%; 75 | border: 0; 76 | outline: 0; 77 | border-radius: 2px; 78 | box-shadow: 0px 3px 5px rgba(0,0,0,.25); 79 | transition: box-shadow .12s; 80 | } 81 | 82 | .sidebar input:focus { 83 | box-shadow: 0px 5px 6px rgba(0,0,0,.5); 84 | } 85 | 86 | .sidebar input::-webkit-input-placeholder { /* Chrome/Opera/Safari */ 87 | color: #C8C8C8; 88 | font-size: 14px; 89 | text-indent: 2px; 90 | } 91 | 92 | .sidebar input::-moz-placeholder { /* Firefox 19+ */ 93 | color: #C8C8C8; 94 | font-size: 14px; 95 | text-indent: 2px; 96 | } 97 | 98 | .sidebar input:-ms-input-placeholder { /* IE 10+ */ 99 | color: #C8C8C8; 100 | font-size: 14px; 101 | text-indent: 2px; 102 | } 103 | 104 | .sidebar input:-moz-placeholder { /* Firefox 18- */ 105 | color: #C8C8C8; 106 | font-size: 14px; 107 | text-indent: 2px; 108 | } 109 | 110 | .project-summary { 111 | padding: 9px 15px 30px 30px; 112 | } 113 | 114 | .project-name { 115 | font-size: 1.4rem; 116 | margin: 0; 117 | color: #f4f4f4; 118 | font-weight: 600; 119 | } 120 | 121 | .project-version { 122 | margin-top: 5px; 123 | display: inline-block; 124 | position: relative; 125 | } 126 | 127 | .project-version > form::after { 128 | position: absolute; 129 | right: 0; 130 | top: 0; 131 | content: "\25BC"; 132 | font-size: .6em; 133 | line-height: 1.2rem; 134 | z-index: -1; 135 | } 136 | 137 | .project-versions-nav { 138 | cursor: pointer; 139 | margin: 0; 140 | padding: 0 .9em 0 0; 141 | border: none; 142 | -moz-appearance: none; 143 | -webkit-appearance: none; 144 | appearance: none; 145 | background-color: transparent; 146 | color: inherit; 147 | font-family: inherit; 148 | font-size: inherit; 149 | line-height: inherit; 150 | } 151 | .project-versions-nav:focus { 152 | outline: none; 153 | } 154 | 155 | .project-versions-nav > option { 156 | color: initial; 157 | } 158 | 159 | .sidebar ul { 160 | margin: 0; 161 | padding: 0; 162 | list-style: none outside; 163 | } 164 | 165 | .sidebar li { 166 | display: block; 167 | position: relative; 168 | } 169 | 170 | .types-list li.hide { 171 | display: none; 172 | } 173 | 174 | .sidebar a { 175 | text-decoration: none; 176 | color: inherit; 177 | transition: color .14s; 178 | } 179 | .types-list a { 180 | display: block; 181 | padding: 5px 15px 5px 30px; 182 | } 183 | 184 | .types-list { 185 | display: block; 186 | } 187 | 188 | .sidebar a:focus { 189 | outline: 1px solid #D1B7F1; 190 | } 191 | 192 | .types-list a { 193 | padding: 5px 15px 5px 30px; 194 | } 195 | 196 | .sidebar .current > a, 197 | .sidebar a:hover { 198 | color: #866BA6; 199 | } 200 | 201 | .types-list li ul { 202 | overflow: hidden; 203 | height: 0; 204 | max-height: 0; 205 | transition: 1s ease-in-out; 206 | } 207 | 208 | .types-list li.parent { 209 | padding-left: 30px; 210 | } 211 | 212 | .types-list li.parent::before { 213 | box-sizing: border-box; 214 | content: "▼"; 215 | display: block; 216 | width: 30px; 217 | height: 30px; 218 | position: absolute; 219 | top: 0; 220 | left: 0; 221 | text-align: center; 222 | color: white; 223 | font-size: 8px; 224 | line-height: 30px; 225 | transform: rotateZ(-90deg); 226 | cursor: pointer; 227 | transition: .2s linear; 228 | } 229 | 230 | 231 | .types-list li.parent > a { 232 | padding-left: 0; 233 | } 234 | 235 | .types-list li.parent.open::before { 236 | transform: rotateZ(0); 237 | } 238 | 239 | .types-list li.open > ul { 240 | height: auto; 241 | max-height: 1000em; 242 | } 243 | 244 | .main-content { 245 | padding: 0 30px 30px 30px; 246 | width: 100%; 247 | } 248 | 249 | .kind { 250 | font-size: 60%; 251 | color: #866BA6; 252 | } 253 | 254 | .superclass-hierarchy { 255 | margin: -15px 0 30px 0; 256 | padding: 0; 257 | list-style: none outside; 258 | font-size: 80%; 259 | } 260 | 261 | .superclass-hierarchy .superclass { 262 | display: inline-block; 263 | margin: 0 7px 0 0; 264 | padding: 0; 265 | } 266 | 267 | .superclass-hierarchy .superclass + .superclass::before { 268 | content: "<"; 269 | margin-right: 7px; 270 | } 271 | 272 | .other-types-list li { 273 | display: inline-block; 274 | } 275 | 276 | .other-types-list, 277 | .list-summary { 278 | margin: 0 0 30px 0; 279 | padding: 0; 280 | list-style: none outside; 281 | } 282 | 283 | .entry-const { 284 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 285 | } 286 | 287 | .entry-const code { 288 | white-space: pre-wrap; 289 | } 290 | 291 | .entry-summary { 292 | padding-bottom: 4px; 293 | } 294 | 295 | .superclass-hierarchy .superclass a, 296 | .other-type a, 297 | .entry-summary .signature { 298 | padding: 4px 8px; 299 | margin-bottom: 4px; 300 | display: inline-block; 301 | background-color: #f8f8f8; 302 | color: #47266E; 303 | border: 1px solid #f0f0f0; 304 | text-decoration: none; 305 | border-radius: 3px; 306 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 307 | transition: background .15s, border-color .15s; 308 | } 309 | 310 | .superclass-hierarchy .superclass a:hover, 311 | .other-type a:hover, 312 | .entry-summary .signature:hover { 313 | background: #D5CAE3; 314 | border-color: #624288; 315 | } 316 | 317 | .entry-summary .summary { 318 | padding-left: 32px; 319 | } 320 | 321 | .entry-summary .summary p { 322 | margin: 12px 0 16px; 323 | } 324 | 325 | .entry-summary a { 326 | text-decoration: none; 327 | } 328 | 329 | .entry-detail { 330 | padding: 30px 0; 331 | } 332 | 333 | .entry-detail .signature { 334 | position: relative; 335 | padding: 5px 15px; 336 | margin-bottom: 10px; 337 | display: block; 338 | border-radius: 5px; 339 | background-color: #f8f8f8; 340 | color: #47266E; 341 | border: 1px solid #f0f0f0; 342 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 343 | transition: .2s ease-in-out; 344 | } 345 | 346 | .entry-detail:target .signature { 347 | background-color: #D5CAE3; 348 | border: 1px solid #624288; 349 | } 350 | 351 | .entry-detail .signature .method-permalink { 352 | position: absolute; 353 | top: 0; 354 | left: -35px; 355 | padding: 5px 15px; 356 | text-decoration: none; 357 | font-weight: bold; 358 | color: #624288; 359 | opacity: .4; 360 | transition: opacity .2s; 361 | } 362 | 363 | .entry-detail .signature .method-permalink:hover { 364 | opacity: 1; 365 | } 366 | 367 | .entry-detail:target .signature .method-permalink { 368 | opacity: 1; 369 | } 370 | 371 | .methods-inherited { 372 | padding-right: 10%; 373 | line-height: 1.5em; 374 | } 375 | 376 | .methods-inherited h3 { 377 | margin-bottom: 4px; 378 | } 379 | 380 | .methods-inherited a { 381 | display: inline-block; 382 | text-decoration: none; 383 | color: #47266E; 384 | } 385 | 386 | .methods-inherited a:hover { 387 | text-decoration: underline; 388 | color: #6C518B; 389 | } 390 | 391 | .methods-inherited .tooltip>span { 392 | background: #D5CAE3; 393 | padding: 4px 8px; 394 | border-radius: 3px; 395 | margin: -4px -8px; 396 | } 397 | 398 | .methods-inherited .tooltip * { 399 | color: #47266E; 400 | } 401 | 402 | pre { 403 | padding: 10px 20px; 404 | margin-top: 4px; 405 | border-radius: 3px; 406 | line-height: 1.45; 407 | overflow: auto; 408 | color: #333; 409 | background: #fdfdfd; 410 | font-size: 14px; 411 | border: 1px solid #eee; 412 | } 413 | 414 | code { 415 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 416 | } 417 | 418 | :not(pre) > code { 419 | background-color: rgba(40,35,30,0.05); 420 | padding: 0.2em 0.4em; 421 | font-size: 85%; 422 | border-radius: 3px; 423 | } 424 | 425 | span.flag { 426 | padding: 2px 4px 1px; 427 | border-radius: 3px; 428 | margin-right: 3px; 429 | font-size: 11px; 430 | border: 1px solid transparent; 431 | } 432 | 433 | span.flag.orange { 434 | background-color: #EE8737; 435 | color: #FCEBDD; 436 | border-color: #EB7317; 437 | } 438 | 439 | span.flag.yellow { 440 | background-color: #E4B91C; 441 | color: #FCF8E8; 442 | border-color: #B69115; 443 | } 444 | 445 | span.flag.green { 446 | background-color: #469C14; 447 | color: #E2F9D3; 448 | border-color: #34700E; 449 | } 450 | 451 | span.flag.red { 452 | background-color: #BF1919; 453 | color: #F9ECEC; 454 | border-color: #822C2C; 455 | } 456 | 457 | span.flag.purple { 458 | background-color: #2E1052; 459 | color: #ECE1F9; 460 | border-color: #1F0B37; 461 | } 462 | 463 | span.flag.lime { 464 | background-color: #a3ff00; 465 | color: #222222; 466 | border-color: #00ff1e; 467 | } 468 | 469 | .tooltip>span { 470 | position: absolute; 471 | opacity: 0; 472 | display: none; 473 | pointer-events: none; 474 | } 475 | 476 | .tooltip:hover>span { 477 | display: inline-block; 478 | opacity: 1; 479 | } 480 | 481 | .c { 482 | color: #969896; 483 | } 484 | 485 | .n { 486 | color: #0086b3; 487 | } 488 | 489 | .t { 490 | color: #0086b3; 491 | } 492 | 493 | .s { 494 | color: #183691; 495 | } 496 | 497 | .i { 498 | color: #7f5030; 499 | } 500 | 501 | .k { 502 | color: #a71d5d; 503 | } 504 | 505 | .o { 506 | color: #a71d5d; 507 | } 508 | 509 | .m { 510 | color: #795da3; 511 | } 512 | 513 | .hidden { 514 | display: none; 515 | } 516 | .search-results { 517 | font-size: 90%; 518 | line-height: 1.3; 519 | } 520 | 521 | .search-results mark { 522 | color: inherit; 523 | background: transparent; 524 | font-weight: bold; 525 | } 526 | .search-result { 527 | padding: 5px 8px 5px 5px; 528 | cursor: pointer; 529 | border-left: 5px solid transparent; 530 | transform: translateX(-3px); 531 | transition: all .2s, background-color 0s, border .02s; 532 | min-height: 3.2em; 533 | } 534 | .search-result.current { 535 | border-left-color: #ddd; 536 | background-color: rgba(200,200,200,0.4); 537 | transform: translateX(0); 538 | transition: all .2s, background-color .5s, border 0s; 539 | } 540 | .search-result.current:hover, 541 | .search-result.current:focus { 542 | border-left-color: #866BA6; 543 | } 544 | .search-result:not(.current):nth-child(2n) { 545 | background-color: rgba(255,255,255,.06); 546 | } 547 | .search-result__title { 548 | font-size: 105%; 549 | word-break: break-all; 550 | line-height: 1.1; 551 | padding: 3px 0; 552 | } 553 | .search-result__title strong { 554 | font-weight: normal; 555 | } 556 | .search-results .search-result__title > a { 557 | padding: 0; 558 | display: block; 559 | } 560 | .search-result__title > a > .args { 561 | color: #dddddd; 562 | font-weight: 300; 563 | transition: inherit; 564 | font-size: 88%; 565 | line-height: 1.2; 566 | letter-spacing: -.02em; 567 | } 568 | .search-result__title > a > .args * { 569 | color: inherit; 570 | } 571 | 572 | .search-result a, 573 | .search-result a:hover { 574 | color: inherit; 575 | } 576 | .search-result:not(.current):hover .search-result__title > a, 577 | .search-result:not(.current):focus .search-result__title > a, 578 | .search-result__title > a:focus { 579 | color: #866BA6; 580 | } 581 | .search-result:not(.current):hover .args, 582 | .search-result:not(.current):focus .args { 583 | color: #6a5a7d; 584 | } 585 | 586 | .search-result__type { 587 | color: #e8e8e8; 588 | font-weight: 300; 589 | } 590 | .search-result__doc { 591 | color: #bbbbbb; 592 | font-size: 90%; 593 | } 594 | .search-result__doc p { 595 | margin: 0; 596 | text-overflow: ellipsis; 597 | display: -webkit-box; 598 | -webkit-box-orient: vertical; 599 | -webkit-line-clamp: 2; 600 | overflow: hidden; 601 | line-height: 1.2em; 602 | max-height: 2.4em; 603 | } 604 | 605 | .js-modal-visible .modal-background { 606 | display: flex; 607 | } 608 | .main-content { 609 | position: relative; 610 | } 611 | .modal-background { 612 | position: absolute; 613 | display: none; 614 | height: 100%; 615 | width: 100%; 616 | background: rgba(120,120,120,.4); 617 | z-index: 100; 618 | align-items: center; 619 | justify-content: center; 620 | } 621 | .usage-modal { 622 | max-width: 90%; 623 | background: #fff; 624 | border: 2px solid #ccc; 625 | border-radius: 9px; 626 | padding: 5px 15px 20px; 627 | min-width: 50%; 628 | color: #555; 629 | position: relative; 630 | transform: scale(.5); 631 | transition: transform 200ms; 632 | } 633 | .js-modal-visible .usage-modal { 634 | transform: scale(1); 635 | } 636 | .usage-modal > .close-button { 637 | position: absolute; 638 | right: 15px; 639 | top: 8px; 640 | color: #aaa; 641 | font-size: 27px; 642 | cursor: pointer; 643 | } 644 | .usage-modal > .close-button:hover { 645 | text-shadow: 2px 2px 2px #ccc; 646 | color: #999; 647 | } 648 | .modal-title { 649 | margin: 0; 650 | text-align: center; 651 | font-weight: normal; 652 | color: #666; 653 | border-bottom: 2px solid #ddd; 654 | padding: 10px; 655 | } 656 | .usage-list { 657 | padding: 0; 658 | margin: 13px; 659 | } 660 | .usage-list > li { 661 | padding: 5px 2px; 662 | overflow: auto; 663 | padding-left: 100px; 664 | min-width: 12em; 665 | } 666 | .usage-modal kbd { 667 | background: #eee; 668 | border: 1px solid #ccc; 669 | border-bottom-width: 2px; 670 | border-radius: 3px; 671 | padding: 3px 8px; 672 | font-family: monospace; 673 | margin-right: 2px; 674 | display: inline-block; 675 | } 676 | .usage-key { 677 | float: left; 678 | clear: left; 679 | margin-left: -100px; 680 | margin-right: 12px; 681 | } 682 | .doc-inherited { 683 | font-weight: bold; 684 | } 685 | 686 | .anchor { 687 | float: left; 688 | padding-right: 4px; 689 | margin-left: -20px; 690 | } 691 | 692 | .main-content .anchor .octicon-link { 693 | width: 16px; 694 | height: 16px; 695 | } 696 | 697 | .main-content .anchor:focus { 698 | outline: none 699 | } 700 | 701 | .main-content h1:hover .anchor, 702 | .main-content h2:hover .anchor, 703 | .main-content h3:hover .anchor, 704 | .main-content h4:hover .anchor, 705 | .main-content h5:hover .anchor, 706 | .main-content h6:hover .anchor { 707 | text-decoration: none 708 | } 709 | 710 | .main-content h1 .octicon-link, 711 | .main-content h2 .octicon-link, 712 | .main-content h3 .octicon-link, 713 | .main-content h4 .octicon-link, 714 | .main-content h5 .octicon-link, 715 | .main-content h6 .octicon-link { 716 | visibility: hidden 717 | } 718 | 719 | .main-content h1:hover .anchor .octicon-link, 720 | .main-content h2:hover .anchor .octicon-link, 721 | .main-content h3:hover .anchor .octicon-link, 722 | .main-content h4:hover .anchor .octicon-link, 723 | .main-content h5:hover .anchor .octicon-link, 724 | .main-content h6:hover .anchor .octicon-link { 725 | visibility: visible 726 | } 727 | --------------------------------------------------------------------------------