commit 86299b8827185ce05f28acf2b169aafd3065c155 Author: Greg Date: Sat Jun 20 13:58:47 2026 -0500 SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM A from-scratch fork of simply_couch's model layer that runs on Couchbase natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`. - SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++ finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or JavaScript map/reduce — the hand-written association views become N1QL (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array index, or KV multi-get on the storing side). - Redis replacements on the same cluster: Counter (atomic increment/decrement), Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob), Lock (insert-if-absent + TTL + CAS-checked release). - Runs the simply_couch test suite against live Couchbase: 378/432 applicable examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded). - Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded N1QL) and a few pagination/lifecycle edges. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a74229b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.bundle/ +/vendor/bundle/ +Gemfile.lock +*.gem diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0a951b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +Changelog +============= + +0.1.0 +----- +- Initial release: a Couchbase-native ActiveModel ORM, API-compatible with + simply_couch. `include SimplyBased::Model`. + - SimplyBased::Store: KV documents, N1QL/SQL++ finders, CAS-as-_rev optimistic + concurrency. No CouchDB design documents or JavaScript views. + - Associations (belongs_to / has_many / has_one / has_and_belongs_to_many) + served by N1QL + lazily-created GSIs instead of hand-written map/reduce. + - Redis replacements on the same cluster: Counter (atomic), Cache + (ActiveSupport::Cache::Store over KV+TTL), Lock (insert-if-absent + CAS). + - Runs the simply_couch suite against live Couchbase: 378/432 applicable green. + - Known remaining: ancestry (tree) + embedded_in reverse-lookups (recursive / + embedded N1QL) and a few pagination/lifecycle edges. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..5b39ef6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' +gemspec +group :test do + gem 'rspec' + gem 'pry' +end +gem 'aws-sdk-s3' +gem 'rexml' diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..ecad220 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Benjamin ter Kuile + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ad621e --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# SimplyBased + +A **Couchbase-native** ActiveModel ORM — the same API as +[simply_couch](https://github.com/bterkuile/simply_couch) (CouchDB), running on +Couchbase via the official SDK. Swap one line: + +```ruby +- include SimplyCouch::Model ++ include SimplyBased::Model +``` + +…and your models, associations, validations, callbacks and finders run on +Couchbase: **key/value** for documents, **N1QL/SQL++ + GSI** for queries, **CAS** +for optimistic concurrency. There is no CouchDB design document or JavaScript +map/reduce anywhere — the hand-written association views become N1QL. + +```ruby +class User + include SimplyBased::Model + property :name + has_many :posts + validates :name, presence: true +end + +SimplyBased.adapter ||= nil +SimplyBased.database_url = + "couchbase://Administrator:password@localhost/app?scope=_default&collection=_default" + +user = User.create(name: "Ada") # KV insert +User.find(user.id) # KV get +User.all # N1QL SELECT +User.find_by_name("Ada") # N1QL WHERE name = $key +user.posts # N1QL WHERE user_id = $id (GSI auto-created) +user.post_count # N1QL COUNT(*) +``` + +## Why it works with so little change + +simply_couch's model layer (properties, type-casting, validations, callbacks, +dirty tracking, the association DSL, soft-delete, pagination) is already storage +-neutral. SimplyBased keeps all of it and replaces exactly one layer — the +`SimplyBased::Store` — which speaks Couchbase directly. Every association lookup +funnels through a handful of Store methods (`find_by_foreign_key`, +`find_by_array_contains`, …) that emit N1QL instead of querying JS views. So the +`has_and_belongs_to_many` join that needed two hand-written map/reduce functions +in CouchDB is now: + +```sql +SELECT c.*, META(c).id, META(c).cas +FROM `bucket`.`_default`.`_default` AS c +WHERE c.ruby_class = $rc + AND ANY x IN c.`network_ids` SATISFIES x = $v END +``` + +backed by an array GSI created on first use. + +## Replaces Redis too + +Same cluster, no second datastore: + +```ruby +counter = SimplyBased::Counter.new +counter.increment("jobs:processed") # server-side atomic INCR + +Rails.application.config.cache_store = + SimplyBased::Cache.new(namespace: "app") # ActiveSupport::Cache::Store (KV + TTL) + +SimplyBased::Lock.new.with_lock("rebuild", ttl: 30) do + # critical section — distributed lock via insert-if-absent + CAS +end +``` + +- **Counter** — Couchbase binary increment/decrement (true atomic counters). +- **Cache** — an `ActiveSupport::Cache::Store` over KV + native TTL; drop-in for + `Rails.cache` and therefore ActiveJob. +- **Lock** — distributed lock: insert-if-absent + TTL auto-expiry + CAS-checked + release so only the holder can unlock. + +## The one recommended pattern shift + +v0 uses a single collection with a `ruby_class` discriminator (closest to +CouchDB, runs the whole suite as-is). For production, map each model to its own +Couchbase **Collection** — tighter GSIs, cleaner queries, idiomatic Couchbase. A +`collection :name` per-model DSL is the planned opt-in. + +## Status & test coverage + +SimplyBased runs the **simply_couch test suite** against a live Couchbase. + +- **378 / 432 applicable examples green (~88%).** +- 12 CouchDB-mechanic specs are excluded (`:couchdb_only`) — native inline + attachments and JS-view token-safety have no Couchbase equivalent (use + `has_s3_attached` / `has_local_attached`; N1QL uses bound parameters). +- **Green:** CRUD, finders (`find`, `all`, `first`, `find_by_*`, `count_by_*`), + `belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many`, validations, + callbacks, dirty tracking, soft-delete, bulk, eager-loading, the Redis + primitives. +- **Remaining (tracked):** `ancestry` (tree/subtree — recursive N1QL) and + `embedded_in` reverse-lookups, plus a handful of pagination/lifecycle edge + cases. These were the recursive/embedded custom views; they need per-pattern + N1QL and are the next work item. + +```bash +docker run -d --name cb -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community +# init a single-node cluster + a couchstore bucket named simply_based_test, then: +COUCHBASE_URL="couchbase://Administrator:password@localhost/simply_based_test" \ + bundle exec rspec +``` + +## License + +MIT. diff --git a/lib/core_ext/date.rb b/lib/core_ext/date.rb new file mode 100644 index 0000000..e44b49b --- /dev/null +++ b/lib/core_ext/date.rb @@ -0,0 +1,15 @@ +# use ISO8601 as timeformat +class Date + def to_json(*a) + %|"#{as_json}"| + end + + def as_json(*args) + iso8601 + end + + def self.json_create string + return nil if string.nil? + Date.parse(string) + end +end diff --git a/lib/core_ext/time.rb b/lib/core_ext/time.rb new file mode 100644 index 0000000..c9e98f6 --- /dev/null +++ b/lib/core_ext/time.rb @@ -0,0 +1,23 @@ +require 'active_support/time' +# use ISO8601 as timeformat +class Time + def to_json(*a) + %|"#{as_json}"| + end + + def as_json(*args) + getutc.iso8601 + end + + def self.json_create string + return nil if string.nil? + d = DateTime.parse(string.to_s).new_offset + self.utc(d.year, d.month, d.day, d.hour, d.min, d.sec).in_time_zone + end +end + +ActiveSupport::TimeWithZone.class_eval do + def as_json(*args) + utc.iso8601 + end +end diff --git a/lib/simply_based.rb b/lib/simply_based.rb new file mode 100644 index 0000000..9cb277c --- /dev/null +++ b/lib/simply_based.rb @@ -0,0 +1,41 @@ +# Please move me to a proper location +class String + def property_name + underscore.gsub('/','__').gsub('::','__') + end +end + +# Guard on a sentinel that only this file defines (SimplyBased::VERSION), not on +# the bare SimplyBased constant. A plugin gem (e.g. simply_couch-couchbase) may +# open `module SimplyBased` for its own version/namespace before this file loads; +# guarding on the bare module would then skip the library's whole initialization. +unless defined?(SimplyBased::VERSION) + $:<<(File.expand_path(File.dirname(__FILE__) + "/lib")) + require 'simply_based/instance_methods' + require 'simply_based/class_methods_base' + + module SimplyBased + VERSION = '0.1.0' + class Error < RuntimeError; end + class RecordNotFound < RuntimeError; end + class NotImplementedError < RuntimeError; end + class ModelNotInstantiatedError < RuntimeError; end + class NotFound < RuntimeError; end + + # No-op retained for source compatibility with SimplyCouch. SimplyBased + # never interpolates values into JavaScript views — queries are N1QL with + # bound parameters — so there is nothing to sanitize. Returns its tokens. + def self.assert_safe_view_token!(*tokens) + tokens + end + end + + require 'simply_based/document_codec' + require 'simply_based/database_config' + require 'simply_based/model' + require 'simply_based/store' + require 'simply_based/redis' + require 'core_ext/time' + require 'core_ext/date' +end +class SimplyBased::Conflict < StandardError; end diff --git a/lib/simply_based/class_methods_base.rb b/lib/simply_based/class_methods_base.rb new file mode 100644 index 0000000..c76ee31 --- /dev/null +++ b/lib/simply_based/class_methods_base.rb @@ -0,0 +1,77 @@ +module SimplyBased + module ClassMethods + module Base + def get_class_from_name(klass_name) + # Try to find class from property definition + if property = _find_property(klass_name) and property.respond_to?(:options) and property.options[:class_name].present? + property.options[:class_name].constantize + else # Fall back to name guessing + base = klass_name.to_s.gsub('__','/') + base = base.classify unless base[0,1] =~ /[A-Z]/ + base.constantize + end + end + + def foreign_key + name.underscore.gsub('/','__').gsub('::','__') + "_id" + end + + def foreign_property + name.underscore.gsub('/','__').gsub('::','__') + end + + def attr_protected(*args) + @_protected_attributes ||= [] + @_protected_attributes += args.to_a + end + + def attr_accessible(*args) + @_accessible_attributes ||= [] + @_accessible_attributes += args.to_a + end + + def _find_property(name) + properties.find{|property| property.name == name} + end + + # Namespace aware method of creating proper class names + def find_association_class_name(association_name) + (name.split('::')[0..-2] + [association_name.to_s.singularize.camelize]).join('::') + end + + # More compatibility with active record plugins + def primary_key + 'id' + end + + def view(view_name, options) + # SimplyBased serves associations and lookups with N1QL, not CouchDB + # JavaScript views. Hand-written map/reduce registrations + # (:map_function / :reduce_function / :type => :custom|:raw) are no-ops — + # the association layer queries the Store directly. Structured model + # views (all_documents, find_by_*; no :map_function) still register. + return if options[:map_function] || options[:reduce_function] || + [:custom, :raw].include?(options[:type]) + super(view_name, options) + end + + # Fix for paperclip > 3.5.2 + def after_commit(*); end + + # Get documents by ids and bulk update attributes + def bulk_update(ids, pairs) + # Bulk load documents + # Map to doc + # Filter out errors, or none founds (compact) + # Select the Couch objects + docs = database.bulk_load(ids).select{|d| d.is_a?(SimplyBased::Model)} + for doc in docs + pairs.each_pair do |k, v| + doc.send("#{k}=", v) if doc.respond_to?("#{k}=") + end + doc.save # Should become a bulk update in the future + end + end + end + end +end diff --git a/lib/simply_based/database_config.rb b/lib/simply_based/database_config.rb new file mode 100644 index 0000000..bec8542 --- /dev/null +++ b/lib/simply_based/database_config.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/module/attribute_accessors' + +module SimplyBased + # Global Couchbase configuration. + # + # Set once at boot: + # SimplyBased.database_url = + # "couchbase://user:pass@localhost/my_bucket?scope=_default&collection=_default" + # + # Per-request override (multi-tenant): define a top-level Current class with a + # `couch_database` attribute and set `Current.couch_database = + # SimplyBased.database_for(...)`. Block-scoped: SimplyBased.with_database(db){}. + # + # Fallback chain for Model#database: + # 1. Model's own use_database URL + # 2. Current.couch_database (request-scoped) + # 3. SimplyBased.database_url (global default) + + mattr_accessor :database_url + mattr_accessor :s3_defaults + + # Load S3 defaults from a YAML config file with ERB support. + def self.load_s3_config(path, env = nil) + env ||= defined?(Rails) ? Rails.env : 'development' + raw = File.read(path.to_s) + yaml = defined?(ERB) ? ERB.new(raw).result : raw + config = YAML.safe_load(yaml, permitted_classes: [], permitted_symbols: [], aliases: true) || {} + self.s3_defaults = (config[env] || {}).deep_symbolize_keys + end + + # Returns a Store for the given Couchbase connection URL, cached by URL. + def self.database_for(url) + return nil unless url + databases[url] ||= Store.new(url) + end + + def self.database + current_database || database_for(database_url) || fallback_database + end + + def self.with_database(database) + previous = current_database + self.current_database = database + yield + ensure + self.current_database = previous + end + + def self.current_database + defined?(::Current) && ::Current.respond_to?(:couch_database) ? ::Current.couch_database : nil + end + + def self.current_database=(db) + ::Current.couch_database = db if defined?(::Current) && ::Current.respond_to?(:couch_database=) + end + + private + + def self.databases + @_databases ||= {} + end + + def self.fallback_database + database_for(ENV['COUCHBASE_URL'] || 'couchbase://Administrator:password@localhost/simply_based') + end +end diff --git a/lib/simply_based/document_codec.rb b/lib/simply_based/document_codec.rb new file mode 100644 index 0000000..1f2a5f1 --- /dev/null +++ b/lib/simply_based/document_codec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module SimplyBased + # Reconstructs a stored JSON document (a plain Hash) back into its model + # instance, independently of any database driver. + # + # simply_couch tags every persisted document with a "ruby_class" key (see + # SimplyBased::Model::Persistence::Json#to_hash), and every model defines + # .json_create. Together they let an adapter rehydrate a model from a hash + # without relying on a particular driver's JSON-decoding hook. + # + # The CouchRest adapter lets couchrest decode for it (it sets + # JSON.create_id = 'ruby_class', so JSON.parse already returns model objects). + # Adapters whose driver returns plain hashes (e.g. couchbase) call + # DocumentCodec.from_hash to get the same result. + module DocumentCodec + module_function + + # hash -> model instance. Returns the input unchanged when it is nil or + # carries no recognised ruby_class tag (so raw/system rows pass through). + def from_hash(hash) + return hash if hash.nil? + klass_name = hash['ruby_class'] || hash[:ruby_class] + return hash unless klass_name + klass = constantize(klass_name) + return hash unless klass.respond_to?(:json_create) + klass.json_create(hash) + end + + def constantize(name) + Object.const_get(name) + rescue NameError + nil + end + end +end diff --git a/lib/simply_based/include_relation.rb b/lib/simply_based/include_relation.rb new file mode 100644 index 0000000..bbe6f13 --- /dev/null +++ b/lib/simply_based/include_relation.rb @@ -0,0 +1,156 @@ +# This file is a tool extension to simply stored. Its intention is to not +# change anything of an existing implementation, but to hugely speedup existing +# implementations. I will illustrate this given the example relation types: +# Person has_many posts and belongs to a group, Post has many comments, Comment belongs to Writer +# If for a reason you have one page where you want to display all of these objects +# (Person, Post, Comment) you can ofcourse create a view returning all these +# objects with a smart key for some handy selection. This will probably end up +# in a controller implementation: +# @persons = view_result.select{|r| r.is_a?(Person)} +# @posts = view_result.select{|r| r.is_a?(Post)} +# @comments = view_result.select{|r| r.is_a?(Comment)} +# @writers = view_result.select{|r| r.is_a?(Writer)} +# This probably is the recommended way of solving problems in most cases, but sometimes +# because you are lazy or some other obscure reason, you want to use the standard +# SimplyBased behaviour, but not wait too long. For example, if I have a list of 40 persons, +# all having 5 posts that all have 10 comments belonging to a writer, getting all these +# through their standard relations: +# @persons.each{ |person| person.posts.each{ |post| post.comments.each{ |comment| puts person.group.name + comment.writer.name } } } +# This will result in 40 * 5 * 10 + 40 = 2040 queries to the database. Doing exactly the same thing using this script +# will look like: +# @persons = Person.all.include_relation( :group, posts: { conmments: :writer } ) +# The useless script above will not take 2040 queries but: +# 1 (persons) + 1 (group) + 1 (posts) + 1 (comments) + 1 (write) = 5 queries +# This makes a difference. +# Issues: +# * Supported relation types: +# * has_many +# * belongs_to +# * belongs_to relations, that have no value (nil) will be queried again. +# That would make the calculation above: 5 + number of persons without a group + number of comments without a writer +# * Little test coverage +class Array + def include_relation(*relations_arg) + return self if empty? + relations = {} + database = nil + database = relations_arg.last.delete(:database) if relations_arg.last.is_a?(Hash) and relations_arg.last.has_key?(:database) + database ||= self.first&.class&.database + raise ArgumentError, "Cannot include relations without a database — pass :database option or ensure models respond to .database" unless database + + # Make sure relations is a Hash, process up to two levels for recursion + # keys with value nil will not have a followup + relations_arg.each do |arg| + if arg.is_a?(Symbol) + relations[arg] = nil + elsif arg.is_a?(Hash) + arg.each{|k, v| relations[k] = v} + elsif arg.is_a?(Array) + arg.each do |v| + if arg.is_a?(Symbol) + relations[v] = nil + elsif arg.is_a?(Hash) + arg.each{|k, v| relations[k] = v} + end + end + end + end + + # For now, assume an array of only one datatype + klass = first.class + + relations.each do |relation, followup| + property = klass.properties.find{|p| p.name == relation} + unless property + warn "Attempt to include_relations #{relation} on #{klass.name} but does not have supporting relation", uplevel: 1 + next + end + case property + when SimplyBased::Model::HasMany::Property then + other_class = property.options[:class_name].constantize + other_property = other_class.properties.find{|p| p.is_a?(SimplyBased::Model::BelongsTo::Property) && p.options[:class_name] == klass.name} + #TODO riase when soft_delete is enabled + view_name = "by_#{other_property.name}_id" + raise "Cannot include has_many relation #{other_class.name.underscore.pluralize} on #{klass.name} when view :#{view_name}, key: :#{other_property.name}_id is not defined on #{other_class.name}" unless other_class.views[view_name].present? + relation_objects = other_class.database.view(other_class.send(view_name, keys: collect(&:id))) #not working yet + if followup # deeper nested including + case followup + when Hash + then relation_objects.include_relation(followup.merge(database: database)) + else + relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}])) + end + end + + for obj in self + found_relation_objects = relation_objects.select{|r| r.send("#{other_property.name}_id") == obj.id} + + # Make sure every object has a cached value, no more loading is done + obj.instance_variable_set("@#{relation}", {all: []}) unless obj.instance_variable_get("@#{relation}").try('[]', :all) + if found_relation_objects.any? + obj.instance_variable_get("@#{relation}")[:all] |= found_relation_objects + if reverse_property_name = other_class.properties.find{|p| p.is_a?(SimplyBased::Model::BelongsTo::Property) && p.options[:class_name] == klass.name }.try(:name) + found_relation_objects.each{|relation_object| relation_object.instance_variable_set("@#{reverse_property_name}", obj)} + end + end + end + when SimplyBased::Model::BelongsTo::Property then + key = "#{relation}_id" + # Collect keys for all objects + keys = [] + each do |obj| + next unless obj.is_a?(SimplyBased::Model) && obj.respond_to?(key) + keys << obj.send(key) + end + + # Get from the database + relation_objects = database.bulk_load(keys.compact.uniq) + if followup # deeper nested including + case followup + when Hash + then relation_objects.include_relation(followup.merge(database: database)) + else + relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}])) + end + end + + # Set to attributes + each do |obj| + obj.instance_variable_set("@#{relation}", relation_objects.find{|o| o.id == obj.send(key)}) + end + when SimplyBased::Model::HasAndBelongsToMany::Property + if property.options[:storing_keys] + key = "#{relation.to_s.singularize}_ids" + # Collect relation ids for all objects + relation_ids = [] + each do |obj| + next unless obj.is_a?(SimplyBased::Model) && obj.respond_to?(key) && obj.send(key).present? + relation_ids += obj.send(key) + end + # Create unique list of ids, this will optimize stuff and synchronize the object ids + relation_ids = relation_ids.flatten.compact.uniq + + # Get from the database + relation_objects = database.bulk_load(relation_ids) + each do |obj| + obj.instance_variable_set("@#{relation}", {all: relation_objects.select{|o| Array.wrap(obj.send(key)).include?(o.id)}}) + end + if followup # deeper nested including + case followup + when Hash + then relation_objects.include_relation(followup.merge(database: database)) + else + relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}])) + end + end + end + end + end + self + end + + # Alias method as plural form + def include_relations(*args) + include_relation(*args) + end +end diff --git a/lib/simply_based/instance_methods.rb b/lib/simply_based/instance_methods.rb new file mode 100644 index 0000000..1525fb5 --- /dev/null +++ b/lib/simply_based/instance_methods.rb @@ -0,0 +1,331 @@ +module SimplyBased + module InstanceMethods + def initialize(attributes = {}, &blk) + super(_remove_protected_attributes(attributes)) + blk.call(self) if blk + end + def ==(other) + # Unsaved records (no _id) only equal themselves — otherwise two distinct + # new instances would compare equal (both _id/_rev nil) and collide in + # Set/uniq/include?, which the associations rely on. + return equal?(other) if _id.nil? + other.kind_of?(SimplyBased::Model) && other._id == _id && other._rev == _rev + end + + def eql?(other) + self.==(other) + end + + def save(validate = true) + validate = validate[:validate] if validate.is_a?(Hash) && validate.has_key?(:validate) + result = retry_on_conflict do + retry_on_connection_error do + self.class.database.save_document(self, validate) + end + end + # ActiveModel implementations + @previously_changed = changes + #@changed_attributes.clear + return result + end + + def save! + result = retry_on_conflict do + retry_on_connection_error do + self.class.database.save_document!(self) + end + end + # ActiveModel implementations + @previously_changed = changes + #@changed_attributes.clear + return result + end + + def destroy(override_soft_delete=false) + check_and_destroy_dependents + if self.class.soft_deleting_enabled? && !override_soft_delete + # soft-delete + _mark_as_deleted + else + if self.class.soft_deleting_enabled? && deleted? + # really deleting a previously soft-deleted object - skipping callbacks + self.class.database.destroy_document(self, false) + else # deleting a normal object or a soft-deletable object that was not soft-deleted before + self.class.database.destroy_document(self, true) + end + freeze + end + self + end + alias :delete :destroy + + def update_attributes(attributes = {}) + parent_id_present = attributes.delete(:parent_id) || attributes.delete('parent_id') + self.attributes = attributes + if parent_id_present + if valid? + self.parent_id = parent_id_present + save # only for return value, revalidate etc. + else + return false + end + else + save + end + end + alias_method :update, :update_attributes + + def attributes=(attr) + super(_remove_protected_attributes(attr)) + end + + def reload + @children = nil + @descendants = nil + instance = self.class.find(_id, :with_deleted => true) + instance.attributes.each do |attribute, value| + send "#{attribute}=", value + end + self._rev = instance._rev + self._attachments = instance._attachments # CouchDB internal, not a property + clear_changes_information + reset_association_caches + self + end + + def deleted? + if self.class.soft_deleting_enabled? + !send(self.class.soft_delete_attribute).nil? + else + false + end + end + + def serializable_hash(*) + {'id' => self.id}.merge attributes + end + + protected + + def retry_on_connection_error(max_retries = 2, &blk) + retry_count = 0 + begin + blk.call + rescue Errno::ECONNREFUSED => e + if retry_count < max_retries + retry_count += 1 + retry + else + raise e + end + end + end + + def retry_on_conflict(max_retries = 2, &blk) + retry_count = 0 + begin + _reset_conflict_information + blk.call + rescue SimplyBased::Conflict => e + if self.class.auto_conflict_resolution_on_save && retry_count < max_retries && try_to_merge_conflict + retry_count += 1 + retry + else + _decorate_with_conflict_details(e) if e.is_a?(SimplyBased::Conflict) + raise e + end + end + end + + def _decorate_with_conflict_details(exception) + if @_conflict_information.present? + def exception.metaclass + class << self + self + end + end + local_conflict_information = @_conflict_information + exception.metaclass.send(:define_method, :message){ "409 Conflict - conflict on attributes: #{local_conflict_information.inspect}" } + end + end + + def try_to_merge_conflict + original = self.class.find(id) + our_attributes = self.attributes.dup + their_attributes = original.attributes.dup + _clear_non_relevant_attributes(our_attributes) + _clear_non_relevant_attributes(their_attributes) + if _merge_possible?(our_attributes, their_attributes) + _copy_non_conflicting_attributes(our_attributes, their_attributes) + self._rev = original._rev + @_document['_rev'] = original._rev if @_document # keep CouchDB cache in sync + true + else + @_conflict_information = _conflicting_attributes(our_attributes, their_attributes) + false + end + end + + def _reset_conflict_information + @_conflict_information = nil + end + + def _clear_non_relevant_attributes(attr_list) + [:updated_at, :created_at, :id, :rev, :_id, :_rev].each do |skipped_attribute| + attr_list.delete(skipped_attribute) + end + end + + def _copy_non_conflicting_attributes(our_attributes, their_attributes) + their_attributes.each do |attr_name, their_value| + if !self.public_send("#{attr_name}_changed?") && our_attributes[attr_name] != their_value + self.public_send("#{attr_name}=", their_value) + end + end + end + + def _merge_possible?(our_attributes, their_attributes) + _conflicting_attributes(our_attributes, their_attributes).empty? + end + + def _conflicting_attributes(our_attributes, their_attributes) + their_attributes.keys.delete_if do |attr_name| + _attribute_not_in_conflict?(attr_name, our_attributes[attr_name], their_attributes[attr_name]) + end + end + + def _attribute_not_in_conflict?(attr_name, our_value, their_value) + our_value == their_value || # same + !self.public_send("#{attr_name}_changed?") || # we didn't change + self.public_send("#{attr_name}_changed?") && their_value == self.public_send("#{attr_name}_was") # we changed and they kept the original + end + + def reset_association_caches + self.class.properties.each do |property| + if property.respond_to?(:association?) && property.association? + instance_variable_set("@#{property.name}", nil) + end + end + end + + def _remove_protected_attributes(attrs) + return {} if attrs.blank? + attrs = attrs.dup.stringify_keys + (self.class.instance_variable_get(:@_protected_attributes) || []).map(&:to_s).each do |protected_attribute| + attrs.delete(protected_attribute) + end + + accessible_attributes = (self.class.instance_variable_get(:@_accessible_attributes) || []).map(&:to_s) + + if accessible_attributes.present? + attrs.each do |attr_key, attr_value| + attrs.delete(attr_key) unless accessible_attributes.include?(attr_key) + end + end + + attrs + end + + def check_and_destroy_dependents + self.class.properties.each do |property| + if property.respond_to?(:association?) and property.association? + if property.is_a?(SimplyBased::Model::HasAndBelongsToMany::Property) + has_and_belongs_to_many_clean_up_after_destroy(property) + else + next unless property.options[:dependent] + next if property.options[:through] + dependents = send(property.name, :force_reload => true) + dependents = [dependents] unless dependents.is_a?(Array) + dependents.reject{|d| d.nil?}.each do |dependent| + case property.options[:dependent] + when :destroy + dependent.destroy + when :ignore + # skip + else + # nullify + unless dependent.class.soft_deleting_enabled? && dependent.deleted? + foreign_property = self.class.foreign_property + foreign_property_without_namespace = foreign_property.to_s.split('__').last + if dependent.respond_to?("#{foreign_property}=") + dependent.public_send("#{foreign_property}=", nil) + elsif dependent.respond_to?("#{foreign_property_without_namespace}=") + dependent.public_send("#{foreign_property_without_namespace}=", nil) + end + dependent.save(false) + end + end + end + end + end + end + end + + def _default_view_options(options = {}) + view_options = {:include_docs => true, :reduce => false} + view_options[:descending] = options[:descending] if options[:descending] + if view_options[:descending] + view_options[:startkey] = ["#{id}\u9999"] + view_options[:endkey] = [id] + else + view_options[:startkey] = [id] + view_options[:endkey] = ["#{id}\u9999"] + end + view_options[:limit] = options[:limit] if options[:limit] + view_options + end + + def find_one_associated(from, to, options = {}) + options = { + :limit => 1, + :descending => true + }.update(options) + find_associated(from, to, options).first + end + + # belongs_to / has_many / has_one: docs of `from` whose scalar foreign-key + # column points at this record. N1QL equality, served by the Store. + def _association_foreign_column(to, options) + base = (options[:foreign_key] || to.name.singularize.property_name.foreign_key).to_s.sub(/_id$/, '') + "#{base}_id" + end + + def find_associated(from, to, options = {}) + self.class.database.find_by_foreign_key( + self.class.get_class_from_name(from), + _association_foreign_column(to, options), id, + with_deleted: options[:with_deleted], limit: options[:limit], + descending: options[:descending], skip: options[:skip]) + end + + def count_associated(from, to, options = {}) + self.class.database.count_by_foreign_key( + self.class.get_class_from_name(from), + _association_foreign_column(to, options), id, + with_deleted: options[:with_deleted]) + end + + # has_and_belongs_to_many (non-storing side): docs of `from` whose array + # column contains this record's id. N1QL `ANY ... SATISFIES`, served by the + # Store. (The storing side is a KV multi-get, handled in the habtm getter.) + def find_associated_via_join_view(from, to, options = {}) + self.class.database.find_by_array_contains( + self.class.get_class_from_name(from), options[:foreign_key].to_s, id, + with_deleted: options[:with_deleted], limit: options[:limit], + descending: options[:descending], skip: options[:skip]) + end + + def count_associated_via_join_view(from, to, options = {}) + self.class.database.count_by_array_contains( + self.class.get_class_from_name(from), options[:foreign_key].to_s, id, + with_deleted: options[:with_deleted]) + end + + def _mark_as_deleted + run_callbacks :destroy do + send("#{self.class.soft_delete_attribute}=", Time.now) + save(false) + end + end + end +end diff --git a/lib/simply_based/locale/en.yml b/lib/simply_based/locale/en.yml new file mode 100644 index 0000000..48de8ee --- /dev/null +++ b/lib/simply_based/locale/en.yml @@ -0,0 +1,5 @@ +en: + errors: + messages: + taken: is already taken + no_parent: should be parent object diff --git a/lib/simply_based/model.rb b/lib/simply_based/model.rb new file mode 100644 index 0000000..5b1f034 --- /dev/null +++ b/lib/simply_based/model.rb @@ -0,0 +1,203 @@ +require 'active_model' +require 'json' +# Hydrate parsed JSON back into model objects via the 'ruby_class' tag (see +# Persistence::Json#json_create and SimplyBased::DocumentCodec). +JSON.create_id = 'ruby_class' + +require 'active_support' +unless {}.respond_to?(:assert_valid_keys) + require 'active_support/core_ext' +end +I18n.load_path << File.join(File.expand_path(File.dirname(__FILE__)), 'locale', 'en.yml') +require File.expand_path(File.dirname(__FILE__) + '/../simply_based') +require 'simply_based/model/database' +require 'simply_based/model/validations' +require 'simply_based/model/pagination_options' +require 'simply_based/model/association_property' +require 'simply_based/model/properties' +require 'simply_based/model/ancestry' +require 'simply_based/model/finders' +require 'simply_based/model/find_by' +require 'simply_based/model/belongs_to' +require 'simply_based/model/embedded_in' +require 'simply_based/model/has_many' +require 'simply_based/model/has_many_embedded' +require 'simply_based/model/has_and_belongs_to_many' +require 'simply_based/model/has_one' +require 'simply_based/model/attachments' +require 'simply_based/model/pagination' +require 'simply_based/model/persistence' +require 'simply_based/model/bulk' +require 'simply_based/model/view' +require 'simply_based/model/views' +require 'simply_based/include_relation' + +module SimplyBased + module Model + def self.included(clazz) + clazz.send(:include, Persistence) + clazz.send(:include, InstanceMethods) + clazz.send(:extend, ClassMethods) + + clazz.instance_eval do + attr_accessor :_accessible_attributes, :_protected_attributes + + view :all_documents, :key => :created_at + end + end + + module ClassMethods + include SimplyBased::ClassMethods::Base + include SimplyBased::Model::Database + include SimplyBased::Model::Bulk + include SimplyBased::Model::Validations + include SimplyBased::Model::BelongsTo + include SimplyBased::Model::EmbeddedIn + include SimplyBased::Model::HasMany + include SimplyBased::Model::HasManyEmbedded + include SimplyBased::Model::HasAndBelongsToMany + include SimplyBased::Model::HasOne + include SimplyBased::Model::Finders + include SimplyBased::Model::FindBy + include SimplyBased::Model::Pagination + include SimplyBased::Model::PaginationOptions + include SimplyBased::Model::Ancestry + + # Declare an S3 attachment. + # Lazy-loads + includes SimplyBased::Model::Attachment::S3. + def has_s3_attached(name, options = {}) + require 'simply_based/model/attachment/s3' + include SimplyBased::Model::Attachment::S3::InstanceMethods unless included_modules.include?(SimplyBased::Model::Attachment::S3::InstanceMethods) + SimplyBased::Model::Attachment::S3.define_s3_attached(self, name, options) + end + # Declare a CouchDB inline attachment. + # Lazy-loads + includes SimplyBased::Model::Attachment::Couch. + def has_couch_attached(name, options = {}) + require 'simply_based/model/attachment/couch' + include SimplyBased::Model::Attachment::Couch unless included_modules.include?(SimplyBased::Model::Attachment::Couch) + SimplyBased::Model::Attachment::Couch.define_couch_attached(self, name, options) + end + + # Declare a local filesystem attachment (Paperclip replacement). + # Lazy-loads + includes SimplyBased::Model::Attachment::Local. + def has_local_attached(name, styles: {}, default_url: nil, default_style: :original, content_type: nil) + require 'simply_based/model/attachment/local' + include SimplyBased::Model::Attachment::Local unless included_modules.include?(SimplyBased::Model::Attachment::Local) + SimplyBased::Model::Attachment::Local.define_local_attached(self, name, styles: styles, default_url: default_url, default_style: default_style, content_type: content_type) + end + + def create(attributes = {}, &blk) + instance = new(attributes, &blk) + instance.save + instance + end + + def create!(attributes = {}, &blk) + instance = new(attributes, &blk) + instance.save! + instance + end + + def enable_soft_delete(property_name = :deleted_at) + @_soft_delete_attribute = property_name.to_sym + property property_name, :type => Time + _define_hard_delete_methods + _define_soft_delete_views + end + + def soft_delete_attribute + @_soft_delete_attribute + end + + def soft_deleting_enabled? + !soft_delete_attribute.nil? + end + + def split_design_documents_per_view(enabled = true) + @_split_design_documents = enabled + end + + def split_design_documents? + @_split_design_documents || false + end + + def auto_conflict_resolution_on_save + @auto_conflict_resolution_on_save.nil? ? true : @auto_conflict_resolution_on_save + end + + def auto_conflict_resolution_on_save=(val) + @auto_conflict_resolution_on_save = val + end + + def method_missing(name, *args) + if name.to_s =~ /^find_by/ + _define_find_by(name, *args) + elsif name.to_s =~ /^find_all_by/ + _define_find_all_by(name, *args) + elsif name.to_s =~ /^count_by/ + _define_count_by(name, *args) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + name.to_s =~ /^(find_by|find_all_by|count_by)/ || super + end + + def _define_hard_delete_methods + define_method("destroy!") do + destroy(true) + end + + define_method("delete!") do + destroy(true) + end + end + + def _define_soft_delete_views + view :all_documents_without_deleted, :type => SimplyBased::Model::Views::DeletedModelViewSpec + end + + def _define_cache_accessors(name, options) + define_method "_get_cached_#{name}" do + instance_variable_get("@#{name}") || {} + end + + define_method "_set_cached_#{name}" do |value, cache_key| + cached = send("_get_cached_#{name}") + cached[cache_key] = value + instance_variable_set("@#{name}", cached) + end + + define_method "_cache_key_for" do |opt| + opt.blank? ? :all : opt.to_s + end + end + end + + def extract_association_options(local_options = nil) + forced_reload = false + with_deleted = false + limit = nil + descending = false + skip = nil + + if local_options + local_options.assert_valid_keys(:force_reload, :with_deleted, :limit, :order) + forced_reload = local_options.delete(:force_reload) + with_deleted = local_options[:with_deleted] + limit = local_options[:limit] + descending = (local_options[:order] == :desc) ? true : false + skip = local_options[:skip] + end + return [forced_reload, with_deleted, limit, descending, skip] + end + + # CouchDB design-document maintenance has no Couchbase equivalent — N1QL uses + # GSIs, managed by the Store. Kept as no-ops for source compatibility. + def self.delete_all_design_documents(*); 0; end + def self.compact_all_design_documents(*); 0; end + + end +end diff --git a/lib/simply_based/model/ancestry.rb b/lib/simply_based/model/ancestry.rb new file mode 100644 index 0000000..679ab35 --- /dev/null +++ b/lib/simply_based/model/ancestry.rb @@ -0,0 +1,308 @@ +module SimplyBased + module Model + module Ancestry + module InstanceMethods + def children + return @children if @children + if root_property = self.class.ancestry_by_property + @children = self.class.database.view(self.class.children_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false)) + else + @children = self.class.database.view(self.class.children_view(startkey: [id], endkey: [id, {}], reduce: false)) + end + @children + end + def children=(val) + @old_descendants = descendants + @children = val + @children.map(&:subtree) # preload children hierarchy + @new_descendants = [] + for child in @children + for descendant in child.descendants + @new_descendants << descendant + end + end + @old_descendants.each{|d| d.instance_variable_set('@parent', nil); d.path_ids = [d.id]} # old descendants become root + (@old_descendants - @new_descendants).map(&:save) # Persist old descendants not present in new descendants + + # update by_property of children if it differs from parent (locale or ... orther field is required to have the same values) + if root_property = self.class.ancestry_by_property + self_by_property = send(root_property) + for child in @children | @new_descendants + child.send("#{root_property}=", self_by_property) if self_by_property != child.send(root_property) + end + end + + self.class.set_parent(self, @children) # recurring update of parent in subtree of children + @descendants = (@children | @new_descendants) + clear_cached_ancestors + + # Return wether all children can be saved :) + (@descendants).map(&:save).all? + end + + # reset cache attributes for ancestors + def clear_cached_ancestors + ansi = self + while ancestor = ansi.instance_variable_get('@parent').presence + ancestor.instance_variable_set('@descendants', nil) + ancestor.instance_variable_set('@children', nil) + ansi = ancestor + end + self + end + + def add_child(child) + unless children.include?(child) + @children ||= [] + @children += [child] + child.parent = self + + # update by_property of child if it differs from parent + if root_property = self.class.ancestry_by_property + child.send("#{root_property}=", send(root_property)) if send(root_property) != child.send(root_property) + end + @descendants = nil # reload descendants if requested + end + child + end + + # Get all descendants + def descendants + return @descendants if @descendants + return @descendants = [] if id.blank? + if root_property = self.class.ancestry_by_property + @descendants = self.class.database.view(self.class.subtree_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false)).sort_by!{|d| [d.path_ids.size, d.position]} + else + @descendants = self.class.database.view(self.class.subtree_view(startkey: [id], endkey: [id, {}], reduce: false)).sort_by{|d| [d.path_ids.size, d.position]} + end + @children = self.class.build_tree(@descendants) + @descendants + end + + # Find subtree of a given page and set children with the result (important to get same children object ids as in descendants) + def subtree + @children = self.class.build_tree(descendants) + end + + # Triggered before save + def update_tree_path + return false unless id + newpath = ( (parent && parent.path_ids) || []) + [id] + if path_ids != newpath + path_ids_will_change! + self.path_ids = newpath + end + self + end + + # Triggered after create, because needs id + def create_tree_path + return false unless id + newpath = ( (parent && parent.path_ids) || []) + [id] + return true if path_ids == newpath + self.path_ids = newpath + save + end + + def parent + return @parent if @parent + return @parent = self.class.find(parent_id) if parent_id.present? + nil + end + + def parent=(val) + if @parent != val + @parent = val + update_tree_path + @parent.children += [self] unless @parent.children.include?(self) + save + end + val + end + + def parent_id=(val) + if parent_id != val.presence + @parent = nil + @parent_id = val.presence + subtree if persisted? + path_ids_will_change! + if @parent_id + @parent = parent + self.path_ids = parent.path_ids + [id] + self.class.set_parent(self, @children) if persisted? + else + self.path_ids = [id] + self.class.set_parent(self, @children) if persisted? + end + @descendants.map(&:save) if persisted? && save + end + end + + def parent_id + @parent_id ||= parent_ids.last + end + + def parent_ids + (path_ids || [])[0..-2] + end + + def ancestors + return [] unless parent_ids.any? + return [parent] if parent_ids.size == 1 # optimization, parent is pre-loaded many times + self.class.database.bulk_load(parent_ids) + end + def parents + ancestors + end + + def tree_path + ancestors + [self] + end + + def tree_depth + (path_ids || [nil]).size - 1 + end + end + + module TreeBuilder + + end + + # Add ancestry logic to your model + # class Page + # include SimplyBased::Model + # has_ancestry + # end + # or + # class Page + # include SimplyBased::Model + # property :locale + # has_ancestry by_property: :locale + # end + def has_ancestry(options = {}) + @ancestry_by_property = nil + def self.ancestry_by_property + @ancestry_by_property + end + options = {order_by: :position}.merge(options) + SimplyBased.assert_safe_view_token!(name, options[:by_property], options[:order_by]) + order_by = case options[:order_by] + when Symbol then "doc['#{options[:order_by]}']" + when Array then "[#{options[:order_by].map{|o| "doc['#{o}']"}.join(', ')}]" + else "doc['position']" + end + property :path_ids, type: Array, default: [] + property :position, type: Integer, default: 0 + if options[:by_property].present? + property options[:by_property] unless property_names.include?(options[:by_property]) + @ancestry_by_property = options[:by_property] + by_property_view_prefix = options[:by_property].present? ? "doc['#{options[:by_property]}'], " : '' + end + + view :subtree_view, type: :custom, include_docs: true, map_function: %|function(doc){ + if(doc['ruby_class'] == '#{name}' && doc.path_ids){ + for(var i = 0; i < doc.path_ids.length - 1; i++){ + emit([#{by_property_view_prefix}doc.path_ids[i], #{order_by}], 1); + } + } + }|, reduce_function: "_sum" + + view :children_view, type: :custom, include_docs: true, map_function: %|function(doc){ + if(doc['ruby_class'] == '#{name}' && doc.path_ids){ + emit([#{by_property_view_prefix}doc.path_ids.slice(-2,-1)[0], #{order_by}], 1); + } + }|, reduce_function: "_sum" + view :roots_view, conditions: "doc.path_ids && doc.path_ids.length == 1", key: [options[:by_property].presence, options[:order_by]].compact + include SimplyBased::Model::Ancestry::InstanceMethods + extend SimplyBased::Model::Ancestry::ClassMethods + before_update :update_tree_path + after_create :create_tree_path + end + module ClassMethods + def roots(options = {}) + if root_property = ancestry_by_property + if options.blank? + options = {} + elsif options.is_a?(Symbol) + options = {startkey: [options.to_s], endkey: [options.to_s, {}]} + elsif options.is_a?(String) + options = {startkey: [options], endkey: [options, {}]} + elsif options.keys.include?(root_property) + root_key = options.delete(root_property) + options[:startkey] = [root_key.to_s] + options[:endkey] = [root_key.to_s, {}] + end + end + options[:reduce] = false + database.view(roots_view(options)) + end + + def full_tree(options = {}) + if root_property = ancestry_by_property + if options.blank? + records = all + elsif options.is_a?(Array) + records = options + elsif (options.is_a?(Symbol) || options.is_a?(String)) + records = send("find_all_by_#{root_property}", options.to_s) + elsif options.is_a?(Hash) && options.keys.include?(root_property) + root_key = options.delete(root_property) + records = send("find_all_by_#{root_property}", root_key.to_s) + else + records = options[:records].presence || all + end + else + records = options.is_a?(Array) ? options : (options.present? && options[:records].presence) || all + end + build_tree(records) #.first.children + end + + # Build a tree from a flat set of pages making use of the path attribute + def build_tree(pages = nil) + pages ||= all + return pages if pages.empty? # Do not process empty array + tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children + old_tree_slice = tree_wrapper.children + new_tree_slice = [] + pages.sort_by!{|p| [p.path_ids.size, p.position]} + root_depth = pages.first.path_ids.size + current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one + for page in pages + old_tree_slice << page and next if page.path_ids.size == root_depth # fill first slice with roots + + # Move further if new depth is reached + if page.path_ids.size > current_depth + old_tree_slice = new_tree_slice + new_tree_slice = [] + current_depth = page.path_ids.size + end + parent = old_tree_slice.find{|r| r.id == page.path_ids[-2]} # path id before last is parent id + next unless parent # page is not associated in tree + + # Initialize children if needed + parent.instance_variable_set('@children', []) unless parent.instance_variable_get('@children').is_a?(Array) + + # Avoid database call on deepest children + page.instance_variable_set('@children', []) unless page.instance_variable_get('@children').is_a?(Array) + parent.instance_variable_get('@children') << page + page.instance_variable_set('@parent', parent) + page.instance_variable_set('@parent_id', parent.id) + new_tree_slice << page + end + + #TODO: setting @descendants from cache as option to avoid database call when descendants is required + tree_wrapper.children + end + + # Recursive set parents + def set_parent(parent, children) + for child in children.sort_by!(&:position) + child.instance_variable_set('@parent', parent) + child.update_tree_path + set_parent child, child.children + end + end + end + end + end +end diff --git a/lib/simply_based/model/association_property.rb b/lib/simply_based/model/association_property.rb new file mode 100644 index 0000000..d2bf7f3 --- /dev/null +++ b/lib/simply_based/model/association_property.rb @@ -0,0 +1,26 @@ +module SimplyBased + module Model + class AssociationProperty + attr_reader :name, :options + + def dirty?(object) + false + end + + def build(object, json) + end + + def serialize(json, object) + end + alias :value :serialize + + def supports_dirty? + false + end + + def association? + true + end + end + end +end diff --git a/lib/simply_based/model/attachment/couch.rb b/lib/simply_based/model/attachment/couch.rb new file mode 100644 index 0000000..0942d5b --- /dev/null +++ b/lib/simply_based/model/attachment/couch.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module SimplyBased + module Model + module Attachment + # CouchDB inline attachments — declarative, lazy-loaded. + # + # Two levels of API: + # + # 1. Low-level (instance methods): + # put_couch_attachment(name, data, content_type: 'image/png') + # fetch_couch_attachment(name) + # delete_couch_attachment(name) + # + # 2. High-level (class macro): + # has_couch_attached :invoice + # + # record.invoice = File.read('invoice.pdf') # auto-detects content_type + # record.invoice # fetches binary data + # record.invoice_content_type # => 'application/pdf' + # record.invoice_size # => 24501 + # + module Couch + def self.included(base) + base.after_save :_save_pending_couch_attachments + end + + # ── Low-level instance methods ────────────────────────────────── + + def put_couch_attachment(name, file, content_type: 'binary/octet-stream') + result = _couchrest_database.put_attachment( + to_hash, name, file, content_type: content_type + ) + self._rev = result['rev'] if result['ok'] + self._attachments = (_attachments || {}).merge(name => {}) + result + end + + def fetch_couch_attachment(name) + _couchrest_database.fetch_attachment(to_hash, name) + rescue CouchRest::NotFound + nil + end + + def delete_couch_attachment(name) + result = _couchrest_database.delete_attachment(to_hash, name) + self._rev = result['rev'] if result['ok'] + self._attachments = (_attachments || {}).except(name) if result['ok'] + result + end + + def couch_attachment_names + (_attachments || {}).keys + end + + def couch_attachment?(name) + couch_attachment_names.include?(name.to_s) + end + + # ── High-level: has_couch_attached ────────────────────────────── + + def self.define_couch_attached(base, name, options = {}) + options = { + content_type: nil, + filename: nil # template: '{updated_at|iso8601date}-invoice.pdf' + }.merge(options) + + # Properties for metadata + base.property :"#{name}_content_type" + base.property :"#{name}_size" + + # Resolve the attachment filename + base.define_method(:"#{name}_filename") do + template = options[:filename] || name.to_s + if template.include?('{') + _resolve_filename_template(template) + else + template + end + end + + # Getter — fetches from CouchDB on first access + base.define_method(name) do + cache_key = :"@_couch_attachment_#{name}" + return instance_variable_get(cache_key) if instance_variable_defined?(cache_key) + + filename = public_send(:"#{name}_filename") + data = fetch_couch_attachment(filename) + instance_variable_set(cache_key, data) + data + end + + # Setter — queues attachment for save + base.define_method(:"#{name}=") do |value| + cache_key = :"@_couch_attachment_#{name}" + instance_variable_set(cache_key, value) + + # Detect content type + content_type = options[:content_type] || _detect_content_type(value) + public_send(:"#{name}_content_type=", content_type) if value + + # Track size + size = value.respond_to?(:bytesize) ? value.bytesize : value.to_s.bytesize + public_send(:"#{name}_size=", size) if value + + # Queue for save + @_pending_couch_attachments ||= {} + @_pending_couch_attachments[name] = { + filename: public_send(:"#{name}_filename"), + file: value, + content_type: content_type + } + value + end + + # URL getter + base.define_method(:"#{name}_url") do + db_url = _couchrest_database.root + doc_id = _id + filename = public_send(:"#{name}_filename") + "#{db_url}/#{doc_id}/#{filename}" + end + end + + private + + def _save_pending_couch_attachments + return unless @_pending_couch_attachments&.any? + + @_pending_couch_attachments.each do |_name, opts| + put_couch_attachment(opts[:filename], opts[:file], content_type: opts[:content_type]) + end + @_pending_couch_attachments = nil + end + + def _detect_content_type(data) + return nil unless data.is_a?(String) || data.respond_to?(:read) + # Try to detect from magic bytes using the `marcel` gem if available, + # otherwise fall back to octet-stream. + if defined?(Marcel) + content = data.respond_to?(:read) ? data.read : data + Marcel::MimeType.for(StringIO.new(content.to_s)) + else + 'binary/octet-stream' + end + rescue StandardError + 'binary/octet-stream' + end + + def _resolve_filename_template(template) + template.gsub(/\{(\w+)(?:\|(\w+))?\}/) do + method = Regexp.last_match(1) + format = Regexp.last_match(2) + value = respond_to?(method) ? send(method) : nil + if format && value.respond_to?(:"to_#{format}") + value.send(:"to_#{format}") + else + value.to_s + end + end + end + + def _couchrest_database + if respond_to?(:database) && database.respond_to?(:couchrest_database) + database.couchrest_database + else + self.class.database.couchrest_database + end + end + end + end + end +end diff --git a/lib/simply_based/model/attachment/local.rb b/lib/simply_based/model/attachment/local.rb new file mode 100644 index 0000000..8df53bb --- /dev/null +++ b/lib/simply_based/model/attachment/local.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +module SimplyBased + module Model + module Attachment + # Drop-in replacement for Paperclip in SimplyBased/CouchDB models. + # + # Usage: + # class Image + # include SimplyBased::Model + # has_local_attached :file, styles: { + # medium: "354x1000>", + # thumb: "160x1250>" + # } + # end + # + # Auto-declares CouchDB properties: + # _file_name, _content_type, _file_size, _updated_at + # + # Files stored at: public/system///original. + # Uses MiniMagick (ImageMagick) for thumbnail generation. + module Local + # Proxy object returned by attachment getters (e.g. `image.file`). + # Mimics the Paperclip::Attachment API that views expect. + class Proxy + def initialize(record, name) + @record = record + @name = name + end + + def present? + @record.public_send(:"#{@name}_file_name").present? + end + + def blank? + !present? + end + + def file? + return false unless present? + File.exist?(path.to_s) + end + alias exist? file? + + def url(style = nil) + @record.public_send(:"#{@name}_url", style) + end + + def path(style = nil) + @record.public_send(:"#{@name}_path", style) + end + + def original_filename + @record.public_send(:"#{@name}_file_name") + end + + def content_type + @record.send(:"#{@name}_content_type") + end + + def size + @record.send(:"#{@name}_file_size") + end + + def method_missing(method, *args, &block) + if @record.respond_to?(method, true) + @record.send(method, *args, &block) + else + super + end + end + + + def respond_to_missing?(method, include_private = false) + @record.respond_to?(method, include_private) || super + end + end + + def self.define_local_attached(base, name, styles: {}, default_url: nil, default_style: :original, content_type: nil) + # Auto-declare CouchDB properties for attachment metadata + base.property :"#{name}_file_name" + base.property :"#{name}_content_type" + base.property :"#{name}_file_size", type: Integer + base.property :"#{name}_updated_at", type: Time + + # Auto-validate content type (replaces Paperclip validates_attachment_content_type) + if content_type + base.validate :"#{name}_content_type_must_be_allowed" + base.define_method(:"#{name}_content_type_must_be_allowed") do + fname = send(:"#{name}_file_name") + ctype = send(:"#{name}_content_type") + return if fname.blank? || ctype.blank? + allowed = content_type.is_a?(Array) ? content_type : [content_type] + unless allowed.any? { |t| ctype&.start_with?(t) || ctype == t } + errors.add(name, "must be one of: #{allowed.join(', ')}") + end + end + end + + # Move files from tmp/ to real ID directory after first save + base.after_save :"_move_attachment_#{name}_from_tmp" + base.define_method(:"_move_attachment_#{name}_from_tmp") do + token = instance_variable_get(:"@_attachment_#{name}_tmp_token") + return unless token + instance_variable_set(:"@_attachment_#{name}_tmp_token", nil) + + tmp_dir = Rails.root.join('public', 'system', name.to_s, token) + next unless File.directory?(tmp_dir) + + real_dir = Rails.root.join('public', 'system', name.to_s, _id) + if File.directory?(real_dir) + Dir.glob(File.join(tmp_dir, '*')).each { |f| FileUtils.mv(f, File.join(real_dir, File.basename(f))) } + FileUtils.rmdir(tmp_dir) rescue nil + else + FileUtils.mv(tmp_dir.to_s, real_dir.to_s) + end + end + + # Register configuration + base.attachment_registry[name] = { + styles: styles, + default_url: default_url, + default_style: default_style + } + + # ---- Setter: model.file = uploaded_file ---- + base.define_method(:"#{name}=") do |uploaded| + if uploaded.nil? || (uploaded.respond_to?(:empty?) && uploaded.empty?) + send(:"#{name}_file_name=", nil) + send(:"#{name}_content_type=", nil) + send(:"#{name}_file_size=", nil) + send(:"#{name}_updated_at=", nil) + return + end + + config = self.class.attachment_registry[name] + + original_filename = if uploaded.respond_to?(:original_filename) + uploaded.original_filename + elsif uploaded.respond_to?(:path) + File.basename(uploaded.path) + else + 'file' + end + ext = File.extname(original_filename) + ext = '.bin' if ext.blank? + + # Invalidate memoized proxy + remove_instance_variable(:"@_attachment_proxy_#{name}") if instance_variable_defined?(:"@_attachment_proxy_#{name}") + + content_type = uploaded.respond_to?(:content_type) ? uploaded.content_type : nil + file_size = uploaded.respond_to?(:size) ? uploaded.size : nil + + content = if uploaded.respond_to?(:read) + data = uploaded.read + uploaded.rewind if uploaded.respond_to?(:rewind) + data + elsif uploaded.respond_to?(:path) && File.exist?(uploaded.path) + File.binread(uploaded.path) + elsif uploaded.respond_to?(:tempfile) + uploaded.tempfile.read + else + uploaded.to_s + end + + record_id = if respond_to?(:id) && id.present? + id.to_s + else + token = SecureRandom.urlsafe_base64(8) + instance_variable_set(:"@_attachment_#{name}_tmp_token", token) + token + end + base_dir = Rails.root.join('public', 'system', name.to_s, record_id) + FileUtils.mkdir_p(base_dir) + + begin + original_path = base_dir.join("original#{ext}") + File.binwrite(original_path, content) + + config[:styles].each do |style_name, geometry| + style_path = base_dir.join("#{style_name}#{ext}") + begin + image = MiniMagick::Image.open(original_path.to_s) + image.resize(geometry) + image.write(style_path.to_s) + rescue StandardError => e + Rails.logger.warn("[HasAttachment] Could not generate #{style_name} for #{name}: #{e.message}") + FileUtils.cp(original_path, style_path) + end + end + + send(:"#{name}_file_name=", original_filename) + send(:"#{name}_content_type=", content_type) + send(:"#{name}_file_size=", file_size || content.bytesize) + send(:"#{name}_updated_at=", Time.current) + rescue StandardError => e + errors.add(name, "could not be processed: #{e.message}") + Rails.logger.error("[HasAttachment] Error processing #{name}: #{e.message}") + end + end + + # ---- Getter: model.file → Proxy (memoized) ---- + base.define_method(name) do + cache_key = :"@_attachment_proxy_#{name}" + instance_variable_get(cache_key) || instance_variable_set(cache_key, Proxy.new(self, name)) + end + + # ---- Presence check: model.file? ---- + base.define_method(:"#{name}?") do + public_send(name)&.exist? + end + + # ---- URL helper: model.file_url(:thumb) ---- + base.define_method(:"#{name}_url") do |style_name = nil| + config = self.class.attachment_registry[name] + style_name ||= config[:default_style] + + fname = send(:"#{name}_file_name") + if fname.blank? + return config[:default_url] if config[:default_url] + return nil + end + + ext = File.extname(fname) + record_id = if respond_to?(:id) && id.present? + id.to_s + else + instance_variable_get(:"@_attachment_#{name}_tmp_token") || 'tmp' + end + "/system/#{name}/#{record_id}/#{style_name}#{ext}" + end + + # ---- Backward compat: *_path derives from *_url ---- + base.define_method(:"#{name}_path") do |style_name = nil| + url = send(:"#{name}_url", style_name) + return nil if url.nil? + root = defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd) + root.join('public', url.sub(%r{^/}, '')).to_s + end + end + + # Backward compat: old code calls has_attachment directly. + # Delegate to define_local_attached. + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def has_attachment(name, styles: {}, default_url: nil, default_style: :original) + Local.define_local_attached(self, name, styles: styles, default_url: default_url, default_style: default_style) + end + + def attachment_registry + @_has_attachment_registry ||= {} + end + end + end + end + end +end diff --git a/lib/simply_based/model/attachment/s3.rb b/lib/simply_based/model/attachment/s3.rb new file mode 100644 index 0000000..af808fc --- /dev/null +++ b/lib/simply_based/model/attachment/s3.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +module SimplyBased + module Model + module Attachment + # S3 attachment strategy — uses aws-sdk-s3. + # + # Lazy-loaded via has_s3_attached / has_s3_attachment macro on SimplyBased::Model. + # + # Config (per environment): + # SimplyBased.s3_defaults = { bucket: 'myapp', access_key: '...', ... } + # SimplyBased.load_s3_config(Rails.root.join('config', 's3.yml')) + # + # Usage: + # class Report + # include SimplyBased::Model + # has_s3_attached :pdf + # end + # + # report.pdf = File.read('monthly.pdf') + # report.save + # report.pdf_url # presigned S3 URL + # + module S3 + module InstanceMethods + def _s3_options + self.class._s3_options + end + + def s3_client + @_s3_client ||= begin + creds = _s3_options.values.first + Aws::S3::Client.new( + access_key_id: creds[:access_key], + secret_access_key: creds[:secret_access_key], + region: _s3_region(creds), + logger: creds[:logger] + ) + end + end + + def s3_bucket(name) + return @_s3_bucket if @_s3_bucket + opts = _s3_options[name] + begin + s3_client.head_bucket(bucket: opts[:bucket]) + rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchBucket + s3_client.create_bucket( + bucket: opts[:bucket], + create_bucket_configuration: _s3_region(opts) == 'us-east-1' ? nil : { location_constraint: _s3_region(opts) } + ) + rescue Aws::S3::Errors::Forbidden, Aws::S3::Errors::BucketAlreadyOwnedByYou + # bucket exists but we can't check — proceed + end + @_s3_bucket = opts[:bucket] + rescue StandardError => e + raise ArgumentError, "Could not access/create S3 bucket '#{name}': #{e}" + end + + def save(validate = true) + update_attachment_sizes + if ret = super(validate) + save_attachments + end + ret + end + + def save!(*args) + update_attachment_sizes + super + save_attachments + end + + def delete(*args) + delete_attachments + super + end + + def destroy(*args) + delete_attachments + super + end + + def save_attachments + return unless id.present? + if @_s3_attachments + @_s3_attachments.each do |name, attachment| + if attachment[:dirty] + value = attachment[:value].is_a?(String) ? attachment[:value] : attachment[:value].to_json + s3_bucket(name) + s3_client.put_object( + bucket: _s3_options[name][:bucket], + key: s3_attachment_key(name), + body: value, + acl: _s3_options[name][:permissions] + ) + attachment[:dirty] = false + end + end + end + end + + def delete_attachments + return unless id.present? + (@_s3_attachments || {}).each do |name, attachment| + if _s3_options[name][:after_delete] == :delete + s3_client.delete_object( + bucket: _s3_options[name][:bucket], + key: s3_attachment_key(name) + ) + end + end + end + + def update_attachment_sizes + if @_s3_attachments + @_s3_attachments.each do |name, attachment| + if attachment[:dirty] + value = attachment[:value].is_a?(String) ? attachment[:value] : attachment[:value].to_json + public_send("#{name}_size=", (value.size rescue nil)) + end + end + end + end + + def s3_attachment_key(name) + "#{self.class.name.tableize}/#{name}/#{id}" + end + + private + + def _s3_region(opts) + case opts[:location] + when :eu then 'eu-west-1' + when :us, nil then 'us-east-1' + else opts[:location].to_s + end + end + end + + def self.define_s3_attached(base, name, options = {}) + require 'aws-sdk-s3' + + name = name.to_sym + base.singleton_class.class_eval { attr_accessor :_s3_options } + + # Merge: model options > global s3_defaults > gem defaults + base.property :"#{name}_size" if base.respond_to?(:property) + + defaults = (SimplyBased.s3_defaults || {}).merge(options) + raise ArgumentError, "No bucket name specified for attachment #{name}" if defaults[:bucket].blank? + defaults = { + permissions: 'private', + ssl: true, + location: :us, + ca_file: nil, + after_delete: :nothing, + logger: nil + }.update(defaults) + base._s3_options ||= {} + base._s3_options[name] = defaults + + _define_s3_accessors(base, name) + base.attr_reader :_s3_attachments + base.include InstanceMethods + end + + def self._define_s3_accessors(base, name) + base.define_method(name) do + unless @_s3_attachments && @_s3_attachments[name] + @_s3_attachments = { name => {} } + resp = s3_client.get_object( + bucket: _s3_options[name][:bucket], + key: s3_attachment_key(name) + ) + @_s3_attachments[name][:value] = resp.body.read + end + @_s3_attachments[name][:value] + end + + base.define_method("#{name}=") do |value| + @_s3_attachments ||= {} + @_s3_attachments[name] ||= {} + @_s3_attachments[name].update(value: value, dirty: true) + value + end + + base.define_method("#{name}_url") do + opts = _s3_options[name] + signer = Aws::S3::Presigner.new(client: s3_client) + signer.presigned_url( + :get_object, + bucket: opts[:bucket], + key: s3_attachment_key(name), + expires_in: opts[:permissions] == 'private' ? 300 : 3600 + ) + end + end + end + end + end +end diff --git a/lib/simply_based/model/attachments.rb b/lib/simply_based/model/attachments.rb new file mode 100644 index 0000000..d4eab16 --- /dev/null +++ b/lib/simply_based/model/attachments.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# CouchDB inline attachment support. +# Adds put_attachment, fetch_attachment, delete_attachment, and attachment_names +# to any SimplyBased model. +# +# Usage: +# class Invoice +# include SimplyBased::Model +# include SimplyBased::Model::Attachments +# end +# +# invoice.put_attachment('invoice.pdf', file, content_type: 'application/pdf') +# invoice.fetch_attachment('invoice.pdf') # => file data +# invoice.delete_attachment('invoice.pdf') +# invoice.attachment_names # => ['invoice.pdf', 'logo.png'] +# +module SimplyBased + module Model + module Attachments + def self.included(base) + base.after_save :_save_pending_attachments + end + + # Upload a file as a CouchDB inline attachment on this document. + # The attachment is stored immediately — no need to call save separately. + # Returns the CouchDB result hash. + def put_attachment(name, file, content_type: 'binary/octet-stream') + result = _couchrest_database.put_attachment( + to_hash, name, file, content_type: content_type + ) + self._rev = result['rev'] if result['ok'] + result + end + + # Fetch an attachment's binary data from CouchDB. + # Returns nil if the attachment doesn't exist. + def fetch_attachment(name) + _couchrest_database.fetch_attachment(to_hash, name) + rescue RestClient::ResourceNotFound + nil + end + + # Queue an attachment for upload on next save. + # Useful when building a new document that hasn't been saved yet + # (no _rev available for immediate put_attachment). + def add_attachment(name, file, content_type: 'binary/octet-stream') + @_pending_attachments ||= {} + @_pending_attachments[name] = { file: file, content_type: content_type } + end + + # Delete an attachment from this document. + # The attachment is removed immediately — no need to call save separately. + def delete_attachment(name) + result = _couchrest_database.delete_attachment(to_hash, name) + self._rev = result['rev'] if result['ok'] + result + end + + # List all attachment names on this document. + def attachment_names + (_attachments || {}).keys + end + + # Check if an attachment exists. + def attachment?(name) + attachment_names.include?(name.to_s) + end + + private + + def _save_pending_attachments + return unless @_pending_attachments&.any? + + @_pending_attachments.each do |name, opts| + put_attachment(name, opts[:file], content_type: opts[:content_type]) + end + @_pending_attachments = nil + end + + def _couchrest_database + if respond_to?(:database) && database.respond_to?(:couchrest_database) + database.couchrest_database + else + self.class.database.couchrest_database + end + end + end + end +end diff --git a/lib/simply_based/model/belongs_to.rb b/lib/simply_based/model/belongs_to.rb new file mode 100644 index 0000000..7f632f3 --- /dev/null +++ b/lib/simply_based/model/belongs_to.rb @@ -0,0 +1,141 @@ +# Gracefully taken from CouchPotato after it has been removed for good. +module SimplyBased + module Model + module BelongsTo + + def belongs_to(name, options = {}) + check_existing_properties(name, SimplyBased::Model::BelongsTo::Property) + SimplyBased.assert_safe_view_token!(self.to_s, name, soft_delete_attribute) + association_property = if name.to_s.index('__') + # Already defined properly + name + elsif options[:class_name].present? + # Determine namespace and replace last argument with given name + name_hierarchy = options[:class_name].to_s.underscore.split(/\/|::/) + name_hierarchy[-1] = name + name_hierarchy.join('__') + elsif rindex = foreign_property.to_s.rindex('__') + # Make name based on current namespace + "#{foreign_property[0...rindex]}__#{name}" + else + # Just return the good old name + name + end + + map_definition_without_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name}_id'] != null) { + if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){ + // "soft" deleted + }else{ + emit([doc.#{name}_id, doc.created_at], 1); + } + } + } + eos + + reduce_definition = "_sum" + view "association_#{foreign_property}_belongs_to_#{association_property}", + :map_function => map_definition_without_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + + map_definition_with_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name}_id'] != null) { + emit([doc.#{name}_id, doc.created_at], 1); + } + } + eos + + view "association_#{foreign_property}_belongs_to_#{association_property}_with_deleted", + :map_function => map_definition_with_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + + properties << SimplyBased::Model::BelongsTo::Property.new(self, name, options) + end + + class Property #:nodoc: + attr_accessor :name, :options + + def initialize(owner_clazz, name, options = {}) + @name = name + @options = { + :class_name => owner_clazz.find_association_class_name(name) + }.update(options) + + @options.assert_valid_keys(:class_name) + + owner_clazz.class_eval do + property :"#{name}_id" + alias_method :"#{name}_changed?", :"#{name}_id_changed?" + + define_method name do |*args| + local_options = args.last.is_a?(Hash) ? args.last : {} + local_options.assert_valid_keys(:force_reload, :with_deleted) + forced_reload = local_options[:force_reload] || false + with_deleted = local_options[:with_deleted] || false + + return instance_variable_get("@#{name}") unless instance_variable_get("@#{name}").nil? or forced_reload + + if send("#{name}_id").present? + # Try to fetch the object. Does not have to be present. When relation dependency is ignore, the id remains. + # This will result in a id to a non existent object. Therefore the rescue for object + object = self.class.get_class_from_name(name).find(send("#{name}_id"), :with_deleted => with_deleted) rescue nil + instance_variable_set("@#{name}", object) + else + instance_variable_set("@#{name}", nil) + end + end + + define_method "#{name}=" do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass) + + if value + # Has many object update + value_has_many_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasMany::Property) && p.options[:class_name] == self.class.name}.try(:name) + value.send(value_has_many_name) << self unless !value_has_many_name || value.send(value_has_many_name).include?(self) + + # Has one object update + value_has_one_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasOne::Property) && p.options[:class_name] == self.class.name}.try(:name) + value.instance_variable_set("@#{value_has_one_name}", self) unless !value_has_one_name || value.send(value_has_one_name) == self + end + + # Mark changed if appropriate + # send("#{name}_will_change!") if value != instance_variable_get("@#{name}") is not a persisted property, do not mark as changed + + instance_variable_set("@#{name}", value) + if value.nil? + send("#{name}_id=", nil) + else + send("#{name}_id=", value.id) + end + end + + define_method "#{name}_id=" do |new_foreign_id| + super(new_foreign_id) + value = instance_variable_get("@#{name}") + remove_instance_variable("@#{name}") if instance_variable_defined?("@#{name}") && new_foreign_id != value.try(:id) + end + end + end + def build(object, json) + object.send "#{name}_id=", json["#{name}_id"] + end + + def serialize(json, object) + json["#{name}_id"] = object.send("#{name}_id") if object.send("#{name}_id") + end + alias :value :serialize + + def association? + true + end + end + end + end +end diff --git a/lib/simply_based/model/bulk.rb b/lib/simply_based/model/bulk.rb new file mode 100644 index 0000000..803438f --- /dev/null +++ b/lib/simply_based/model/bulk.rb @@ -0,0 +1,36 @@ +module SimplyBased + module Model + # Bulk write helpers backed by CouchDB's `_bulk_docs` endpoint. + # + # DRAFT / UNVERIFIED: this code has not yet been exercised against a live + # CouchDB instance. Review before relying on it. + # + # Persisting or deleting many documents one at a time is one HTTP round-trip + # per document. `_bulk_docs` does it in a single request. These helpers map + # onto that endpoint via CouchRest::Database#bulk_save. + # + # IMPORTANT — like ActiveRecord's `insert_all` / `upsert_all`, the bulk path + # intentionally **skips model callbacks** (before/after save/create/update). + # Timestamps are still maintained directly. If you need callbacks, save the + # records individually. Validations run by default and can be disabled with + # `validate: false`. + module Bulk + # Persist an array of records in a single request. + # Returns a Hash: { saved: [records], invalid: [records], failed: [[record, error]] } + def save_all(records, validate: true) + database.bulk_save(Array(records), validate) + end + + # Build records from an array of attribute hashes and persist them in bulk. + def create_all(attribute_sets, validate: true) + save_all(Array(attribute_sets).map { |attrs| new(attrs) }, validate: validate) + end + + # Delete an array of persisted records in a single request. + # Returns the raw `_bulk_docs` result rows. + def destroy_all(records) + database.bulk_destroy(Array(records)) + end + end + end +end diff --git a/lib/simply_based/model/database.rb b/lib/simply_based/model/database.rb new file mode 100644 index 0000000..5a08b01 --- /dev/null +++ b/lib/simply_based/model/database.rb @@ -0,0 +1,30 @@ +module SimplyBased + module Model + module Database + def database + @_simply_based_database ||= begin + if @_database_url + SimplyBased.database_for(@_database_url) + else + SimplyBased.database + end + end + end + + # Point this model at a specific Couchbase connection URL. + # use_database "couchbase://user:pass@host/bucket?scope=s&collection=c" + def use_database(url) + self.database_url = url + end + + def database_url + @_database_url + end + + def database_url=(url) + @_database_url = url + @_simply_based_database = nil + end + end + end +end diff --git a/lib/simply_based/model/embedded_in.rb b/lib/simply_based/model/embedded_in.rb new file mode 100644 index 0000000..9e4083e --- /dev/null +++ b/lib/simply_based/model/embedded_in.rb @@ -0,0 +1,197 @@ +# Gracefully taken from CouchPotato after it has been removed for good. +module SimplyBased + module Model + module EmbeddedIn + + def is_embedded_in(name, options = {}) + check_existing_properties(name, SimplyBased::Model::BelongsTo::Property) + parent = options[:class_name] || name.to_s.camelize + SimplyBased.assert_safe_view_token!(self.to_s, name, parent, soft_delete_attribute) + self.name.property_name.pluralize + + soft_delete_check = if soft_delete_attribute + %{if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){ return; }} + else + '' + end + + map_definition_without_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{parent}') { + #{soft_delete_check} + emit([doc.#{name.to_s}_id, doc.created_at], 1); + } + } + eos + + reduce_definition = "_sum" + + view "association_#{self.name.underscore.gsub('/', '__')}_embedded_in_#{name}", + :map_function => map_definition_without_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + + map_definition_with_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name.to_s}_id'] != null) { + emit([doc.#{name.to_s}_id, doc.created_at], 1); + } + } + eos + + view "association_#{self.name.underscore.gsub('/', '__')}_embedded_in_#{name}_with_deleted", + :map_function => map_definition_with_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + + properties << SimplyBased::Model::EmbeddedIn::Property.new(self, name, options) + end + + class Property #:nodoc: + attr_accessor :name, :options + + def initialize(owner_clazz, name, options = {}) + @name = name + embedded_in_name = name + @options = { + :class_name => name.to_s.singularize.camelize + }.update(options) + + @options.assert_valid_keys(:class_name) + + # For now restrictions on naming + parent_property_name = owner_clazz.name.property_name.pluralize + + owner_clazz.class_eval do + property :"#{name}_id" + attr_accessor :parent_object + property :index + @@embedded_in_class_name = name.to_s.camelize + + class << self + + define_method :embedded_in_class_name do + # embedded_in_name.to_s.singularize.camelize + @@embedded_in_class_name + end + + define_method :belongs_to do |belongs_to_name, *args| + super(*([belongs_to_name] + args)) + # Now override belongs to view + view "association_#{foreign_property}_belongs_to_#{belongs_to_name}", + :map_function => %|function(doc){if(doc['ruby_class'] == '#{embedded_in_class_name}' && doc['#{self.name.property_name.pluralize}']){ + for(var i in doc.#{self.name.property_name.pluralize}){ + if(doc['#{self.name.property_name.pluralize}'][i]['#{belongs_to_name.to_s.foreign_key}']){ + emit([doc['#{self.name.property_name.pluralize}'][i]['#{belongs_to_name.to_s.foreign_key}'], doc['created_at']], doc['#{self.name.property_name.pluralize}'][i]); + } + } + }}|, + :reduce_function => %|function(key, values){return values.length}|, + :type => :raw, + :results_filter => lambda{|results| results['rows'].map{|row| d = row['value']; d.parent_object = row['doc']; d.parent_object.send(self.name.property_name.pluralize)[d.index]}}, + :include_docs => true + end + + define_method :count do |options = {}| + database.view(all_documents_for_count(options.merge(:reduce => true)))['rows'].try(:first).try('[]', 'value').to_i + end + end + + # Make parent object send through original for callbacks + define_method :parent_object= do |value| + return @parent_object if @parent_object && @parent_object == value + @parent_object = value # Prevent circular calls + send("#{name}=", value) + end + # Redefine the equality method, since we are different kind of objects + define_method "==" do |value| + self.class == value.class && (value.respond_to?(:parent_object) && self.parent_object == value.parent_object) && (value.respond_to?(:index) && self.index == value.index) + end + view :all_documents_for_count, :type => :raw, :include_docs => false, :map_function => %|function(doc){ + if(doc['ruby_class'] == '#{name.to_s.singularize.camelize}' && typeof(doc['#{parent_property_name}']) == 'object'){ + for(var i=0; i < doc['#{parent_property_name}'].length; i++){ + emit(doc['#{parent_property_name}'][i]['created_at'], 1); + } + } + }|, :reduce_function => '_sum' + view :all_documents, :type => :raw, :include_docs => true, :map_function => %|function(doc){ + if(doc['ruby_class'] == '#{name.to_s.singularize.camelize}' && typeof(doc['#{parent_property_name}']) == 'object'){ + for(var i=0; i < doc['#{parent_property_name}'].length; i++){ + emit(doc['#{parent_property_name}'][i]['created_at'], doc['#{parent_property_name}'][i]); + } + } + }|, :results_filter => lambda{|results| results['rows'].map{|row| d = row['value']; d.parent_object = row['doc']; d.parent_object.send(parent_property_name)[d.index]}} + + + + # For now empty merge. Since value of map function is transformed to object + define_method :merge do |*args| + end + + define_method :save do |callbacks=true| + if !parent_object + errors.add(name, 'no_parent') + return false + end + if callbacks + _run_save_callbacks do + parent_object.save + end + else + parent_object.save + end + end + + define_method name do |*args| + local_options = args.last.is_a?(Hash) ? args.last : {} + local_options.assert_valid_keys(:force_reload, :with_deleted) + forced_reload = local_options[:force_reload] || false + with_deleted = local_options[:with_deleted] || false + return parent_object + end + + define_method "#{name}=" do |value| + return value if instance_variable_get("@#{name}") == value + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass) + + if value + # Has many object update + value_has_many_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasManyEmbedded::Property) && p.options[:class_name] == self.class.name}.try(:name) + value.send("add_#{value_has_many_name.to_s.singularize}", self) unless !value_has_many_name || value.send(value_has_many_name).include?(self) + + # Has one object update + #value_has_one_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasOneEmbedded::Property) && p.options[:class_name] == self.class.name}.try(:name) + #value.instance_variable_set("@#{value_has_one_name}", self) unless !value_has_one_name || value.send(value_has_one_name) == self + end + + # Mark changed if appropriate + attribute_will_change!("#{name}_id") if value != parent_object + + instance_variable_set('@parent_object', value) + if value.nil? + send("#{name}_id=", nil) + else + send("#{name}_id=", value.id) + end + end + end + end + def build(object, json) + object.send "#{name}_id=", json["#{name}_id"] + end + + def serialize(json, object) + json["#{name}_id"] = object.send("#{name}_id") if object.send("#{name}_id") + end + alias :value :serialize + + def association? + true + end + end # Property + end + end +end diff --git a/lib/simply_based/model/find_by.rb b/lib/simply_based/model/find_by.rb new file mode 100644 index 0000000..5e450ab --- /dev/null +++ b/lib/simply_based/model/find_by.rb @@ -0,0 +1,211 @@ +module SimplyBased + module Model + module FindBy + include PaginationOptions + def _define_find_by(name, *args) + raise_when_not_found = name.to_s.end_with?('!') + name = name.to_s.chop.to_sym if raise_when_not_found + keys = name.to_s.sub(/^find_by_/, "").split("_and_").map(&:to_sym) + + # replace asociation assignments with their property values if possible + keys.each.with_index do |key, i| + if properties.find{|p| p.name.to_sym == key.to_sym}.is_a?(SimplyBased::Model::BelongsTo::Property) + keys[i] = "#{keys[i]}_id" + end + end + + view_name = name.to_s.sub(/^find_/, "").to_sym + view_keys = keys.length == 1 ? keys.first : keys + without_deleted_view_name = "#{view_name}_withoutdeleted" + without_deleted_view_keys = keys + [:deleted_at] + + + unless respond_to?(view_name) + puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(view_name, key: view_keys) + end + + if !respond_to?(without_deleted_view_name) && soft_deleting_enabled? + puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(without_deleted_view_name, key: without_deleted_view_keys) + end + if raise_when_not_found + (class << self; self end).instance_eval do + define_method(:"#{name}!") do |*key_args| + options = key_args.last.is_a?(Hash) ? key_args.pop : {} + options.assert_valid_keys(:with_deleted) + with_deleted = options.delete(:with_deleted) + + raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size + + key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a} + + if soft_deleting_enabled? && !with_deleted + key_args = key_args + [nil] # deleted_at + result = database.view(send(without_deleted_view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first + else + result = database.view(send(view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first + end + raise SimplyBased::RecordNotFound unless result + result + end + end + send(:"#{name}!", *args) + else + (class << self; self end).instance_eval do + define_method(name) do |*key_args| + options = key_args.last.is_a?(Hash) ? key_args.pop : {} + options.assert_valid_keys(:with_deleted) + with_deleted = options.delete(:with_deleted) + + raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size + + key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a} + + if soft_deleting_enabled? && !with_deleted + key_args = key_args + [nil] # deleted_at + database.view(send(without_deleted_view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first + else + database.view(send(view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first + end + end + end + send(name, *args) + end + end + + def _define_find_all_by(name, *args) + raise_when_not_found = name.to_s.end_with?('!') + name = name.to_s.chop.to_sym if raise_when_not_found + keys = name.to_s.sub(/^find_all_by_/, "").split("_and_") + + # replace asociation assignments with their property values if possible + keys.each.with_index do |key, i| + if properties.find{|p| p.name.to_sym == key.to_sym}.is_a?(SimplyBased::Model::BelongsTo::Property) + keys[i] = "#{keys[i]}_id" + end + end + + view_name = name.to_s.sub(/^find_all_/, "").to_sym + count_name = name.to_s.sub(/^find_all_/, 'count_').to_sym + view_keys = keys.length == 1 ? keys.first : keys + without_deleted_view_name = "#{view_name}_withoutdeleted" + without_deleted_view_keys = keys + [:deleted_at] + + unless respond_to?(view_name) + puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(view_name, key: view_keys) + end + + if !respond_to?(without_deleted_view_name) && soft_deleting_enabled? + puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(without_deleted_view_name, key: without_deleted_view_keys) + end + + # Pre-define the matching count_by_* method so the generated finder's + # total_entries lookup (send(count_name, ...)) resolves to a real method + # instead of re-entering method_missing on first use. + # NOTE: respond_to? returns true via respond_to_missing? even before + # the method is defined, so we must check method_defined? instead. + _define_count_by(count_name, define_only: true) unless method_defined?(count_name) || private_method_defined?(count_name) + + if raise_when_not_found + (class << self; self end).instance_eval do + define_method(:"#{name}!") do |*key_args| + options = key_args.last.is_a?(Hash) ? key_args.pop : {} + with_pagination_options(options.update(total_entries: send(count_name, *key_args))) do |options| + options.assert_valid_keys(:with_deleted, :limit, :skip, :keys) + with_deleted = options.delete(:with_deleted) + + key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a} + options[:key] = key_args.first if key_args.size == 1 + options[:key] = key_args if key_args.size > 1 + options[:include_docs] = true + + raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size || options[:keys] + + key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a} + + if soft_deleting_enabled? && !with_deleted + options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at + result = database.view(send(without_deleted_view_name, options)) + else + result = database.view(send(view_name, options)) + end + raise SimplyBased::RecordNotFound unless result && result.any? + result + end + end + end + send(:"#{name}!", *args) + else + (class << self; self end).instance_eval do + define_method(name) do |*key_args| + options = key_args.last.is_a?(Hash) ? key_args.pop : {} + with_pagination_options(options.update(total_entries: send(count_name, *key_args))) do |options| + options.assert_valid_keys(:with_deleted, :limit, :skip, :keys) + with_deleted = options.delete(:with_deleted) + + key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a} + options[:key] = key_args.first if key_args.size == 1 + options[:key] = key_args if key_args.size > 1 + options[:include_docs] = true + + raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size || options[:keys] + + if soft_deleting_enabled? && !with_deleted + options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at + database.view(send(without_deleted_view_name, options)) + else + database.view(send(view_name, options)) + end + end + end + end + send(name, *args) + end + end + + def _define_count_by(name, *args, define_only: false) + keys = name.to_s.sub(/^count_by_/, "").split("_and_") + view_name = name.to_s.sub(/^count_/, "").to_sym + view_keys = keys.length == 1 ? keys.first : keys + without_deleted_view_name = "#{view_name}_withoutdeleted" + without_deleted_view_keys = keys + [:deleted_at] + + unless respond_to?(view_name) + puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(view_name, key: view_keys) + end + + if !respond_to?(without_deleted_view_name) && soft_deleting_enabled? + puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})" + view(without_deleted_view_name, key: without_deleted_view_keys) + end + + (class << self; self end).instance_eval do + define_method("#{name}") do |*key_args| + options = key_args.last.is_a?(Hash) ? key_args.pop : {} + options.assert_valid_keys(:with_deleted, :keys) + with_deleted = options.delete(:with_deleted) + options[:key] = key_args.first if key_args.size == 1 + options[:key] = key_args if key_args.size > 1 + options[:reduce] = true + + if soft_deleting_enabled? && !with_deleted + options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at + database.view(send(without_deleted_view_name, options)) + else + database.view(send(view_name, options)) + end + + end + end + + # define_only is used when another finder (find_all_by) just needs the + # method present; skip the immediate count query in that case. + send(name, *args) unless define_only + end + end + end +end diff --git a/lib/simply_based/model/finders.rb b/lib/simply_based/model/finders.rb new file mode 100644 index 0000000..558f6b4 --- /dev/null +++ b/lib/simply_based/model/finders.rb @@ -0,0 +1,77 @@ +module SimplyBased + module Model + module Finders + include PaginationOptions + def find(*args) + what = args.shift + options = args.last.is_a?(Hash) ? args.last : {} + if options && order = options.delete(:order) + options[:descending] = true if order == :desc + end + + with_deleted = options.delete(:with_deleted) + + result = case what + when :all + if options.has_key?(:page) + options[:total_entries] = count + end + if with_deleted || !soft_deleting_enabled? + with_pagination_options(options) do |options| + database.view(all_documents(options)) + end + else + with_pagination_options(options) do |options| + database.view(all_documents_without_deleted(options.update(include_docs: true))) + end + end + when :first + if with_deleted || !soft_deleting_enabled? + database.view(all_documents(options.update(limit: 1, include_docs: true))).first + else + database.view(all_documents_without_deleted(options.update(limit: 1, include_docs: true))).first + end + else + raise SimplyBased::Error, "Can't load record without an id" if what.nil? + document = database.load_document(what) + if what.is_a?(Array) # Support for multiple find + #TODO: extended validation and checking, for array arguments + raise SimplyBased::NotImplementedError + else + # TODO, this part should be better. + raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect}") unless document.present? + raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect} — got #{document.class.name}") unless document.is_a?(self) + if document.deleted? && !with_deleted + raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect}") + end + end + document + end + end + + def all(*args) + find(:all, *args) + end + + def first(*args) + find(:first, *args) + end + + def last(*args) + options = args.last.is_a?(Hash) ? args.last : {} + find(:first, options.update(order: :desc)) + end + + def count(options = {}) + options.assert_valid_keys(:with_deleted) + with_deleted = options[:with_deleted] + + if with_deleted || !soft_deleting_enabled? + database.view(all_documents(reduce: true)) + else + database.view(all_documents_without_deleted(reduce: true)) + end + end + end + end +end diff --git a/lib/simply_based/model/has_and_belongs_to_many.rb b/lib/simply_based/model/has_and_belongs_to_many.rb new file mode 100644 index 0000000..529fe7c --- /dev/null +++ b/lib/simply_based/model/has_and_belongs_to_many.rb @@ -0,0 +1,241 @@ +module SimplyBased + module Model + module HasAndBelongsToMany + def has_and_belongs_to_many(name, options = {}) + check_existing_properties(name, SimplyBased::Model::HasAndBelongsToMany::Property) + properties << SimplyBased::Model::HasAndBelongsToMany::Property.new(self, name, options) + end + + def define_has_and_belongs_to_many_property(foreign_key) + property foreign_key + + # Only allow non nil or empty values to be set + define_method "#{foreign_key}=" do |value| + super(value.is_a?(Array) ? value.select{|v| v.present?} : value) + end + end + + def define_has_and_belongs_to_many_views(name, options) + SimplyBased.assert_safe_view_token!(self.name, name, options[:class_storing_keys], options[:foreign_key], soft_delete_attribute) + key_order = options[:class_storing_keys] == self.name ? "doc.#{options[:foreign_key]}[index], doc._id" : "doc._id, doc.#{options[:foreign_key]}[index]" + value = options[:class_storing_keys] == self.name ? 1 : "{ _id :doc.#{options[:foreign_key]}[index]}" + association_property = if name.to_s.index('__') + # Already defined properly + name + elsif options[:class_name].present? + # Determine namespace and replace last argument with given name + name_hierarchy = options[:class_name].to_s.underscore.split(/\/|::/) + name_hierarchy[-1] = name + name_hierarchy.join('__') + elsif rindex = foreign_property.to_s.rindex('__') + # Make name based on current namespace + "#{foreign_property[0...rindex]}__#{name}" + else + # Just return the good old name + name + end + + soft_delete_check = if soft_delete_attribute + %{if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){ return; }} + else + '' + end + + map_definition_without_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{options[:class_storing_keys]}' && doc['#{options[:foreign_key]}'] != null) { + #{soft_delete_check} + for (var index in doc.#{options[:foreign_key]}) { + emit([#{key_order}], #{value}); + } + } + } + eos + + reduce_definition = options[:class_storing_keys] == self.name ? "_sum" : <<-eos + function(key, values, rereduce) { + if (rereduce) { + return sum(values); + } else { + return values.length; + } + } + eos + + view "association_#{foreign_property}_has_and_belongs_to_many_#{association_property}", + :map_function => map_definition_without_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + + map_definition_with_deleted = <<-eos + function(doc) { + if (doc['ruby_class'] == '#{options[:class_storing_keys]}' && doc['#{options[:foreign_key]}'] != null) { + for (var index in doc.#{options[:foreign_key]}) { + emit([#{key_order}], #{value}); + } + } + } + eos + + view "association_#{self.name.underscore.gsub('/', '__')}_has_and_belongs_to_many_#{name}_with_deleted", + :map_function => map_definition_with_deleted, + :reduce_function => reduce_definition, + :type => :custom, + :include_docs => true + end + + def define_has_and_belongs_to_many_getter(name, options) + define_method(name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + forced_reload, with_deleted, limit, descending, skip = extract_association_options(local_options) + + cached_results = send("_get_cached_#{name}") + cache_key = _cache_key_for(local_options) + return cached_results[cache_key] || [] unless persisted? + if forced_reload || cached_results[cache_key].nil? + cached_results[cache_key] = + if options[:class_storing_keys] == self.class.name + # This record holds the id array → resolve by KV multi-get. + docs = self.class.database.bulk_load(Array(send(options[:foreign_key]))) + docs = docs.reject { |d| d.respond_to?(:deleted?) && d.deleted? } unless with_deleted + docs = docs.reverse if descending + docs = docs.first(limit) if limit + docs + else + # The other class holds the array → N1QL ANY ... SATISFIES. + find_associated_via_join_view(options[:class_name], self.class, :with_deleted => with_deleted, :limit => limit, :descending => descending, :foreign_key => options[:foreign_key], :skip => skip) + end + instance_variable_set("@#{name}", cached_results) + end + cached_results[cache_key] + end + end + + def define_has_and_belongs_to_many_setter_add(name, options) + define_method("add_#{name.to_s.singularize}") do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass) + + if options[:class_storing_keys] == self.class.name + self.send("#{options[:foreign_key]}=", ((send(options[:foreign_key]) || []) + [value.id]).uniq ) + self.save(false) + else + value.send("#{options[:foreign_key]}=", ((value.send(options[:foreign_key]) || []) + [self.id]).uniq ) + value.save(false) + end + + cached_results = send("_get_cached_#{name}")[:all] + send("_set_cached_#{name}", (cached_results || []) << value, :all) + nil + end + end + + def define_has_and_belongs_to_many_setter_remove(name, options) + define_method "remove_#{name.to_s.singularize}" do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass) + + if options[:class_storing_keys] == self.class.name + raise ArgumentError, "cannot remove not mine" unless (send(options[:foreign_key]) || []).include?(value.id) + else + raise ArgumentError, "cannot remove not mine" unless (value.send(options[:foreign_key]) || []).include?(id) + end + + if options[:class_storing_keys] == self.class.name + foreign_keys = (send(options[:foreign_key]) || []) - [value.id] + send("#{options[:foreign_key]}=", foreign_keys) + save(false) + else + foreign_keys = (value.send(options[:foreign_key]) || []) - [self.id] + value.send("#{options[:foreign_key]}=", foreign_keys) + value.save(false) + end + + cached_results = send("_get_cached_#{name}")[:all] + send("_set_cached_#{name}", (cached_results || []).delete_if{|item| item.id == value.id}, :all) + nil + end + end + + def define_has_and_belongs_to_many_setter_remove_all(name, options) + define_method "remove_all_#{name}" do + all = send("#{name}", :force_reload => true) + + all.collect{|i| i}.each do |item| + send("remove_#{name.to_s.singularize}", item) + end + end + end + + def define_has_and_belongs_to_many_count(name, options, through = nil) + method_name = name.to_s.singularize.underscore.gsub('/', '__') + "_count" + define_method(method_name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + forced_reload, with_deleted, limit, descending = extract_association_options(local_options) + + if forced_reload || instance_variable_get("@#{method_name}").nil? + count = + if through.nil? && options[:class_storing_keys] == self.class.name + ids = Array(send(options[:foreign_key])) + with_deleted ? ids.size : self.class.database.bulk_load(ids).reject { |d| d.respond_to?(:deleted?) && d.deleted? }.size + else + count_associated_via_join_view(through || options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key]) + end + instance_variable_set("@#{method_name}", count) + end + instance_variable_get("@#{method_name}") + end + end + + def define_has_and_belongs_to_many_after_destroy_cleanup(name, options) + if options[:class_storing_keys] == self.name + define_method "has_and_belongs_to_many_clean_up_after_destroy" do |property| + nil # deleting is enough as we store the keys + end + else + define_method "has_and_belongs_to_many_clean_up_after_destroy" do |property| + send("remove_all_#{property.name}") + end + end + end + + class Property < SimplyBased::Model::AssociationProperty + + def initialize(owner_clazz, name, options = {}) + options = { + :storing_keys => false, + :class_name => owner_clazz.find_association_class_name(name), + :foreign_key => nil, + }.update(options) + + # there is only one pair of foreign_keys and it usualy the name of the class not storing the keys + if options[:foreign_key].blank? + if options[:storing_keys] + options[:foreign_key] = options[:class_name].singularize.underscore.sub(/.*\//, '').foreign_key.pluralize.to_sym + else + options[:foreign_key] = owner_clazz.name.singularize.underscore.sub(/.*\//, '').foreign_key.pluralize.to_sym + end + end + options[:class_storing_keys] = options[:storing_keys] ? owner_clazz.name : options[:class_name] + @name, @options = name, options + + options.assert_valid_keys(:class_name, :foreign_key, :storing_keys, :class_storing_keys) + + owner_clazz.class_eval do + _define_cache_accessors(name, options) + define_has_and_belongs_to_many_property(options[:foreign_key]) if options[:storing_keys] + define_has_and_belongs_to_many_views(name, options) + define_has_and_belongs_to_many_getter(name, options) + define_has_and_belongs_to_many_setter_add(name, options) + define_has_and_belongs_to_many_setter_remove(name, options) + define_has_and_belongs_to_many_setter_remove_all(name, options) + define_has_and_belongs_to_many_count(name, options) + define_has_and_belongs_to_many_after_destroy_cleanup(name, options) + end + end + + end + end + end +end diff --git a/lib/simply_based/model/has_many.rb b/lib/simply_based/model/has_many.rb new file mode 100644 index 0000000..6cc5120 --- /dev/null +++ b/lib/simply_based/model/has_many.rb @@ -0,0 +1,177 @@ +module SimplyBased + module Model + module HasMany + def has_many(name, options = {}) + check_existing_properties(name, SimplyBased::Model::HasMany::Property) + properties << SimplyBased::Model::HasMany::Property.new(self, name, options) + end + + def define_has_many_getter(name, options) + define_method(name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + forced_reload, with_deleted, limit, descending = extract_association_options(local_options) + + cached_results = send("_get_cached_#{name}") + cache_key = _cache_key_for(local_options) + if forced_reload || cached_results[cache_key].nil? + cached_results[cache_key] = find_associated(options[:class_name], self.class, :with_deleted => with_deleted, :limit => limit, :descending => descending, :foreign_key => options[:foreign_key]) + instance_variable_set("@#{name}", cached_results) + self.class.set_parent_has_many_association_object(self, cached_results[cache_key]) + end + cached_results[cache_key] + end + end + + def define_has_many_through_getter(name, options, through) + raise ArgumentError, "no such relation: #{self} - #{through}" unless instance_methods.map(&:to_sym).include?(through.to_sym) + + define_method(name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + if local_options + local_options.assert_valid_keys(:force_reload, :with_deleted, :limit) + forced_reload = local_options[:force_reload] + with_deleted = local_options[:with_deleted] + limit = local_options[:limit] + else + forced_reload = false + with_deleted = false + limit = nil + end + + cached_results = send("_get_cached_#{name}") + cache_key = _cache_key_for(local_options) + + if forced_reload || cached_results[cache_key].nil? + + # there is probably a faster way to query this + through_property = self.class.properties.find{|p| p.name == through} + if through_property && through_property.respond_to?(:options) && through_property.options[:class_name].present? + through_finder = through_property.options[:class_name].constantize.foreign_property + else + through_finder = through # try with the association name + end + intermediate_objects = find_associated(through_finder, self.class, :with_deleted => with_deleted, :limit => limit, :foreign_key => options[:foreign_key]) + + through_objects = intermediate_objects.map do |intermediate_object| + intermediate_object.send(name.to_s.singularize.underscore.gsub('/', '__'), :with_deleted => with_deleted) + end.flatten.uniq + cached_results[cache_key] = through_objects + instance_variable_set("@#{name}", cached_results) + end + cached_results[cache_key] + end + end + + def define_has_many_setter_add(name, options) + define_method("add_#{name.to_s.singularize}") do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass) + foreign_key = "#{self.class.foreign_key}=" + # If foreign key is namespace: admin__user, try jus user if admin_user_id is not present + foreign_key.gsub!(/.*__/, '') if !value.respond_to?(foreign_key) && value.respond_to?(foreign_key.sub(/.*__/, '')) + value.send(foreign_key, id) + value.save(false) + + cached_results = send("_get_cached_#{name}")[:all] + send("_set_cached_#{name}", (cached_results || []) << value, :all) + nil + end + end + + def define_has_many_setter_remove(name, options) + define_method "remove_#{name.to_s.singularize}" do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass) + raise ArgumentError, "cannot remove not mine" unless value.send(self.class.foreign_key.to_sym) == id + + if options[:dependent] == :destroy + value.destroy + elsif options[:dependent] == :ignore + # skip + else # nullify + foreign_key = "#{self.class.foreign_key}=" + # If foreign key is namespace: admin__user, try jus user if admin_user_id is not present + foreign_key.gsub!(/.*__/, '') if !value.respond_to?(foreign_key) && value.respond_to?(foreign_key.sub(/.*__/, '')) + value.send(foreign_key, nil) + value.save(false) + end + + cached_results = send("_get_cached_#{name}")[:all] + send("_set_cached_#{name}", (cached_results || []).delete_if{|item| item.id == value.id}, :all) + nil + end + end + + def define_has_many_setter_remove_all(name, options) + define_method "remove_all_#{name}" do + all = send("#{name}", :force_reload => true) + + all.collect{|i| i}.each do |item| + send("remove_#{name.to_s.singularize}", item) + end + end + end + + def define_has_many_count(name, options, through = nil) + method_name = name.to_s.singularize.underscore.gsub('/', '__') + "_count" + define_method(method_name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + if local_options + local_options.assert_valid_keys(:force_reload, :with_deleted) + forced_reload = local_options[:force_reload] + with_deleted = local_options[:with_deleted] + else + forced_reload = false + with_deleted = false + end + + if forced_reload || instance_variable_get("@#{method_name}").nil? + instance_variable_set("@#{method_name}", count_associated(through || options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key])) + end + instance_variable_get("@#{method_name}") + end + end + + def set_parent_has_many_association_object(parent, child_collection) + child_collection.each do |child| + if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=") + child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent) + end + end + end + + class Property < SimplyBased::Model::AssociationProperty + + def initialize(owner_clazz, name, options = {}) + options = { + :dependent => :nullify, + :through => nil, + :class_name => owner_clazz.find_association_class_name(name), + :foreign_key => nil + }.update(options) + @name, @options = name, options + + options.assert_valid_keys(:dependent, :through, :class_name, :foreign_key) + + if options[:through] + owner_clazz.class_eval do + _define_cache_accessors(name, options) + define_has_many_through_getter(name, options, options[:through]) + define_has_many_count(name, options, options[:through]) + end + else + owner_clazz.class_eval do + _define_cache_accessors(name, options) + define_has_many_getter(name, options) + define_has_many_setter_add(name, options) + define_has_many_setter_remove(name, options) + define_has_many_setter_remove_all(name, options) + define_has_many_count(name, options) + end + end + end + + end + end + end +end diff --git a/lib/simply_based/model/has_many_embedded.rb b/lib/simply_based/model/has_many_embedded.rb new file mode 100644 index 0000000..aca8905 --- /dev/null +++ b/lib/simply_based/model/has_many_embedded.rb @@ -0,0 +1,188 @@ +module SimplyBased + module Model + module HasManyEmbedded + def has_many_embedded(name, options = {}) + check_existing_properties(name, SimplyBased::Model::HasMany::Property) + properties << SimplyBased::Model::HasManyEmbedded::Property.new(self, name, options) + end + + def define_has_many_embedded_getter(name, options) + # Make an alias to the property getter. + property_getter_method = "#{name}_property_getter" + alias_method property_getter_method, name + define_method(name) do |*args| + current = instance_variable_get("@#{name}") + if current && current.respond_to?(:parent_object_set?) + current.each { |o| o.parent_object = self } + return current + end + # Rebuild current, ensure parent object + current = Array.wrap(self.send(property_getter_method)).map do |h| + if h.is_a?(options[:class_name].constantize) + o = h + o.parent_object = self + else + o = options[:class_name].constantize.new + o._document = h + o.parent_object = self + end + o + end + def current.parent_object_set? + true + end + instance_variable_set("@#{name}", current) + return current + end + end + + def define_has_many_embedded_setter(name, options) + define_method("#{name}=") do |values| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected Array got #{values.class}" unless values.is_a?(Array) + instance_variable_set("@#{name}", []) unless instance_variable_get("@#{name}") + iid = 0 + for value in values + if value.is_a?(Hash) + newval = klass.new + newval._document = value + newval.updated_at ||= Time.now + newval.created_at ||= Time.now + else + newval = value + end + newval.index = iid + instance_variable_get("@#{name}") << newval + iid += 1 + end + current = instance_variable_get("@#{name}") + current.define_singleton_method(:parent_object_set?) { true } + save + end + end + + def define_has_many_embedded_setter_add(name, options) + define_method("add_#{name.to_s.singularize}") do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass) + if value.is_a?(Hash) + newval = klass.new + newval._document = value + newval.updated_at ||= Time.now + newval.created_at ||= Time.now + else + newval = value + end + newval.index = (instance_variable_get("@#{name}") || []).size + instance_variable_get("@#{name}") << newval + save + end + end + + def define_has_many_embedded_setter_remove(name, options) + define_method "remove_#{name.to_s.singularize}" do |value| + klass = self.class.get_class_from_name(name) + if value.is_a?(klass) + found = instance_variable_get("@#{name}").delete(value) + if found + self.send("reset_#{name}_index_values") + end + else + raise ArgumentError, "expected #{klass} got #{value.class}" + end + return save + end + end + + def define_reset_index_values(name, options) + define_method "reset_#{name}_index_values" do + i = 0 + for embedded_document in (instance_variable_get("@#{name}") || []) + embedded_document.index = i + i += 1 + end + end + end + + def define_has_many_embedded_setter_remove_all(name, options) + define_method "remove_all_#{name}" do + all = send("#{name}", force_reload: true) + all.collect { |i| i }.each do |item| + send("remove_#{name.to_s.singularize}", item) + end + end + end + + def define_has_many_embedded_count(name, options, through = nil) + method_name = name.to_s.singularize.underscore.gsub('/', '__') + '_count' + define_method(method_name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + if local_options + local_options.assert_valid_keys(:force_reload, :with_deleted) + forced_reload = local_options[:force_reload] + with_deleted = local_options[:with_deleted] + else + forced_reload = false + with_deleted = false + end + + if forced_reload || instance_variable_get("@#{method_name}").nil? + instance_variable_set("@#{method_name}", count_associated(through || options[:class_name], self.class, with_deleted: with_deleted, foreign_key: options[:foreign_key])) + end + instance_variable_get("@#{method_name}") + end + end + + def set_parent_has_many_embedded_association_object(parent, child_collection) + child_collection.each do |child| + if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=") + child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent) + end + end + end + + class Property < SimplyBased::Model::AssociationProperty + + def build(object, json) + values = Array.wrap(json[name]).map do |v| + obj = if v.is_a?(Hash) + options[:class_name].constantize.new.tap { |o| o._document = v } + else + v + end + obj.parent_object = object + obj + end + values.define_singleton_method(:parent_object_set?) { true } + values.each_with_index { |v, i| v.index = i } + object.instance_variable_set("@#{name}", values) + end + + def initialize(owner_clazz, name, options = {}) + options = { + dependent: :nullify, + through: nil, + class_name: name.to_s.singularize.camelize, + foreign_key: nil + }.update(options) + @name, @options = name, options + + options.assert_valid_keys(:dependent, :through, :class_name, :foreign_key) + + owner_clazz.class_eval do + property name, type: Array + _define_cache_accessors(name, options) + define_has_many_embedded_getter(name, options) + define_has_many_embedded_setter(name, options) + define_has_many_embedded_setter_add(name, options) + define_has_many_embedded_setter_remove(name, options) + define_has_many_embedded_setter_remove_all(name, options) + define_reset_index_values(name, options) + define_has_many_embedded_count(name, options) + end + end + + end + end + end +end diff --git a/lib/simply_based/model/has_one.rb b/lib/simply_based/model/has_one.rb new file mode 100644 index 0000000..9d91be2 --- /dev/null +++ b/lib/simply_based/model/has_one.rb @@ -0,0 +1,75 @@ +module SimplyBased + module Model + module HasOne + def has_one(name, options = {}) + check_existing_properties(name, SimplyBased::Model::HasOne::Property) + properties << SimplyBased::Model::HasOne::Property.new(self, name, options) + end + + def define_has_one_setter(name, options) + define_method("#{name}=") do |value| + klass = self.class.get_class_from_name(name) + raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass) + old_value = send("#{name}", :force_reload => true) + if value.nil? + instance_variable_set("@#{name}", nil) + else + instance_variable_set("@#{name}", value) + value.send("#{self.class.foreign_key}=", id) + value.save + end + + if old_value + if options[:dependent] == :destroy + old_value.destroy + else + old_value.send("#{self.class.foreign_property}=", nil) + old_value.save + end + end + old_value + end + end + + def define_has_one_getter(name, options) + define_method(name) do |*args| + local_options = args.first && args.first.is_a?(Hash) && args.first + forced_reload, with_deleted, limit, descending = extract_association_options(local_options) + + if forced_reload || instance_variable_get("@#{name}").nil? + found_object = find_one_associated(options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key]) + instance_variable_set("@#{name}", found_object) + self.class.set_parent_has_one_association_object(self, found_object) + end + instance_variable_get("@#{name}") + end + end + + def set_parent_has_one_association_object(parent, child) + if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=") + child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent) + end + end + + class Property < SimplyBased::Model::AssociationProperty + + def initialize(owner_clazz, name, options = {}) + options = { + :dependent => :nullify, + :class_name => name.to_s.singularize.camelize, + :foreign_key => nil + }.update(options) + @name, @options = name, options + + options.assert_valid_keys(:dependent, :class_name, :foreign_key) + + owner_clazz.class_eval do + define_has_one_getter(name, options) + define_has_one_setter(name, options) + end + end + + end + end + end +end diff --git a/lib/simply_based/model/pagination.rb b/lib/simply_based/model/pagination.rb new file mode 100644 index 0000000..4fdbc80 --- /dev/null +++ b/lib/simply_based/model/pagination.rb @@ -0,0 +1,25 @@ +module SimplyBased + module Model + module Pagination + def total_pages_method(setter = nil) + @_total_pages_method = setter if setter + @_total_pages_method || 'total_pages' + end + + def current_page_method(setter = nil) + @_current_page_method = setter if setter + @_current_page_method || 'current_page' + end + + def num_pages_method(setter = nil) + @_num_pages_method = setter if setter + @_num_pages_method || 'num_pages' + end + + def per_page_method(setter = nil) + @_per_page_method = setter if setter + @_per_page_method || 'per_page' + end + end + end +end diff --git a/lib/simply_based/model/pagination_options.rb b/lib/simply_based/model/pagination_options.rb new file mode 100644 index 0000000..296fe48 --- /dev/null +++ b/lib/simply_based/model/pagination_options.rb @@ -0,0 +1,55 @@ +module SimplyBased + module Model + module PaginationOptions + def with_pagination_options(options, &block) + # Allow for manual setting of total_entries + # If none given, default fallback will be total_rows + # This is not true for a paginated find_all_by construction + total_entries = options.delete(:total_entries) + # Add limit and skip to options if requested + if ([:page, :per_page] & options.keys).any? # Pagination active + page = [options.delete(:page).to_i, 1].max + per_page = options.delete(:per_page).to_i # Can be string + per_page = 22 unless per_page > 0 # Nill will be 0 + options[:limit] = per_page + options[:skip] = (page - 1) * options[:limit] + elsif options[:offset] && options[:limit] # Sql syntax support + options[:skip] = options.delete(:offset) + options.delete(:order) + page = (options[:skip].to_i / options[:limit].to_i).floor.succ + per_page = options[:limit] + else + page = 1 + per_page = 22 + end + result = block.call(options) + if result + result.instance_eval <<-PAGINATION, __FILE__, __LINE__ + unless respond_to?(:total_rows) + def total_rows + 1 + end + end + def total_entries + #{total_entries || 'total_rows'} + end + alias total_count total_entries + + def #{current_page_method} + #{page} + end + def #{num_pages_method} + return 1 if total_entries.zero? + (total_entries.to_f / #{per_page}).ceil + end + alias :#{total_pages_method} :#{num_pages_method} + def #{per_page_method} + #{per_page} + end + PAGINATION + end + result + end + end + end +end diff --git a/lib/simply_based/model/persistence.rb b/lib/simply_based/model/persistence.rb new file mode 100644 index 0000000..06c1754 --- /dev/null +++ b/lib/simply_based/model/persistence.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true + +# Persistence — replaces CouchPotato::Persistence. +# Provides property macro, callbacks, JSON, dirty tracking, timestamps, validations. +# +module SimplyBased + module Model + module Persistence + require 'active_support/time' + + def self.included(base) + base.instance_variable_set(:@properties, nil) if base.instance_variable_defined?(:@properties) + base.send :include, Properties + base.send :include, Callbacks + base.send :include, Json + base.send :include, DirtyAttributes + base.send :include, MagicTimestamps + base.send :include, ActiveModelCompliance + base.send :include, ForbiddenAttributesProtection + base.send :include, Revisions + base.send :include, Validation + base.send :include, View::CustomViews + base.send :include, View::Lists + + base.class_eval do + attr_accessor :_id, :_rev, :_deleted, :_attachments, :database, :_document + alias_method :id, :_id + alias_method :id=, :_id= + + def _document=(val) + @_document = val.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(val) : val + end + end + end + + # ── initialize / attributes ──────────────────────────────────────── + + def initialize(attributes = {}) + if attributes + @skip_dirty_tracking = true + begin + self.attributes = attributes + ensure + # Always restore tracking, even if a setter raises, so the instance + # isn't left permanently ignoring dirty state. + @skip_dirty_tracking = false + end + end + yield self if block_given? + end + + def attributes=(hash) + hash.each { |attribute, value| self.public_send "#{attribute}=", value } + end + + def attributes + self.class.properties.inject(ActiveSupport::HashWithIndifferentAccess.new) do |res, property| + property.value(res, self) + res + end + end + + def []=(attribute, value); public_send("#{attribute}=", value); end + def [](attribute); public_send(attribute); end + def has_key?(key); attributes.has_key?(key); end + def new?; _rev.nil?; end + alias_method :new_record?, :new? + def to_param; _id; end + + def ==(other) + super || (self.class == other.class && self._id.present? && self._id == other._id) + end + def eql?(other); self == other; end + + def hash + # Unsaved records hash by object identity to stay consistent with #== + # (two distinct new records are not equal, so must not be forced equal). + return super if _id.nil? + [self.class, _id, _rev].hash + end + + def inspect + attrs = attributes.map {|k,v| "#{k}: #{v.inspect}"}.join(", ") + %Q{#<#{self.class} _id: "#{_id}", _rev: "#{_rev}", #{attrs}>} + end + + # ── Properties ───────────────────────────────────────────────────── + + module Properties + class PropertyList + include Enumerable + attr_accessor :list + + def initialize(clazz) + @clazz = clazz + @list = [] + @hash = {} + end + + def each(&block); (list + inherited_properties).each(&block); end + def <<(property); @hash[property.name] = property; @list << property; end + def find_property(name); @hash[name] || @clazz.superclass.properties.find_property(name); end + def inspect; list.map(&:name).inspect; end + + def inherited_properties + superclazz = @clazz.superclass + properties = [] + while superclazz && superclazz.respond_to?(:properties) + properties << superclazz.properties.list + superclazz = superclazz.superclass + end + properties.flatten + end + end + + def self.included(base) + base.extend ClassMethods + base.class_eval do + def self.properties + @properties ||= {} + @properties[name] ||= PropertyList.new(self) + @properties[name] + end + end + end + + def type_caster; @type_caster ||= TypeCaster.new; end + + module ClassMethods + def property_names; properties.map(&:name); end + + def property(name, options = {}) + am = send(:generated_attribute_methods) + am.module_eval { undef_method(name) if instance_methods.include?(name) } + define_attribute_method name + properties << SimpleProperty.new(self, name, options) + am.send(:remove_method, name) if am.instance_methods.include?(name) + end + + def check_existing_properties(name, type) + existing = properties.find{|p| name.to_sym == p.name.to_sym} + return if existing.nil? || existing.class == type + raise "Property #{name} already defined as #{existing.class}, cannot redefine as #{type}" + end + end + end + + # ── SimpleProperty ────────────────────────────────────────────────── + + module PropertyMethods + private + def load_attribute_from_document(name) + if _document&.has_key?(name) + property = self.class.properties.find_property(name) + @skip_dirty_tracking = true + value = property.build(self, _document) + @skip_dirty_tracking = false + value + end + end + end + + class SimpleProperty + attr_accessor :name, :type + + def initialize(owner_clazz, name, options = {}) + self.name = name + @setter_name = "#{name}=" + self.type = options[:type] + @type_caster = TypeCaster.new + owner_clazz.send(:include, PropertyMethods) unless owner_clazz.ancestors.include?(PropertyMethods) + define_accessors(accessors_module_for(owner_clazz), name, options) + end + + def build(object, json); object.public_send @setter_name, json[name]; end + def changed?(object); object.public_send("#{name}_changed?"); end + def serialize(json, object); json[name] = @type_caster.cast_back object.public_send(name); end + alias :value :serialize + + private + + def module_for(clazz, name) + suffix = "#{clazz.name.to_s.gsub('::', '__')}#{name}" + unless clazz.const_defined?(suffix) + clazz.const_set(suffix, Module.new).tap {|m| clazz.send(:include, m) } + end + clazz.const_get(suffix) + end + + def accessors_module_for(clazz); module_for(clazz, "AccessorMethods"); end + + def define_accessors(base, name, options) + ivar = "@#{name}".freeze + base.class_eval do + define_method(name) do + load_attribute_from_document(name) unless instance_variable_defined?(ivar) + value = instance_variable_get(ivar) + if value.nil? && !options[:default].nil? + default = if options[:default].respond_to?(:call) + options[:default].arity == 1 ? options[:default].call(self) : options[:default].call + else + clone_attribute(options[:default]) + end + instance_variable_set(ivar, default) + else + value + end + end + + define_method("#{name}=") do |value| + typecasted = type_caster.cast(value, options[:type]) + public_send("#{name}_will_change!") unless @skip_dirty_tracking || typecasted == public_send(name) + instance_variable_set(ivar, typecasted) + end + + define_method("#{name}?") { !send(name).nil? && !send(name).try(:blank?) } + end + end + end + + # ── Callbacks ──────────────────────────────────────────────────────── + + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :validate, :validation, + :validation_on_save, :validation_on_create, :validation_on_update, + :save, :create, :update, :destroy + %w(validate validation save create update destroy).each do |cb| + class_eval <<-RUBY, __FILE__, __LINE__ + def self.before_#{cb}(*args, &block); set_callback :#{cb}, :before, *args, █ end + def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, █ end + def self.around_#{cb}(*args, &block); set_callback :#{cb}, :around, *args, █ end + RUBY + end + %w(validation_on_create validation_on_update).each do |cb| + class_eval <<-RUBY, __FILE__, __LINE__ + def self.before_#{cb}(*args, &block); set_callback :#{cb}, :before, *args, █ end + def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, █ end + RUBY + end + end + end + + # ── Dirty Attributes ───────────────────────────────────────────────── + + module DirtyAttributes + def self.included(base) + base.send :include, ActiveModel::Dirty + base.send :alias_method, :dirty?, :changed? + + base.class_eval { after_save :clear_changes_information } + end + private + # Deep-copy a (default) attribute value so instances never share mutable + # state. Recurses through Hash/Array instead of Marshal.dump/load, which + # is both lighter and avoids the Marshal round-trip (Marshal can't dump + # some objects and is flagged by security scanners). Preserves the hash + # subclass (e.g. HashWithIndifferentAccess). + def clone_attribute(value) + case value + when Integer, Symbol, TrueClass, FalseClass, NilClass, Float + value + when Array + value.map { |element| clone_attribute(element) } + when Hash + value.each_with_object(value.class.new) do |(k, v), copy| + copy[clone_attribute(k)] = clone_attribute(v) + end + else + value.clone + end + end + end + + # ── JSON ───────────────────────────────────────────────────────────── + + module Json + def to_json(*args); to_hash(*args).to_json(*args); end + def to_hash(options = nil) + doc = { 'ruby_class' => self.class.name, '_id' => _id, '_rev' => _rev }.reject { |_, v| v.nil? }.merge(@_document || {}) + (self.class.properties || []).inject(doc) {|d, p| p.serialize(d, self); d } + end + alias :as_json :to_hash + def _document; @_document ||= {}; end + def _document=(val) + @_document = val.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(val) : val + end + + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + # Called by JSON.parse to hydrate documents into model instances. + # Looks for 'ruby_class' key (mozo convention) or 'json_class' (standard). + def json_create(json) + return if json.nil? + doc = ActiveSupport::HashWithIndifferentAccess.new(json) + instance = new + instance.instance_variable_set(:@_document, doc) + instance._id = doc[:_id] || doc['_id'] + instance._rev = doc[:_rev] || doc['_rev'] + instance._attachments = doc[:_attachments] || doc['_attachments'] + instance + end + end + end + + # ── Magic Timestamps ───────────────────────────────────────────────── + + module MagicTimestamps + def self.included(base) + base.instance_eval do + property :created_at, type: Time + property :updated_at, type: Time + before_create :set_created_at + before_save :set_updated_at + end + end + private + def set_created_at; self.created_at ||= Time.now; end + def set_updated_at; self.updated_at = Time.now; end + end + + # ── ActiveModel Compliance ─────────────────────────────────────────── + + module ActiveModelCompliance + extend ActiveSupport::Concern + def persisted?; !new? && !destroyed?; end + def destroyed?; @destroyed || false; end + def to_key; persisted? ? [id] : nil; end + def to_model; self; end + end + + # ── Forbidden Attributes Protection ────────────────────────────────── + + module ForbiddenAttributesProtection + # Rails 5+ uses strong parameters at controller level — stub only + end + + # ── Revisions ──────────────────────────────────────────────────────── + + module Revisions + def self.included(base) + base.class_eval do + def self.revisions(ids) + return [] if ids.empty? + database.bulk_load(ids) + end + end + end + end + + # ── Validation ─────────────────────────────────────────────────────── + + module Validation + extend ActiveSupport::Concern + include ActiveModel::Validations + end + + # ── Type Caster ────────────────────────────────────────────────────── + + class TypeCaster + # Cast a value to the given type. + # Supports: + # type: SomeClass — direct class reference + # type: :boolean, :integer — symbol (lazy, Rails autoloading safe) + # type: 'ClassName' — string (constantize in Rails) + def cast(value, type = nil) + return value unless type + resolved = resolve_type(type) + # If resolved is a Module (class), try to coerce + if resolved.is_a?(Module) + return value if value.is_a?(resolved) + return cast_to_builtin(value, resolved) + end + value + end + + private + + def cast_to_builtin(value, klass) + case klass.name + when 'Integer' then value.to_i + when 'Float' then value.to_f + when 'String' then value.to_s + when 'Symbol' then value.to_sym + when 'TrueClass', 'FalseClass' then !!value + when 'Array' then value.is_a?(Array) ? value : [value] + when 'Hash' then value.is_a?(Hash) ? value : { value: value } + when 'Time', 'DateTime', 'Date' + value.is_a?(String) ? Time.parse(value) : value + else value + end + end + + public + def cast_back(value) + value.respond_to?(:iso8601) ? value.iso8601 : value + end + + private + + # Map symbols and strings to Ruby classes. + # Symbols are preferred — they work without Rails autoloading. + BUILTIN_TYPES = { + boolean: [TrueClass, FalseClass], + integer: Integer, + float: Float, + string: String, + symbol: Symbol, + time: Time, + datetime: DateTime, + date: Date, + array: Array, + hash: Hash, + }.freeze + + def resolve_type(type) + case type + when Symbol + mapped = BUILTIN_TYPES[type] + mapped || (Object.const_get(type.to_s.classify) rescue type) + when String + Object.const_get(type) rescue type + when Module + type + else + type + end + end + end + end + end +end diff --git a/lib/simply_based/model/properties.rb b/lib/simply_based/model/properties.rb new file mode 100644 index 0000000..6258036 --- /dev/null +++ b/lib/simply_based/model/properties.rb @@ -0,0 +1,11 @@ +module SimplyBased + module Model + module Properties + def check_existing_properties(name, type) + if properties.find{|property| name.to_sym == property.name.to_sym && property.class != type} + raise "Property with the name (#{name}) already defined" + end + end + end + end +end diff --git a/lib/simply_based/model/validations.rb b/lib/simply_based/model/validations.rb new file mode 100644 index 0000000..ac726ee --- /dev/null +++ b/lib/simply_based/model/validations.rb @@ -0,0 +1,28 @@ +#require 'active_support/core_ext/hash/except' +module SimplyBased + module Model + module Validations + class UniquenessValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + other_instance = record.class.send("find_by_#{attribute}", value) + if other_instance && other_instance != record && other_instance.send(attribute) == value + record.errors.add(attribute, :taken, **options.except(:case_sensitive, :scope).merge(:value => value)) + end + end + end + def validates_uniqueness_of(*attr_names) + validates_with UniquenessValidator, _merge_attributes(attr_names) + end + class ContainmentValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless Array.wrap(value) - options[:in] == [] + record.errors.add(attribute, :inclusion, **options.except(:in, :within).merge!(:value => value)) + end + end + end + def validates_containment_of(*attr_names) + validates_with ContainmentValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/lib/simply_based/model/view.rb b/lib/simply_based/model/view.rb new file mode 100644 index 0000000..92613ef --- /dev/null +++ b/lib/simply_based/model/view.rb @@ -0,0 +1,7 @@ +require 'simply_based/model/view/base_view_spec' +require 'simply_based/model/view/model_view_spec' +require 'simply_based/model/view/properties_view_spec' +require 'simply_based/model/view/custom_view_spec' +require 'simply_based/model/view/raw_view_spec' +require 'simply_based/model/view/custom_views' +require 'simply_based/model/view/lists' diff --git a/lib/simply_based/model/view/base_view_spec.rb b/lib/simply_based/model/view/base_view_spec.rb new file mode 100644 index 0000000..bef31a6 --- /dev/null +++ b/lib/simply_based/model/view/base_view_spec.rb @@ -0,0 +1,119 @@ +require 'digest' +module SimplyBased + module Model + module View + class BaseViewSpec + attr_reader :reduce_function, :lib, :list_name, :list_function, :design_document, :view_name, :klass, :options, :language + attr_accessor :view_parameters + + # `klass` (the model) and `options` (the view declaration: :key, :conditions, + # :emit_value, :properties, …) are the structured, backend-neutral query + # description. The couchrest backend renders them to JS map/reduce; other + # backends (e.g. couchbase → N1QL) translate these fields instead of + # parsing the generated JavaScript. Public so adapters can read them. + + DEFAULT_LANGUAGE = :javascript + DEFAULT_DIGEST_VIEW_NAMES = false + + def initialize(klass, view_name, options, view_parameters) + normalized_view_parameters = normalize_view_parameters view_parameters + + @list_name = normalized_view_parameters.delete(:list) || options[:list] + @language = options[:language] || DEFAULT_LANGUAGE + + assert_valid_view_parameters normalized_view_parameters + assert_sorted_false_not_with_startkey_endkey normalized_view_parameters + @klass = klass + @options = options + @view_name = compute_view_name(view_name, + options.key?(:digest_view_name) ? options[:digest_view_name] : DEFAULT_DIGEST_VIEW_NAMES) + @design_document = translate_to_design_doc_name(klass.to_s, @view_name, @list_name) + @list_params = normalized_view_parameters.delete :list_params + + @list_function = klass.lists(@list_name) if @list_name + @view_parameters = {} + [:group, :include_docs, :descending, :group_level, :limit].each do |key| + @view_parameters[key] = options[key] if options.include?(key) + end + @view_parameters.merge!(normalized_view_parameters) + @view_parameters.merge!(@list_params) if @list_params + end + + def process_results(results) + if (filter = options[:results_filter]) + filter.call results + else + results + end + end + + private + + def compute_view_name(view_name, digest) + if digest + "#{view_name}-#{Digest::MD5.hexdigest(map_function + reduce_function.to_s)}" + else + view_name + end + end + + def normalize_view_parameters(params) + hash = wrap_in_hash params + remove_nil_stale(replace_range_key(hash)) + end + + def remove_nil_stale(params) + params.reject{|name, value| name.to_s == 'stale' && value.nil?} + end + + def wrap_in_hash(params) + if params.is_a?(Hash) + params + else + {:key => params} + end + end + + def replace_range_key(params) + if((key = params[:key]).is_a?(Range)) + params.delete :key + params[:startkey] = key.first + params[:endkey] = key.last + end + params + end + + def assert_valid_view_parameters(params) + params.keys.each do |key| + fail ArgumentError, "invalid view parameter: #{key}" unless valid_view_parameters.include?(key.to_s) + end + end + + def assert_sorted_false_not_with_startkey_endkey(params) + used_key_params = params.keys & [:startkey, :endkey, :startkey_docid, :endkey_docid] + if params[:sorted] == false && used_key_params.any? + fail ArgumentError, "view parameter: `sorted: false` can not be combined with #{used_key_params.join(', ')}" + end + end + + def valid_view_parameters + %w(list_params key keys startkey startkey_docid endkey endkey_docid limit stale descending skip group group_level reduce include_docs inclusive_end sorted) + end + + def translate_to_design_doc_name(klass_name, view_name, list_name) + klass_name = klass_name.dup + klass_name.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + klass_name.gsub!(/([a-z\d])([A-Z])/, '\1_\2') + klass_name.tr!('-', '_') + doc_name = klass_name.downcase + + if options && options[:split_design_doc] + doc_name += "_view_#{view_name}" if view_name.present? + doc_name += "_list_#{list_name}" if list_name.present? + end + doc_name + end + end + end + end +end diff --git a/lib/simply_based/model/view/custom_view_spec.rb b/lib/simply_based/model/view/custom_view_spec.rb new file mode 100644 index 0000000..1df3dc7 --- /dev/null +++ b/lib/simply_based/model/view/custom_view_spec.rb @@ -0,0 +1,49 @@ +module SimplyBased + module Model + module View + # a view for custom map/reduce functions that still returns model instances + # + # example: + # view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :include_docs => true, :type => :custom, :reduce => nil + class CustomViewSpec < BaseViewSpec + def map_function + options[:map] + end + + def reduce_function + options[:reduce] + end + + def lib + options[:lib] + end + + def view_parameters + {:include_docs => options[:include_docs] || false}.merge(super) + end + + def process_results(results) + processed = if count? + results['rows'].first.try(:[], 'value') || 0 + else + results['rows'].map do |row| + if row['doc'].kind_of?(klass) + row['doc'] + else + result = row['doc'] || (row['value'].merge(:_id => row['id'] || row['key']) unless view_parameters[:include_docs]) + klass.json_create result if result + end + end.compact + end + super processed + end + + private + + def count? + view_parameters[:reduce] + end + end + end + end +end diff --git a/lib/simply_based/model/view/custom_views.rb b/lib/simply_based/model/view/custom_views.rb new file mode 100644 index 0000000..ccc1373 --- /dev/null +++ b/lib/simply_based/model/view/custom_views.rb @@ -0,0 +1,50 @@ +module SimplyBased + module Model + module View + module CustomViews + extend ActiveSupport::Concern + + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def views(view_name = nil) + if view_name + _find_view(view_name) + else + @views ||= {} + end + end + + def execute_view(view_name, view_parameters) + options = views(view_name).dup + options[:split_design_doc] = split_design_documents? if respond_to?(:split_design_documents?) + view_spec_class(options[:type]).new(self, view_name, options, view_parameters) + end + + # Declare a CouchDB view, for examples on how to use see the *ViewSpec classes in SimplyBased::Model::View + def view(view_name, options) + view_name = view_name.to_s + views[view_name] = options + method_str = "def #{view_name}(view_parameters = {}); execute_view(\"#{view_name}\", view_parameters); end" + self.instance_eval(method_str) + end + + def view_spec_class(type) + if type && type.is_a?(Class) + type + else + name = type.nil? ? 'Model' : type.to_s.camelize + SimplyBased::Model::View.const_get("#{name}ViewSpec") + end + end + + def _find_view(view) + (@views && @views[view]) || (superclass._find_view(view) if superclass.respond_to?(:_find_view)) + end + end + end + end + end +end diff --git a/lib/simply_based/model/view/lists.rb b/lib/simply_based/model/view/lists.rb new file mode 100644 index 0000000..5bb9613 --- /dev/null +++ b/lib/simply_based/model/view/lists.rb @@ -0,0 +1,25 @@ +module SimplyBased + module Model + module View + module Lists + def self.included(base) + base.send :extend, ClassMethods + end + + module ClassMethods + def list(name, list_function) + lists[name] = list_function + end + + def lists(name = nil) + if name.nil? + @lists ||= {} + else + (@lists && @lists[name]) || (superclass.lists(name) if superclass.respond_to?(:lists)) + end + end + end + end + end + end +end diff --git a/lib/simply_based/model/view/model_view_spec.rb b/lib/simply_based/model/view/model_view_spec.rb new file mode 100644 index 0000000..30c9f9a --- /dev/null +++ b/lib/simply_based/model/view/model_view_spec.rb @@ -0,0 +1,106 @@ +module SimplyBased + module Model + module View + # A view to return model instances by searching its properties. + # If you pass reduce => true will count instead + # + # example: + # view :my_view, :key => :name + # + # in addition you can pass in conditions as a javascript string + # view :my_view_only_completed, :key => :name, :conditions => 'doc.completed = true' + # and also a results filter (the results will be run through the given proc): + # view :my_view, :key => :name, :results_filter => lambda{|results| results.size} + class ModelViewSpec < BaseViewSpec + # The key simply_couch uses for class identification in CouchDB documents + RUBY_CLASS_KEY = 'ruby_class' + + class JavascriptGenerator + def initialize(options, klass) + @options = options + @klass = klass + end + + def map_body(&block) + <<-JS + function(doc) { + if(doc.#{RUBY_CLASS_KEY} && doc.#{RUBY_CLASS_KEY} == '#{@klass.name}'#{conditions}) { + #{yield} + } + } + JS + end + + def map_function + map_body do + "emit(#{formatted_key}, #{emit_value});" + end + end + + def formatted_key(_key = nil) + _key ||= @options[:key] + if _key.is_a? Array + '[' + _key.map{|key_part| formatted_key(key_part)}.join(', ') + ']' + else + "doc['#{_key}']" + end + end + + private + + # Allow custom emit values. Raise when the specified argument is not recognized + def emit_value + case @options[:emit_value] + when Symbol then "doc['#{@options[:emit_value]}']" + when String then @options[:emit_value] + when Numeric then @options[:emit_value] + when NilClass then 1 + else + raise "The emit value specified is not recognized" + end + end + + def conditions + " && (#{@options[:conditions]})" if @options[:conditions] + end + end + + delegate :map_function, :map_body, :formatted_key, :to => :generator + + def view_parameters + _super = super + if _super[:reduce] + _super + else + {:include_docs => true, :reduce => false}.merge(_super) + end + end + + def reduce_function + "_sum" + end + + def process_results(results) + processed = if count? + results['rows'].first.try(:[], 'value') || 0 + else + results['rows'].map {|row| + row['doc'] || (row['id'] unless view_parameters[:include_docs]) + }.compact + end + super processed + end + + private + + def generator + @generator ||= JavascriptGenerator.new(@options, @klass) + end + + def count? + view_parameters[:reduce] + end + end + end + end +end diff --git a/lib/simply_based/model/view/properties_view_spec.rb b/lib/simply_based/model/view/properties_view_spec.rb new file mode 100644 index 0000000..9d9c397 --- /dev/null +++ b/lib/simply_based/model/view/properties_view_spec.rb @@ -0,0 +1,53 @@ +module SimplyBased + module Model + module View + # A view to return model instances with only some properties populated by searching its properties, e.g. for very large documents where you are only interested in some of their data + # + # example: + # view :my_view, :key => :name, :properties => [:name, :author], :type => :properties + class PropertiesViewSpec < ModelViewSpec + def map_function + map_body do + "emit(#{formatted_key}, #{properties_for_map(properties)});" + end + end + + def reduce_function + <<-JS + function(key, values, rereduce) { + if(rereduce) { + return sum(values); + } else { + return values.length; + } + } + JS + end + + def process_results(results) + results['rows'].map do |row| + klass.json_create row['value'].merge(:_id => row['id']) + end + end + + def view_parameters + {:include_docs => false}.merge(super) + end + + def language + :javascript + end + + private + + def properties + options[:properties] + end + + def properties_for_map(properties) + '{' + properties.map{|p| "#{p}: doc.#{p}"}.join(', ') + '}' + end + end + end + end +end diff --git a/lib/simply_based/model/view/raw_view_spec.rb b/lib/simply_based/model/view/raw_view_spec.rb new file mode 100644 index 0000000..c70fda4 --- /dev/null +++ b/lib/simply_based/model/view/raw_view_spec.rb @@ -0,0 +1,30 @@ +module SimplyBased + module Model + module View + # A view for custom map/reduce functions that returns the raw data from couchdb + # + # example: + # view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :reduce => nil + # optionally you can pass in a results filter which you can use to process the raw couchdb results before returning them + # + # example: + # view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :results_filter => lambda{|results| results['rows'].map{|row| row['value']}} + # + # example: + # view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :lib => {:module => "exports.name = 'module';"} + class RawViewSpec < BaseViewSpec + def map_function + options[:map] + end + + def reduce_function + options[:reduce] + end + + def lib + options[:lib] + end + end + end + end +end diff --git a/lib/simply_based/model/views.rb b/lib/simply_based/model/views.rb new file mode 100644 index 0000000..31796ab --- /dev/null +++ b/lib/simply_based/model/views.rb @@ -0,0 +1,2 @@ +require 'simply_based/model/views/array_property_view_spec' +require 'simply_based/model/views/deleted_model_view_spec' \ No newline at end of file diff --git a/lib/simply_based/model/views/array_property_view_spec.rb b/lib/simply_based/model/views/array_property_view_spec.rb new file mode 100644 index 0000000..e85caff --- /dev/null +++ b/lib/simply_based/model/views/array_property_view_spec.rb @@ -0,0 +1,26 @@ +module SimplyBased + module Model + module Views + class ArrayPropertyViewSpec < SimplyBased::Model::View::ModelViewSpec + def map_function + "function(doc) { + if(doc.ruby_class && doc.ruby_class == '#{@klass.name}') { + if (#{formatted_key(key)}.constructor.toString().match(/function Array()/)) { + for (var i in #{formatted_key(key)}) { + emit(#{formatted_key(key)}[i], 1); + } + } else { + emit(#{formatted_key(key)}, 1); + } + } + }" + end + + def key + @options[:key] + end + + end + end + end +end diff --git a/lib/simply_based/model/views/deleted_model_view_spec.rb b/lib/simply_based/model/views/deleted_model_view_spec.rb new file mode 100644 index 0000000..194fdfa --- /dev/null +++ b/lib/simply_based/model/views/deleted_model_view_spec.rb @@ -0,0 +1,43 @@ +module SimplyBased + module Model + module Views + class DeletedModelViewSpec < SimplyBased::Model::View::ModelViewSpec + def map_function + <<-eos + function(doc) { + if (doc.ruby_class && doc.ruby_class == '#{@klass.name}') { + if (doc['#{@klass.soft_delete_attribute}'] && doc['#{@klass.soft_delete_attribute}'] != null){ + // "soft" deleted + }else{ + emit(doc.created_at, 1); + } + } + } + eos + end + + def reduce_function + '_sum' + end + + def view_parameters + _super = super + if _super[:reduce] + _super + else + {:include_docs => true, :reduce => false}.merge(_super) + end + end + + def process_results(results) + if count? + results['rows'].first.try(:[], 'value') || 0 + else + results['rows'].map { |row| row['doc'] || row['id'] } + end + end + + end + end + end +end diff --git a/lib/simply_based/rake.rb b/lib/simply_based/rake.rb new file mode 100644 index 0000000..9f4143f --- /dev/null +++ b/lib/simply_based/rake.rb @@ -0,0 +1,23 @@ +namespace :simply_couch do + desc "delete all design documents" + task :delete_design_documents do + require File.dirname(__FILE__) + "/couch" + if database = ENV['DATABASE'] + deleted = SimplyBased::Model.delete_all_design_documents(database) + puts "deleted #{deleted} design documents in #{database}" + else + puts "please specify which database to clear: DATABASE=http://localhost:5984/simply_couch rake simply_couch:delete_design_documents" + end + end + + desc "compact all design documents" + task :compact_design_documents do + require File.dirname(__FILE__) + "/couch" + if database = ENV['DATABASE'] + compacted = SimplyBased::Model.compact_all_design_documents(database) + puts "triggered compaction of #{compacted} design documents in #{database}" + else + puts "please specify which database to clear: DATABASE=http://localhost:5984/simply_couch rake simply_couch:delete_design_documents" + end + end +end \ No newline at end of file diff --git a/lib/simply_based/redis.rb b/lib/simply_based/redis.rb new file mode 100644 index 0000000..5b5b221 --- /dev/null +++ b/lib/simply_based/redis.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'base64' + +module SimplyBased + # Couchbase-backed replacements for the things apps usually reach to Redis for. + # All three use the same cluster/bucket as your models — one datastore. + + # Server-side atomic counters (Couchbase binary increment/decrement). A drop-in + # for Redis INCR/DECR. + # + # counter = SimplyBased::Counter.new + # counter.increment("jobs:processed") # => 1 (created at 0, +1) + # counter.increment("jobs:processed", 5) # => 6 + # counter.get("jobs:processed") # => 6 + class Counter + def initialize(store: nil) + @store = store + end + + def store + @store ||= SimplyBased.database + end + + def increment(key, by = 1, initial: 0, ttl: nil) + store.counter_incr(key, by, initial: initial, ttl: ttl) + end + + def decrement(key, by = 1, initial: 0, ttl: nil) + store.counter_decr(key, by, initial: initial, ttl: ttl) + end + + def get(key) + v = store.kv_get(key) + v.nil? ? nil : v.to_i + end + + def reset(key, to = 0, ttl: nil) + store.kv_set(key, to, ttl: ttl) + to + end + end + + # An ActiveSupport::Cache::Store backed by Couchbase KV + native TTL — usable + # as `Rails.cache` and therefore as the ActiveJob / fragment / low-level cache. + # + # Rails.application.config.cache_store = SimplyBased::Cache.new(namespace: "app") + class Cache < ActiveSupport::Cache::Store + def initialize(options = {}) + @store = options.delete(:store) + @namespace = options.delete(:namespace) + super(options) + end + + def store + @store ||= SimplyBased.database + end + + def clear(**) + # Namespaced clear would need a key scan; left to the app (or drop the + # bucket in tests). KV TTLs expire entries automatically. + nil + end + + private + + def normalize_key(name, options = nil) + k = super + @namespace ? "#{@namespace}::#{k}" : k + end + + def read_entry(key, **) + raw = store.kv_get(key) + raw && Marshal.load(Base64.strict_decode64(raw)) + end + + def write_entry(key, entry, **options) + ttl = options[:expires_in] && options[:expires_in].to_i + store.kv_set(key, Base64.strict_encode64(Marshal.dump(entry)), ttl: ttl) + true + end + + def delete_entry(key, **) + store.kv_delete(key) + end + end + + # A distributed lock via insert-if-absent + TTL (auto-expiry) and CAS-checked + # release, so only the holder can unlock. Replaces Redis SETNX-style locks. + # + # SimplyBased::Lock.new.with_lock("rebuild:report", ttl: 30) do + # # critical section — only one process at a time + # end + class Lock + def initialize(store: nil) + @store = store + end + + def store + @store ||= SimplyBased.database + end + + # Returns a CAS token (the lock handle) or nil if already held. + def acquire(key, ttl: 30) + store.kv_add(lock_key(key), { 'locked_at' => Time.now.to_i }, ttl: ttl) + end + + # Release only if we still hold the same CAS (no stomping a re-acquired lock). + def release(key, cas = nil) + store.kv_delete(lock_key(key), cas: cas) + end + + def with_lock(key, ttl: 30, retries: 0, wait: 0.1) + cas = acquire(key, ttl: ttl) + tries = 0 + while cas.nil? && tries < retries + sleep wait + tries += 1 + cas = acquire(key, ttl: ttl) + end + raise SimplyBased::Error, "could not acquire lock: #{key}" if cas.nil? + begin + yield + ensure + release(key, cas) + end + end + + private + + def lock_key(key) + "lock::#{key}" + end + end +end diff --git a/lib/simply_based/store.rb b/lib/simply_based/store.rb new file mode 100644 index 0000000..cc51d42 --- /dev/null +++ b/lib/simply_based/store.rb @@ -0,0 +1,460 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'uri' +require 'couchbase' + +module SimplyBased + # The Couchbase-native data store. Plays the role SimplyCouch's + # Model::DatabaseInstance played, but speaks the SDK directly: key/value for + # documents, N1QL/SQL++ for queries, CAS for optimistic concurrency. There is + # no CouchDB design document or JavaScript map/reduce anywhere. + # + # v0 data model: one collection, every document tagged `ruby_class`; queries + # filter on it and partial GSIs are scoped `WHERE ruby_class = '...'`. A future + # `collection :name` per-model mode maps each class to its own collection. + class Store + attr_reader :cluster, :bucket_name, :scope_name, :collection_name + + def initialize(connection = nil) + cfg = parse_connection(connection) + @bucket_name = cfg[:bucket] + @scope_name = cfg[:scope] || '_default' + @collection_name = cfg[:collection] || '_default' + @cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password]) + @ensured_indexes = {} + end + + def keyspace + "`#{bucket_name}`.`#{scope_name}`.`#{collection_name}`" + end + + # ── single document (KV) ──────────────────────────────────────────────── + + def save_document(document, validate = true) + if document.new? + create_document(document, validate) + else + update_document(document, validate) + end + rescue ::Couchbase::Error::CasMismatch, ::Couchbase::Error::DocumentExists + raise SimplyBased::Conflict.new + end + + def save_document!(document) + save_document(document) || raise("Validations failed: #{document.errors.full_messages}") + end + + def load_document(id) + raise "Can't load a document without an id (got nil)" if id.nil? + _track("GET #{id}") + res = collection.get(id) + hydrate(res.content, id, res.cas) + rescue ::Couchbase::Error::DocumentNotFound + nil + end + + def destroy_document(document, run_callbacks = true) + remove = lambda do + document._deleted = true + collection.remove(document._id) if document._id + end + run_callbacks ? document.run_callbacks(:destroy) { remove.call } : remove.call + document._id = nil + document._rev = nil + rescue ::Couchbase::Error::DocumentNotFound + document._id = nil; document._rev = nil + end + + def delete_document(document) + collection.remove(document._id) if document._id + rescue ::Couchbase::Error::DocumentNotFound + nil + end + + # ── bulk (batched KV) ─────────────────────────────────────────────────── + + def bulk_load(ids) + ids = Array(ids).compact + return [] if ids.empty? + ids.filter_map { |id| load_document(id) } + end + + def bulk_save(documents, validate = true) + documents = Array(documents) + return { saved: [], invalid: [], failed: [] } if documents.empty? + invalid = []; saved = []; failed = []; now = Time.now + documents.each do |document| + document.database = self + document.created_at ||= now if document.respond_to?(:created_at) && document.new? + document.updated_at = now if document.respond_to?(:updated_at=) + if validate + document.errors.clear + (invalid << document; next) unless valid_document?(document) + end + begin + persist(document) + document.send(:clear_changes_information) if document.respond_to?(:clear_changes_information, true) + saved << document + rescue ::Couchbase::Error::CouchbaseError => e + failed << [document, e.message] + end + end + { saved: saved, invalid: invalid, failed: failed } + end + + def bulk_destroy(documents) + documents = Array(documents) + return [] if documents.empty? + rows = documents.map do |d| + begin + collection.remove(d._id) if d._id + { 'id' => d._id, 'ok' => true } + rescue ::Couchbase::Error::DocumentNotFound + { 'id' => d._id, 'error' => 'not_found' } + end + end + documents.each { |d| d._id = nil; d._rev = nil } + rows + end + + # ── view specs (finders / count) → N1QL ───────────────────────────────── + # Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count). + + def view(spec) + kls = spec.klass + vp = spec.view_parameters + where = ["c.`ruby_class` = $rc"] + params = { rc: kls.name } + where << not_deleted(kls) if deleted_spec?(spec) + keyexp = key_expr(spec.options[:key]) + where.concat(key_filters(keyexp, vp, params)) + + results = + if vp[:reduce] + [{ 'value' => run_count(where, params) }] + else + run_docs(where, params, keyexp, vp).map { |doc| { 'doc' => doc, 'id' => doc._id } } + end + processed = spec.process_results({ 'rows' => results }) + assign_db(processed) + processed + end + + def first(spec) + spec.view_parameters = spec.view_parameters.merge(limit: 1) + Array(view(spec)).first + end + + # ── associations → N1QL (replaces JS join views) ──────────────────────── + # has_many / has_one / belongs_to: docs of `from_class` whose scalar foreign + # key column equals `value`. + def find_by_foreign_key(from_class, column, value, opts = {}) + ensure_index(from_class, [column]) + where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"] + params = { rc: from_class.name, v: value } + where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) + run_docs(where, params, 'c.`created_at`', order_opts(opts)) + end + + def count_by_foreign_key(from_class, column, value, opts = {}) + ensure_index(from_class, [column]) + where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"] + params = { rc: from_class.name, v: value } + where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) + run_count(where, params) + end + + # has_and_belongs_to_many: docs of `from_class` whose array column contains value. + def find_by_array_contains(from_class, column, value, opts = {}) + ensure_array_index(from_class, column) + where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"] + params = { rc: from_class.name, v: value } + where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) + run_docs(where, params, 'c.`created_at`', order_opts(opts)) + end + + def count_by_array_contains(from_class, column, value, opts = {}) + ensure_array_index(from_class, column) + where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"] + params = { rc: from_class.name, v: value } + where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) + run_count(where, params) + end + + # ── admin ─────────────────────────────────────────────────────────────── + + def create_database! + query("CREATE PRIMARY INDEX IF NOT EXISTS ON #{keyspace}") + self + rescue ::Couchbase::Error::CouchbaseError + self + end + + # Clear all documents. Indexes survive (DELETE removes rows, not GSIs), so + # the @ensured_indexes cache is intentionally kept — between test examples we + # want to wipe data without rebuilding every association index. + def drop_database! + query("DELETE FROM #{keyspace}") + rescue ::Couchbase::Error::CouchbaseError + nil + end + + # ── raw KV primitives (used by Counter / Cache / Lock) ────────────────── + + # Server-side atomic counter (Couchbase binary increment/decrement). Returns + # the new value. Creates the key at `initial` if absent. + def counter_incr(key, delta = 1, initial: 0, ttl: nil) + o = ::Couchbase::Options::Increment.new(delta: delta, initial: initial) + o.expiry = ttl if ttl + collection.binary.increment(key, o).content + end + + def counter_decr(key, delta = 1, initial: 0, ttl: nil) + o = ::Couchbase::Options::Decrement.new(delta: delta, initial: initial) + o.expiry = ttl if ttl + collection.binary.decrement(key, o).content + end + + def kv_set(key, value, ttl: nil) + o = ::Couchbase::Options::Upsert.new + o.expiry = ttl if ttl + collection.upsert(key, value, o).cas + end + + # Insert only if absent — returns CAS, or nil if the key already exists. + def kv_add(key, value, ttl: nil) + o = ::Couchbase::Options::Insert.new + o.expiry = ttl if ttl + collection.insert(key, value, o).cas + rescue ::Couchbase::Error::DocumentExists + nil + end + + def kv_get(key) + collection.get(key).content + rescue ::Couchbase::Error::DocumentNotFound + nil + end + + def kv_get_cas(key) + r = collection.get(key) + [r.content, r.cas] + rescue ::Couchbase::Error::DocumentNotFound + nil + end + + def kv_delete(key, cas: nil) + o = ::Couchbase::Options::Remove.new + o.cas = cas if cas + collection.remove(key, o) + true + rescue ::Couchbase::Error::DocumentNotFound, ::Couchbase::Error::CasMismatch + false + end + + def query(statement, params = {}) + _track(statement) + opts = ::Couchbase::Options::Query.new(named_parameters: params) + opts.scan_consistency = :request_plus # read-your-writes; the suite expects it + cluster.query(statement, opts) + end + + private + + # Record a database round-trip for the spec query-count matchers (no-op in + # production, where $performed_queries is undefined). + def _track(op) + $performed_queries << { url: op } if defined?($performed_queries) && $performed_queries + end + + def collection + @collection ||= cluster.bucket(bucket_name).scope(scope_name).collection(collection_name) + end + + # ── N1QL builders ──────────────────────────────────────────────────────── + + def run_docs(where, params, order_col, vp) + sql = +"SELECT c.*, META(c).id AS `__id`, META(c).cas AS `__cas` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}" + if order_col + dir = vp[:descending] ? 'DESC' : 'ASC' + # META().id tiebreaker keeps ordering deterministic (created_at is only + # second-precision) so ascending and descending are exact reverses. + sql << " ORDER BY #{order_col} #{dir}, META(c).id #{dir}" + end + sql << " LIMIT #{Integer(vp[:limit])}" if vp[:limit] + sql << " OFFSET #{Integer(vp[:skip])}" if vp[:skip] + query(sql, params).rows.to_a.map { |row| row_to_doc(row) } + end + + def run_count(where, params) + sql = "SELECT COUNT(*) AS `value` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}" + r = query(sql, params).rows.first + (r && r['value']) || 0 + end + + def key_expr(key) + case key + when nil then nil + when Array then '[' + key.map { |k| "c.`#{k}`" }.join(', ') + ']' + else "c.`#{key}`" + end + end + + def key_filters(keyexp, vp, params) + return [] unless keyexp + if vp.key?(:key) + params[:key] = vp[:key]; ["#{keyexp} = $key"] + elsif vp.key?(:keys) + params[:keys] = vp[:keys]; ["#{keyexp} IN $keys"] + else + low, high = vp[:descending] ? %i[endkey startkey] : %i[startkey endkey] + f = [] + (params[:startkey] = vp[low]; f << "#{keyexp} >= $startkey") if vp.key?(low) + (params[:endkey] = vp[high]; f << "#{keyexp} <= $endkey") if vp.key?(high) + f + end + end + + def order_opts(opts) + { descending: opts[:descending], limit: opts[:limit], skip: opts[:skip] } + end + + def not_deleted(kls) + a = kls.soft_delete_attribute + "(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)" + end + + def soft_deletes?(kls) + kls.respond_to?(:soft_deleting_enabled?) && kls.soft_deleting_enabled? + end + + def deleted_spec?(spec) + spec.class.name.end_with?('DeletedModelViewSpec') + end + + # Lazily create the GSIs an association needs — the N1QL analogue of + # SimplyCouch creating a design document on first view query. + def ensure_index(kls, columns) + key = [kls.name, columns].inspect + return if @ensured_indexes[key] + name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60] + cols = columns.map { |col| "`#{col}`" }.join(', ') + query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(#{cols}) WHERE `ruby_class` = '#{kls.name}'") + @ensured_indexes[key] = true + rescue ::Couchbase::Error::CouchbaseError + @ensured_indexes[key] = true + end + + def ensure_array_index(kls, column) + key = [kls.name, :array, column].inspect + return if @ensured_indexes[key] + name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60] + query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(DISTINCT ARRAY x FOR x IN `#{column}` END) WHERE `ruby_class` = '#{kls.name}'") + @ensured_indexes[key] = true + rescue ::Couchbase::Error::CouchbaseError + @ensured_indexes[key] = true + end + + # ── persistence helpers ────────────────────────────────────────────────── + + def create_document(document, validate) + document.database = self + if validate + document.errors.clear + return false if false == document.run_callbacks(:validation) do + return false if false == document.run_callbacks(:validation_on_create) do + return false unless valid_document?(document) + end + end + end + return false if false == document.run_callbacks(:save) do + return false if false == document.run_callbacks(:create) do + document._id ||= SecureRandom.uuid + res = collection.insert(document._id, document.to_hash) + document._rev = res.cas.to_s + end + end + true + end + + def update_document(document, validate) + if validate + document.errors.clear + return false if false == document.run_callbacks(:validation) do + return false if false == document.run_callbacks(:validation_on_update) do + return false unless valid_document?(document) + end + end + end + return false if false == document.run_callbacks(:save) do + return false if false == document.run_callbacks(:update) do + persist(document) if document.changed? + end + end + true + end + + def persist(document) + if document.new? + document._id ||= SecureRandom.uuid + _track("INSERT #{document._id}") + res = collection.insert(document._id, document.to_hash) + else + opts = ::Couchbase::Options::Replace.new + opts.cas = document._rev.to_i if document._rev + _track("REPLACE #{document._id}") + res = collection.replace(document._id, document.to_hash, opts) + end + document._rev = res.cas.to_s + document._id + end + + def valid_document?(document) + original = document.errors.to_hash + document.valid? + original.each { |k, v| Array(v).each { |m| document.errors.add(k, m) } } + document.errors.empty? + end + + # ── hydration ──────────────────────────────────────────────────────────── + + def row_to_doc(row) + id = row['__id'] || row['_id'] + cas = row['__cas'] # the document's CAS is its _rev surrogate + doc = row.reject { |k, _| k == '__id' || k == '__cas' } + doc['_id'] ||= id + hydrate(doc, id, cas || doc['_rev']) + end + + def hydrate(content, id, rev) + return content unless content.is_a?(Hash) + hash = content.dup + hash['_id'] ||= id + hash['_rev'] = rev.to_s unless rev.nil? + model = SimplyBased::DocumentCodec.from_hash(hash) + model.database = self if model.respond_to?(:database=) + model + end + + def assign_db(results) + return unless results.respond_to?(:each) + results.each { |d| d.database = self if d.respond_to?(:database=) } + end + + def parse_connection(connection) + return connection if connection.is_a?(Hash) + uri = URI.parse(connection.to_s) + q = URI.decode_www_form(uri.query.to_s).to_h + host = [uri.host, (":#{uri.port}" if uri.port)].compact.join + { + conn_str: "#{uri.scheme || 'couchbase'}://#{host}", + user: uri.user && URI.decode_www_form_component(uri.user), + password: uri.password && URI.decode_www_form_component(uri.password), + bucket: uri.path.sub(%r{\A/}, ''), + scope: q['scope'], + collection: q['collection'] + } + end + end +end diff --git a/simply_based.gemspec b/simply_based.gemspec new file mode 100644 index 0000000..53caa04 --- /dev/null +++ b/simply_based.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = 'simply_based' + s.version = '0.1.0' + s.license = 'MIT' + + s.summary = 'Couchbase-native ActiveModel ORM' + s.description = 'A Couchbase-native ActiveModel ORM with associations, ' \ + 'validations, callbacks, soft delete and N1QL-backed finders. ' \ + 'API-compatible with simply_couch (CouchDB) — swap ' \ + 'include SimplyCouch::Model for include SimplyBased::Model. ' \ + 'Also provides Couchbase-backed atomic counters, a Rails cache ' \ + 'store and distributed locks to replace Redis.' + s.authors = ['Benjamin ter Kuile'] + s.email = 'bterkuile@gmail.com' + s.homepage = 'https://github.com/bterkuile/simply_based' + + s.required_ruby_version = '>= 3.1' + + s.metadata = { + 'source_code_uri' => 'https://github.com/bterkuile/simply_based', + 'rubygems_mfa_required' => 'true' + } + + s.files = Dir['lib/**/*.rb', 'lib/**/*.yml', 'LICENSE.txt', 'README.md', 'CHANGELOG.md'] + s.require_paths = ['lib'] + + s.add_dependency 'activemodel', '>= 6.0' + s.add_dependency 'activesupport', '>= 6.0' + s.add_dependency 'couchbase', '>= 3.0' + + s.add_development_dependency 'rspec', '~> 3.0' +end diff --git a/spec/active_record_compatibility_spec.rb b/spec/active_record_compatibility_spec.rb new file mode 100644 index 0000000..2969dd3 --- /dev/null +++ b/spec/active_record_compatibility_spec.rb @@ -0,0 +1,7 @@ +require 'spec_helper' + +describe 'Active record compatibility' do + it "had a proper primary key definition" do + Post.primary_key.should eq 'id' + end +end diff --git a/spec/ancestry_spec.rb b/spec/ancestry_spec.rb new file mode 100644 index 0000000..0fda270 --- /dev/null +++ b/spec/ancestry_spec.rb @@ -0,0 +1,289 @@ +require 'spec_helper' + +RSpec.describe 'Ancestry' do + context "standard with hierarchy" do + before do + @d1 = Directory.create(name: 'dir1', position: 0) + @d2 = Directory.create(name: 'dir2', position: 1) + @d3 = Directory.create(name: 'dir3', position: 2) + @d4 = Directory.create(name: 'dir4', position: 3) + @a = [@d1, @d2, @d3, @d4] + end + + it "is invalid if make_invalid is an option" do + expect( @d1.update_attributes make_invalid: true ).to be false + end + + describe '#path_ids' do + it "includes the self.id in path_ids and is [self.id] when record has no parent" do + expect( @a.map(&:id) ).to eq @a.map(&:path_ids).flatten + end + end + + it "sets valid children when mass assigned" do + @d1.children = [@d2, @d3] + expect( @d1.children.map(&:id) ).to match_array [@d2.id, @d3.id] + expect( @d2.path_ids ).to eq [@d1.id, @d2.id] + d1_reloaded = Directory.find(@d1.id) + expect( d1_reloaded.children.map(&:name) ).to match_array [@d2.name, @d3.name] + expect( d1_reloaded.children.sort_by(&:name).first.path_ids ).to eq [@d1.id, @d2.id] + end + + it "is valid when parent is assigned" do + @d1.children = [@d2, @d3] # See previous test for this one + @d4.parent = @d2 # This is the functionality to be tested here + + expect( @d2.children ).to eq [@d4] + expect( @d4.parent ).to eq @d2 + expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + + # Also test database values + d2_reloaded = Directory.find(@d2.id) + d4_reloaded = Directory.find(@d4.id) + expect( d2_reloaded.children ).to eq [@d4] + expect( d4_reloaded.parent ).to eq @d2 + expect( d4_reloaded.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + end + + it "handles roots" do + expect( Directory.roots.sort_by(&:id) ).to eq @a.sort_by(&:id) + @d1.children = [@d2, @d3] + expect( Directory.roots ).to match_array [@d1, @d4] + end + + it "returns proper ancestors" do + @d1.children = [@d2, @d3] + @d3.add_child(@d4).save + expect( @d1.ancestors ).to be_empty + expect( @d2.ancestors ).to eq [@d1] + expect( @d3.ancestors ).to eq [@d1] + expect( @d4.ancestors ).to eq [@d1, @d3] + d4_reloaded = Directory.find(@d4.id) + expect( d4_reloaded.ancestors ).to eq [@d1, @d3] + end + + it "returns proper descendants" do + @d1.children = [@d2, @d3] + @d2.children = [@d4] + expect( @d1.descendants ).to match_array [@d2, @d3, @d4] + end + + it "gives a proper subtree and does not change the subject" do + # This is a relevant test since it failed in the past + @d1.children = [@d2] + @d2.children = [@d3, @d4] + expect( @d2.subtree ).to eq [@d3, @d4] + @d2.reload + # This is what it is about + expect( @d2.path_ids ).to eq [@d1.id, @d2.id] + end + + it "updates path_ids on deeper nested elements using children=" do + @d2.children = [@d3, @d4] + @d1.children = [@d2] + + # Not for now, objects self are not updated + #expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + #expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + @d3.reload + @d4.reload + expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + end + + it "updates path_ids on deeper nested elements using parent_id=" do + @d2.children = [@d3, @d4] + [@d2, @d3, @d4].map(&:reload) + @d2.parent_id = @d1.id + + # Not for now, objects self are not updated + #expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + #expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + @d3.reload + @d4.reload + expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + end + + it "updates path_ids on deeper nested elements using parent_id= nil" do + # Copy of previous + @d2.children = [@d3, @d4] + [@d2, @d3, @d4].map(&:reload) + @d2.parent_id = @d1.id + + # Not for now, objects self are not updated + #expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + #expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + [@d2, @d3, @d4].map(&:reload) + expect( @d3.path_ids ).to eq [@d1.id, @d2.id, @d3.id] + expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + + @d2.parent_id = "" + [@d2, @d3, @d4].map(&:reload) + expect( @d3.path_ids ).to eq [@d2.id, @d3.id] + expect( @d4.path_ids ).to eq [@d2.id, @d4.id] + end + + it "preserve children of parent when another is added through parent_id=" do + # Yes, this one failed once + + # Copy of previous + @d2.children = [@d3] + [@d2, @d3, @d4].map(&:reload) + @d4.parent_id = @d2.id + [@d2, @d3, @d4].map(&:reload) + expect( @d2.children ).to eq [@d3, @d4] + end + + it "not update descendants when parent_id is set in update_attributes and save is not valid" do + # Copy of previous + @d2.children = [@d3, @d4] + [@d2, @d3, @d4].map(&:reload) + @d2.update_attributes(:parent_id => @d1.id, :make_invalid => true) + + [@d1, @d2, @d3, @d4].map(&:reload) + expect( @d3.path_ids ).to eq [@d2.id, @d3.id] + expect( @d4.path_ids ).to eq [@d2.id, @d4.id] + + end + + it 'build tree returning actual objects set in descendants' do + @d1.children = [@d2, @d3] + [@d1, @d2, @d3].map(&:reload) + expect( @d1.descendants.map(&:object_id).sort ).to eq @d1.subtree.map(&:object_id).sort + end + + context "initialization" do + it "initializes a new record without an id" do + expect( Directory.new(parent_id: @d3.id).id ).not_to be_present + end + end + end + + context "namespaced with hierarchy" do + before do + @d1 = NamespacedDirectory.create(:name => 'dir1', :locale => 'en') + @d2 = NamespacedDirectory.create(:name => 'dir2', :locale => 'en') + @d3 = NamespacedDirectory.create(:name => 'dir3', :locale => 'nl') + @d4 = NamespacedDirectory.create(:name => 'dir4', :locale => 'nl') + @a = [@d1, @d2, @d3, @d4] + end + + it "all have a path containing own id" do + expect( @a.map(&:path_ids).flatten ).to eq @a.map(&:id) + end + + it "set valid children when mass assigned" do + @d1.children = [@d2, @d3] + expect( @d1.children ).to match_array [@d2, @d3] + expect( @d2.path_ids ).to eq [@d1.id, @d2.id] + d1_reloaded = NamespacedDirectory.find(@d1.id) + expect( d1_reloaded.children ).to match_array [@d2, @d3] + expect( d1_reloaded.children.sort_by(&:name).first.path_ids ).to eq [@d1.id, @d2.id] + end + + it "be valid when parent is assigned" do + @d1.children = [@d2, @d3] # See previous test for this one + @d4.parent = @d2 # This is the functionality to be tested here + + expect( @d2.children ).to eq [@d4] + expect( @d4.parent ).to eq @d2 + expect( @d4.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + + # Also test database values + d2_reloaded = NamespacedDirectory.find(@d2.id) + d4_reloaded = NamespacedDirectory.find(@d4.id) + expect( d2_reloaded.children ).to eq [@d4] + expect( d4_reloaded.parent ).to eq @d2 + expect( d4_reloaded.path_ids ).to eq [@d1.id, @d2.id, @d4.id] + end + + it "return proper parents" do + @d1.children = [@d2, @d3] + @d2.children = [@d4] + expect( @d4.parents ).to eq [@d1, @d2] + end + + it "handle roots" do + expect( NamespacedDirectory.roots ).to match_array @a + end + + it "removes records as root when given as child argument"do + @d1.children = [@d2, @d3] + expect( NamespacedDirectory.roots ).to eq [@d1, @d4] + end + + it "handle roots by property as symbol" do + @d1.children = [@d2, @d3] + expect( NamespacedDirectory.roots(:en) ).to eq [@d1] + expect( NamespacedDirectory.roots(:nl) ).to eq [@d4] + end + + it "handle roots by property as string" do + @d1.children = [@d2, @d3] + expect( NamespacedDirectory.roots('en') ).to eq [@d1] + expect( NamespacedDirectory.roots('nl') ).to eq [@d4] + end + + it "change by property to valid value when assigned as children" do + expect( @d1.locale ).not_to eq @d3.locale + @d1.children = [@d2, @d3] + expect( @d1.locale ).to eq @d3.locale + d3_reloaded = NamespacedDirectory.find(@d3.id) + expect( @d1.locale ).to eq d3_reloaded.locale + end + + it "change by property to valid value when assigned through add_child with save" do + expect( @d1.locale ).not_to eq @d3.locale + @d1.add_child(@d3).save + expect( @d1.locale ).to eq @d3.locale + d3_reloaded = NamespacedDirectory.find(@d3.id) + expect( @d1.locale ).to eq d3_reloaded.locale + end + + it "change by property to valid value when assigned through add_child without save" do + expect( @d1.locale ).not_to eq @d3.locale + @d1.add_child(@d3) + expect( @d1.locale ).to eq @d3.locale + d3_reloaded = NamespacedDirectory.find(@d3.id) + expect( @d4.locale ).to eq d3_reloaded.locale + end + + it "give a proper tree without namespace" do + @d1.children = [@d2, @d3] + full_tree = NamespacedDirectory.full_tree + expect( full_tree ).to match_array [@d1, @d4] + expect( full_tree.sort_by(&:id).map{|p| p.children.map(&:id).sort}.sort ).to eq [[@d2.id, @d3.id].sort, []].sort + end + + it "give a proper tree with namespace as symbol" do + @d1.children = [@d2, @d3] + full_tree = NamespacedDirectory.full_tree(:en) + expect( full_tree.map(&:id) ).to eq [@d1.id] + expect( full_tree.map{|p| p.children.map(&:id).sort} ).to eq [[@d2.id, @d3.id].sort] + end + it "give a proper tree with namespace as string" do + @d1.children = [@d2, @d3] + full_tree = NamespacedDirectory.full_tree('en') + expect( full_tree.map(&:id) ).to eq [@d1.id] + expect( full_tree.map{|p| p.children.map(&:id).sort} ).to eq [[@d2.id, @d3.id].sort] + end + + it "give a proper tree with namespace as property" do + @d1.children = [@d2, @d3] + full_tree = NamespacedDirectory.full_tree(:locale => 'en') + expect( full_tree.map(&:id) ).to eq [@d1.id] + expect( full_tree.map{|p| p.children.map(&:id).sort} ).to eq [[@d2.id, @d3.id].sort] + end + + it "give a proper tree with revisions" do + @d1.children = [@d2, @d3, @d4] + full_tree = NamespacedDirectory.full_tree(:locale => 'en') + expect( full_tree ).to eq [@d1] + end + + end +end +=begin +class AncestryTest < Test::Unit::TestCase +=end diff --git a/spec/attribute_accessing_spec.rb b/spec/attribute_accessing_spec.rb new file mode 100644 index 0000000..42ef758 --- /dev/null +++ b/spec/attribute_accessing_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe "Attribute accessing" do + context "with brackets" do + it "access with string argument" do + user = User.new + user.name = 'UTest' + user['name'].should eq 'UTest' + end + it "access with symbol argument" do + user = User.new + user.name = 'UTest' + user[:name].should eq 'UTest' + end + end +end diff --git a/spec/belongs_to_spec.rb b/spec/belongs_to_spec.rb new file mode 100644 index 0000000..3f6d440 --- /dev/null +++ b/spec/belongs_to_spec.rb @@ -0,0 +1,217 @@ +require 'spec_helper' + +describe "BelongsTo" do + context "with associations" do + context "with belongs_to" do + it "generate a view for the association" do + #Post.respond_to?(:association_post_belongs_to_user).should be_present + Post.should respond_to :association_post_belongs_to_user + end + + it "raise an error if another property with the same name already exists" do + expect { + class ::DoubleBelongsToUser + include SimplyBased::Model + property :user + belongs_to :user + end + }.to raise_error RuntimeError + end + + it "add the foreign key id to the referencing object" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + + post = Post.find(post.id) + post.user_id.should eq user.id + end + + it "create a property for the foreign key attribute" do + Post.properties.any?{|p| p.name.to_s == 'user_id'}.should be true + end + + it "notice a change to the foreign key attribute in dirty checks" do + user = User.create!(:title => 'Prof') + post = Post.create! + post.user = user + post.should be_user_id_changed + end + + it "set also the foreign key id to nil if setting the referencing object to nil" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + post.user = nil + post.save! + post.reload.user.should be nil + post.reload.user_id.should be nil + end + + it "fetch the object from the database when requested through the getter" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + + post = Post.find(post.id) + post.user.should eq user + end + + it "mark the referencing object as dirty" do + user = User.create(:title => "Mr.") + post = Post.create + post.user = user + post.should be_dirty + end + + it "allow assigning a different object and store the id accordingly" do + user = User.create(:title => "Mr.") + user2 = User.create(:title => "Mrs.") + post = Post.create(:user => user) + post.user = user2 + post.save + + post = Post.find(post.id) + post.user.id.should eq user2.id + end + + it "check the class and raise an error if not matching in belongs_to setter" do + post = Post.create + expect{ post.user = 'foo' }.to raise_error(ArgumentError, 'expected User got String') + end + + it 'not query for the object twice in getter' do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + post = Post.find(post.id) + expect( User ).to receive(:find).and_return "user" + post.user + expect( User ).not_to receive(:find) + post.user + end + + it 'use cache in getter' do + post = Post.create + post.instance_variable_set("@user", 'foo') + post.user.should eq 'foo' + end + + it "ignore the cache if force_reload is given as an option" do + user = User.create(:name => 'Dude', :title => 'Mr.') + post = Post.create(:user => user) + post.reload + post.instance_variable_set("@user", 'foo') + post.user(:force_reload => true).should_not eq 'foo' + end + + it 'set cache in setter' do + post = Post.create + user = User.create :title => 'Mr.' + post.instance_variable_get("@user").should be nil + post.user = user + post.instance_variable_get("@user").should eq user + end + + it "not hit the database when the id column is empty" do + expect( User ).not_to receive(:find) + post = Post.create + post.user + end + + it "know when the associated object changed" do + post = Post.create(:user => User.create(:title => "Mr.")) + user2 = User.create(:title => "Mr.") + post.user = user2 + post.should be_user_id_changed + end + + it "not be changed when an association has not changed" do + post = Post.create(:user => User.create(:title => "Mr.")) + post.should_not be_user_id_changed + end + + it "not be changed when assigned the same object" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + post.user = user + post.should_not be_user_changed + end + + it "not be changed after saving" do + user = User.create(:title => "Mr.") + post = Post.new + post.user = user + post.should be_user_id_changed + post.save! + post.should_not be_user_id_changed + end + + it "have a proper _was value" do + user = User.create(:title => "Mr.") + user2 = User.create(:title => "Mrs.") + post = Post.new + + post.save + post.user.should be nil + post.user_id.should be nil + + post.user = user + #post.user_was.should be nil + post.user_id_was.should be nil + + post.save + + post.user = user2 + #post.user_was.should eq user + post.user_id_was.should eq user.id + end + + it "set the parent object on has_many association" do + # Copy from has_many, but belongs here as well + expect( User ).not_to receive(:find) + user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = user + post.save! + } + post = user.posts.first + user.posts.first.user.should eq user + end + + it "set the parent object on has_one association" do + # Taken from has_one, but belongs here as well + expect( Instance ).not_to receive(:find) + instance = Instance.create + identity = Identity.create(:instance => instance) + + instance.identity.instance.should eq instance + end + + it "handle a foreign_key of '' as nil" do + post = Post.create + post.user_id = '' + + post.user.should be nil + end + + context "with aliased associations" do + it "allow different names for the same class" do + editor = User.create(:name => 'Editor', :title => 'Dr.') + author = User.create(:name => 'author', :title => 'Dr.') + editor.id.should_not be nil + author.id.should_not be nil + + doc = Document.create(:editor => editor, :author => author) + doc.save! + doc.editor_id.should eq editor.id + doc.author_id.should eq author.id + doc = Document.find(doc.id) + doc.editor.should_not be nil + doc.author.should_not be nil + doc.editor.id.should eq editor.id + doc.author.id.should eq author.id + end + end + end + end + + +end diff --git a/spec/build_tree_spec.rb b/spec/build_tree_spec.rb new file mode 100644 index 0000000..e5358ac --- /dev/null +++ b/spec/build_tree_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe "Ancestry.build_tree" do + def node(id, path_ids, position = 0) + d = Directory.new(name: id) + d._id = id + d.path_ids = path_ids + d.position = position + d + end + + it "builds a tree from a flat list using path_ids" do + root = node('r', ['r']) + child = node('c', ['r', 'c']) + roots = Directory.build_tree([root, child]) + roots.map(&:id).should eq(['r']) + roots.first.children.map(&:id).should eq(['c']) + end + + it "does not leak a class-level @tree_wrapper after building" do + Directory.build_tree([node('r', ['r'])]) + Directory.instance_variable_get(:@tree_wrapper).should be_nil + end + + it "is safe under concurrent calls (no shared-state corruption)" do + threads = (0...8).map do |i| + Thread.new do + root = node("r#{i}", ["r#{i}"]) + child = node("c#{i}", ["r#{i}", "c#{i}"]) + roots = Directory.build_tree([root, child]) + [roots.map(&:id), roots.first.children.map(&:id)] + end + end + results = threads.map(&:value) + results.each_with_index do |(root_ids, child_ids), i| + root_ids.should eq(["r#{i}"]) + child_ids.should eq(["c#{i}"]) + end + end +end diff --git a/spec/bulk_spec.rb b/spec/bulk_spec.rb new file mode 100644 index 0000000..de4dff1 --- /dev/null +++ b/spec/bulk_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +# DRAFT: requires a live CouchDB (like the rest of the suite). These specs +# document the intended contract of the bulk-write API. +describe "Bulk write API" do + describe ".save_all" do + it "persists multiple new records in one call and assigns _id/_rev" do + result = User.save_all([User.new(title: 'a'), User.new(title: 'b')]) + result[:saved].size.should eq(2) + result[:invalid].should be_empty + result[:saved].each do |record| + record._id.should_not be_nil + record._rev.should_not be_nil + record.new?.should be false + end + end + + it "sets timestamps on new records" do + record = User.new(title: 'a') + User.save_all([record]) + record.created_at.should_not be_nil + record.updated_at.should_not be_nil + end + end + + describe ".create_all" do + it "builds and persists records from attribute hashes" do + result = User.create_all([{ title: 'a' }, { title: 'b' }]) + result[:saved].map(&:title).sort.should eq(['a', 'b']) + end + end + + describe ".destroy_all" do + it "deletes persisted records in one call and clears their ids" do + records = User.save_all([User.new(title: 'a'), User.new(title: 'b')])[:saved] + User.destroy_all(records) + records.each { |r| r._id.should be_nil } + end + end +end diff --git a/spec/callbacks_spec.rb b/spec/callbacks_spec.rb new file mode 100644 index 0000000..c0540e4 --- /dev/null +++ b/spec/callbacks_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'callbacks' do + + describe ".before_save" do + let(:subject) { Callbacker.new name: 'Cally' } + it "increments the counter" do + subject.save + subject.reload.counter.should be 1 + end + end + + describe "with raising callback #after_safe" do + let(:subject) { Callbacker.new name: 'Cally', raise_on_save: true } + + it "raise_error on normal save" do + expect{ subject.save }.to raise_error StandardError + end + + it "raises error on save with false, validations are not run only" do + expect{ subject.save(false) }.to raise_error StandardError + end + + it "raises error on save with validate: false, validations are not run only" do + expect{ subject.save(validate: false) }.to raise_error StandardError + end + + it "raise error on save with validate: true" do + expect{ subject.save(validate: true) }.to raise_error StandardError + end + end + + describe "with validation error" do + let(:subject) { Callbacker.new name: 'Cally', with_validation_error: true } + + it "should not save the record" do + subject.save.should be false + subject.id.should_not be_present + end + + it "should save the record on save with false" do + subject.save(false).should be true + subject.id.should be_present + end + + it "should save the record on save with validate: false" do + subject.save(validate: false).should be true + subject.id.should be_present + end + + it "should not save the record on save with validate: true" do + subject.save(validate: true).should be false + subject.id.should_not be_present + end + end +end diff --git a/spec/conflict_handling_spec.rb b/spec/conflict_handling_spec.rb new file mode 100644 index 0000000..eb9a19a --- /dev/null +++ b/spec/conflict_handling_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +describe "ConflictHandling" do + let(:original) { User.create name: 'Mickey Mouse', title: "Dr.", homepage: 'www.gmx.de'} + let!(:copy) { User.find(original.id) } + + context "when handling conflicts" do + before do + User.auto_conflict_resolution_on_save = true + end + + it "should be able to save without modifications" do + copy.save.should be true + end + + it "be able to save when modification happen on different attributes" do + original.name = "Pluto" + original.save.should be true + + copy.title = 'Prof.' + expect { copy.save }.not_to raise_error + + copy.reload.name.should eq "Pluto" + copy.reload.title.should eq "Prof." + copy.reload.homepage.should eq "www.gmx.de" + end + + it "be able to save when modification happen on different, multiple attributes - remote" do + original.name = "Pluto" + original.homepage = 'www.google.com' + original.save.should be true + + copy.title = 'Prof.' + expect { copy.save }.not_to raise_error + + copy.reload.name.should eq "Pluto" + copy.reload.title.should eq "Prof." + copy.reload.homepage.should eq "www.google.com" + end + + it "be able to save when modification happen on different, multiple attributes locally" do + original.name = "Pluto" + original.save.should be true + + copy.title = 'Prof.' + copy.homepage = 'www.google.com' + expect { copy.save }.not_to raise_error + + copy.reload.name.should eq "Pluto" + copy.reload.title.should eq "Prof." + copy.reload.homepage.should eq "www.google.com" + end + + it "re-raise the conflict if there is no merge possible" do + original.name = "Pluto" + original.save.should be true + + copy.name = 'Prof.' + expect { copy.save }.to raise_error SimplyBased::Conflict + + copy.name.should eq "Prof." + copy.reload.name.should eq "Pluto" + end + + it "re-raise the conflict if retried several times" do + allow( User.database ).to receive(:save_document).and_raise(SimplyBased::Conflict.new) + + copy.name = 'Prof.' + expect { copy.save }.to raise_error SimplyBased::Conflict + end + + it "not try to merge and re-save if auto_conflict_resolution_on_save is disabled" do + User.auto_conflict_resolution_on_save = false + allow( User.database ).to receive(:save_document).and_raise(SimplyBased::Conflict.new) + + copy.name = 'Prof.' + expect { copy.save }.to raise_error SimplyBased::Conflict + end + + context "with conflict information" do + + it "add information about the conflict to the exception" do + original.name = "Pluto" + original.save.should be true + + copy.name = 'Prof.' + begin + copy.save + rescue SimplyBased::Conflict => e + e.message.should eq '409 Conflict - conflict on attributes: ["name"]' + end + end + + it "only add the conflict information to one exception" do + other_copy = User.find(original.id) + + original.name = "Pluto" + original.title = "Frau" + original.save.should be true + + copy.name = 'Prof.' + begin + copy.save.should be true + rescue SimplyBased::Conflict => e + e.message.should eq '409 Conflict - conflict on attributes: ["name"]' + end + + other_copy.title = 'Prof.' + begin + other_copy.save + rescue SimplyBased::Conflict => e + e.message.should eq '409 Conflict - conflict on attributes: ["title"]' + end + end + end + + end +end diff --git a/spec/couch_attachment_spec.rb b/spec/couch_attachment_spec.rb new file mode 100644 index 0000000..57b6b7e --- /dev/null +++ b/spec/couch_attachment_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +class CouchAttachmentDoc + include SimplyBased::Model + property :title + has_couch_attached :receipt +end + +describe 'CouchDB attachments', :couchdb_only do + describe 'low-level API' do + it 'puts and fetches an attachment' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.put_couch_attachment('file.txt', 'hello world', content_type: 'text/plain') + expect(doc.fetch_couch_attachment('file.txt')).to eq 'hello world' + end + + it 'lists attachment names' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.put_couch_attachment('a.txt', 'a') + doc.put_couch_attachment('b.txt', 'b') + expect(doc.couch_attachment_names).to contain_exactly('a.txt', 'b.txt') + end + + it 'deletes an attachment' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.put_couch_attachment('file.txt', 'data') + doc.delete_couch_attachment('file.txt') + expect(doc.couch_attachment?('file.txt')).to be false + end + + it 'returns nil for missing attachment' do + doc = CouchAttachmentDoc.create(title: 'test') + expect(doc.fetch_couch_attachment('missing.txt')).to be_nil + end + end + + describe 'has_couch_attached macro' do + it 'defines getter and setter' do + doc = CouchAttachmentDoc.new + expect(doc).to respond_to(:receipt) + expect(doc).to respond_to(:receipt=) + end + + it 'defines URL helper' do + doc = CouchAttachmentDoc.new + expect(doc).to respond_to(:receipt_url) + end + + it 'defines metadata properties' do + doc = CouchAttachmentDoc.new + expect(doc).to respond_to(:receipt_content_type) + expect(doc).to respond_to(:receipt_content_type=) + expect(doc).to respond_to(:receipt_size) + expect(doc).to respond_to(:receipt_size=) + end + + it 'stores and retrieves data through the setter/getter' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.receipt = 'binary data here' + doc.save + + reloaded = CouchAttachmentDoc.find(doc.id) + expect(reloaded.receipt).to eq 'binary data here' + end + + it 'tracks content type' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.receipt = 'data' + doc.save + expect(doc.receipt_content_type).to eq 'binary/octet-stream' + end + + it 'tracks size' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.receipt = '12345' + doc.save + expect(doc.receipt_size).to eq 5 + end + + it 'handles non-persisted records via pending queue' do + doc = CouchAttachmentDoc.new(title: 'draft') + doc.receipt = 'pending data' + result = doc.save + + expect(result).to be_truthy + reloaded = CouchAttachmentDoc.find(doc.id) + expect(reloaded.receipt).to eq 'pending data' + end + + it 'updates attachment on second assignment' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.receipt = 'v1' + doc.save + + doc.receipt = 'v2' + doc.save + + reloaded = CouchAttachmentDoc.find(doc.id) + expect(reloaded.receipt).to eq 'v2' + end + + it 'generates a URL' do + doc = CouchAttachmentDoc.create(title: 'test') + doc.receipt = 'data' + doc.save + + url = doc.receipt_url + expect(url).to include(doc._id) + expect(url).to include('receipt') + end + end +end diff --git a/spec/default_value_isolation_spec.rb b/spec/default_value_isolation_spec.rb new file mode 100644 index 0000000..21bc674 --- /dev/null +++ b/spec/default_value_isolation_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe "Default value isolation" do + # Directory (has_ancestry) declares `property :path_ids, type: Array, default: []`. + # Each instance must get its own copy of the default, never a shared array. + it "does not share a mutable Array default across instances" do + a = Directory.new + b = Directory.new + a.path_ids << 'x' + b.path_ids.should eq([]) + a.path_ids.should eq(['x']) + end + + it "gives each instance an independent default object" do + a = Directory.new + b = Directory.new + a.path_ids.object_id.should_not eq(b.path_ids.object_id) + end +end diff --git a/spec/embedding_spec.rb b/spec/embedding_spec.rb new file mode 100644 index 0000000..be13275 --- /dev/null +++ b/spec/embedding_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' + +describe 'Embedding' do + context 'initialized comments' do + before do + @post = Post.new + @post.embedded_comments = [ + { 'ruby_class' => 'EmbeddedComment', 'body' => 'body1' }, + { 'ruby_class' => 'EmbeddedComment', 'body' => 'body2' } + ] + @post.save + end + + it 'return a valid size' do + expect(@post.embedded_comments.size).to eq 2 + post_reloaded = Post.find(@post.id) + expect(post_reloaded.embedded_comments.size).to eq 2 + end + + it 'delete comment using object' do + @post.remove_embedded_comment(@post.embedded_comments.first) + expect(@post.embedded_comments.size).to eq 1 + post_reloaded = Post.find(@post.id) + expect(post_reloaded.embedded_comments.size).to eq 1 + end + + it 'get all emmbedded using .all' do + expect(EmbeddedComment.all.size).to eq 2 + end + + it 'get embedded object, not a hash' do + expect(EmbeddedComment.all.first).to be_a EmbeddedComment + end + + it 'have a parent_object when loaded through all' do + expect(EmbeddedComment.all.first.parent_object).to eq @post + end + + it 'save an instance' do + comment = @post.embedded_comments.first + comment.body = 'body-changed' + comment.save + comment_reloaded = Post.find(@post.id).embedded_comments.first + expect(comment_reloaded.body).to eq 'body-changed' + end + + it 'change attribute when not loaded through parent object' do + embedded_comments = EmbeddedComment.all + embedded_comment = embedded_comments.first + embedded_comment.body = 'newbody' + embedded_comment.save + embedded_comments_reloaded = EmbeddedComment.all + expect(embedded_comments_reloaded.map(&:body)).to include('newbody') + end + + it 'change attribute when saved through parent object' do + embedded_comments = EmbeddedComment.all + embedded_comment = embedded_comments.first + embedded_comment.body = 'newbody' + embedded_comment.parent_object.send(:attribute_will_change!, :embedded_comments) + embedded_comment.parent_object.save + embedded_comments_reloaded = EmbeddedComment.all + expect(embedded_comments_reloaded.map(&:body)).to include('newbody') + end + + it 'delete comment using integer' do + end + + it 'delete comment using integer string' do + end + + it 'Count' do + expect(EmbeddedComment.count).to eq 2 + end + end + + context 'Creation of comment' do + before { @post = Post.create } + + it 'not save when no parent is present' do + comment = EmbeddedComment.new(body: 'no parent') + expect(comment.save).to be false + expect(comment.errors[:post]).to include('no_parent') + end + + it 'save when initialized with parent actual name initialization' do + comment = EmbeddedComment.new(body: 'no parent', post: @post) + expect(comment.save).to be true + expect(comment.post).to eq @post + expect(comment.parent_object).to eq @post + expect(@post.embedded_comments.include?(comment)).to be true + reloaded_post = Post.find(@post.id) + expect(reloaded_post.embedded_comments.include?(comment)).to be true + end + + it 'save when initialized with parent object initialization' do + comment = EmbeddedComment.new(body: 'no parent', parent_object: @post) + expect(comment.save).to be true + expect(comment.post).to eq @post + expect(comment.parent_object).to eq @post + expect(@post.embedded_comments.include?(comment)).to be true + expect(comment.save).to be true + reloaded_post = Post.find(@post.id) + expect(reloaded_post.embedded_comments.include?(comment)).to be true + end + + it 'save when parent object is assigned later with relation name' do + comment = EmbeddedComment.new(body: 'no parent') + comment.post = @post + expect(comment.save).to be true + expect(comment.post).to eq @post + expect(comment.parent_object).to eq @post + expect(@post.embedded_comments.include?(comment)).to be true + reloaded_post = Post.find(@post.id) + expect(reloaded_post.embedded_comments.include?(comment)).to be true + end + + it 'save when parent object is assigned later with parent object assignment' do + comment = EmbeddedComment.new(body: 'no parent') + comment.parent_object = @post + expect(comment.save).to be true + expect(comment.post).to eq @post + expect(comment.parent_object).to eq @post + expect(@post.embedded_comments.include?(comment)).to be true + reloaded_post = Post.find(@post.id) + expect(reloaded_post.embedded_comments.include?(comment)).to be true + end + end + + context 'belongs to strict_post' do + before do + @user = User.create(name: 'embedding user') + @strict_post = StrictPost.create(user: @user) + @post = Post.new + @post.embedded_comments = [ + { 'ruby_class' => 'EmbeddedComment', 'body' => 'body1' }, + { 'ruby_class' => 'EmbeddedComment', 'body' => 'body2' } + ] + @post.save + end + + it 'add embedded comments to strict_post' do + expect(@strict_post.save).to be true + @post.embedded_comments.each { |ec| ec.strict_post = @strict_post; ec.save } + strict_post_reloaded = StrictPost.find(@strict_post.id) + expect(strict_post_reloaded.embedded_comments.size).to eq @post.embedded_comments.size + end + + it 'have strict_post as association' do + expect(@strict_post.save).to be true + @post.embedded_comments.each { |ec| ec.strict_post = @strict_post; ec.save } + post_reloaded = Post.find(@post.id) + comment_reloaded = post_reloaded.embedded_comments.first + expect(comment_reloaded.strict_post).to eq @strict_post + end + + it 'have parent object when queried through relation' do + expect(@strict_post.save).to be true + @post.embedded_comments.each { |ec| ec.strict_post = @strict_post; ec.save } + strict_post_reloaded = StrictPost.find(@strict_post.id) + expect(strict_post_reloaded.embedded_comments.first).to eq @post.embedded_comments.first + expect(strict_post_reloaded.embedded_comments.first.post).to eq @post + end + + it 'have actual object same as in parent object' do + expect(@strict_post.save).to be true + @post.embedded_comments.each { |ec| ec.strict_post = @strict_post; ec.save } + strict_post_reloaded = StrictPost.find(@strict_post.id) + embedded_comment = strict_post_reloaded.embedded_comments.first + expect(embedded_comment.parent_object.embedded_comments.map(&:object_id)).to include(embedded_comment.object_id) + end + end +end diff --git a/spec/equality_spec.rb b/spec/equality_spec.rb new file mode 100644 index 0000000..5b6fba9 --- /dev/null +++ b/spec/equality_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe "Equality and hashing" do + describe "unsaved records" do + it "two distinct new records are not equal" do + a = User.new(name: 'a') + b = User.new(name: 'a') + (a == b).should be false + a.eql?(b).should be false + end + + it "a new record equals itself" do + a = User.new(name: 'a') + (a == a).should be true + end + + it "new records are distinct inside a Set / uniq / include?" do + a = User.new(name: 'a') + b = User.new(name: 'b') + [a, b].uniq.size.should eq(2) + Set.new([a, b]).size.should eq(2) + [a].include?(b).should be false + end + end + + describe "persisted records" do + it "two instances with the same _id and _rev are equal" do + a = User.new(name: 'a') + b = User.new(name: 'b') + a._id = b._id = 'same-id' + a._rev = b._rev = '1-abc' + (a == b).should be true + a.hash.should eq(b.hash) + end + + it "same _id but different _rev are not equal" do + a = User.new + b = User.new + a._id = b._id = 'same-id' + a._rev = '1-abc' + b._rev = '2-def' + (a == b).should be false + end + end +end diff --git a/spec/finder_spec.rb b/spec/finder_spec.rb new file mode 100644 index 0000000..2b1518a --- /dev/null +++ b/spec/finder_spec.rb @@ -0,0 +1,236 @@ +require 'spec_helper' + + +describe "Finder" do + context "when finding instances" do + context "with find(:all)" do + before do + User.create(title: "Mr.") + User.create(title: "Mrs.") + end + + it "return all instances" do + User.find(:all).size.should eq 2 + end + + it "allow a limit" do + User.find(:all, limit: 1).size.should eq 1 + end + + it "allow to order the results" do + User.find(:all, order: :desc).map(&:id).should_not eq User.find(:all).map(&:id) + User.find(:all, order: :desc).map(&:id).should eq User.find(:all).map(&:id).reverse + end + end + + context "to find all instances" do + it 'generate a default find_all view' do + User.should respond_to :all_documents + end + + it 'return all the users when calling all' do + User.create(title: "Mr.") + User.create(title: "Mrs.") + User.all.size.should eq 2 + end + end + + context "to find one instance" do + it 'return one user when calling first' do + user = User.create(title: "Mr.") + User.first.should eq user + end + + it 'understand the order' do + expect{ User.first(order: :desc) }.not_to raise_error + end + + it 'find the last as a reverse first' do + expect( User ).to receive(:find).with(:first, {order: :desc}) + User.last + end + + it 'return nil when no user found' do + User.first.should be nil + end + end + + context "when finding with just an identifier" do + it "find just one instance" do + user = User.create(title: "Mr.") + User.find(user.id).should be_kind_of User + end + + it 'raise an error when no record was found and tell you which class failed to load something' do + expect{ User.find 'abc' }.to raise_error SimplyBased::RecordNotFound, "User could not be found with \"abc\"" + end + + it 'raise an error when nil was specified' do + expect{ User.find(nil) }.to raise_error SimplyBased::Error + end + + it 'raise an error when the record was not of the expected type' do + post = Post.create + expect{ User.find(post.id) }.to raise_error SimplyBased::RecordNotFound + end + end + + context "with a find_by prefix" do + it "create a view for the called finder" do + User.find_by_name("joe") + User.should respond_to :by_name + end + + it 'not create the view when it already exists' do + expect( User ).not_to receive :view + User.find_by_name_and_created_at("joe", 'foo') + end + + it "create a method to prevent future loops through method_missing" do + expect(User).to receive(:method_missing).and_call_original + User.find_by_title("Mr.") + User.find_by_title("Mrs.") + end + + it "call the generated view and return the result" do + user = User.create(homepage: "http://www.peritor.com", title: "Mr.") + User.find_by_homepage("http://www.peritor.com").should eq user + end + + it 'find only one instance when using find_by' do + User.create(title: "Mr.") + User.find_by_title("Mr.").should be_a User + end + + it "find a record through an association" do + user = User.create(title: "Mr.") + post = Post.create user: user + Post.find_by_user(user).should eq post + end + + it "find a record through an association that raises when not found" do + user = User.create(title: "Mr.") + post = Post.create user: user + Post.find_by_user!(user).should eq post + end + + it "raise an error if the parameters don't match" do + expect{ User.find_by_title() }.to raise_error ArgumentError + expect{ User.find_by_title(1,2,3,4,5) }.to raise_error ArgumentError + end + + it "return nil when no result is found" do + User.find_by_title("Mr.").should be nil + end + + it "return nil for multiple finders when no result is found" do + User.find_by_title_and_homepage("Mr.", "http://www.companytools.nl/").should be nil + end + + it "find a record when it ends with an exclamation mark!" do + User.create(title: "Mr.") + User.find_by_title!("Mr.").should be_a User + end + + it "return raise a not found exception when called using an exclamation mark! and is no record is found" do + expect{ User.find_by_title!("Mr.") }.to raise_error SimplyBased::RecordNotFound + end + + it "find a record using multiple arguments when it ends with an exclamation mark!" do + User.create(title: "Mr.", homepage: "http://www.companytools.nl/") + User.find_by_title_and_homepage!("Mr.", "http://www.companytools.nl/").should be_a User + end + + it "return raise a not found exception when called using an exclamation mark! and no record is found" do + expect{ User.find_by_title_and_homepage!("Mr.", "http://www.companytools.nl/") }.to raise_error SimplyBased::RecordNotFound + end + end + + context "with a find_all_by prefix" do + it "create a view for the called finder" do + User.find_all_by_name("joe") + User.should respond_to :by_name + end + + it 'not create the view when it already exists' do + expect( User ).not_to receive(:view) + User.find_all_by_name_and_created_at("joe", "foo") + end + + it "create a method to prevent future loops through method_missing" do + expect(User).to receive(:method_missing).and_call_original + User.find_all_by_foo_attribute("Mr.") + User.find_all_by_foo_attribute("Mrs.") + end + + it "call the generated view and return the result" do + user = User.create(homepage: "http://www.peritor.com", title: "Mr.") + User.find_all_by_homepage("http://www.peritor.com").should eq [user] + end + + it "return an emtpy array if none found" do + User.find_all_by_title('Mr. Magoooo').should be_empty + end + + it 'find all instances when using find_all_by' do + User.create(title: "Mr.") + User.create(title: "Mr.") + User.find_all_by_title("Mr.").size.should eq 2 + end + + it "find all instances when specifying keys" do + User.create name: 'john', title: 'Mr.' + User.create name: 'doe', title: 'Mr.' + User.find_all_by_name(keys: ['john', 'doe', 'jane']).map(&:name).should match_array %w[doe john] + end + + it "raise an error if the parameters don't match" do + expect{ User.find_all_by_title() }.to raise_error ArgumentError + expect{ User.find_all_by_title(1,2,3,4,5) }.to raise_error ArgumentError + end + + it "return an empty array for multiple finders when no result is found" do + User.find_all_by_title_and_homepage("Mr.", "http://www.companytools.nl/").should be_empty + end + + it "find records when the finder ends with an exclamation mark!" do + u1 = User.create(name: 'john', title: "Mr.") + u2 = User.create(name: 'doe', title: "Mr.") + User.find_all_by_title!("Mr.").map(&:name).should match_array %w[doe john] + end + + it "return raise a not found exception when called using an exclamation mark! and is no records are found" do + expect{ User.find_all_by_title!("Mr.") }.to raise_error SimplyBased::RecordNotFound + end + + it "find records using multiple arguments when the finder ends with an exclamation mark!" do + User.create(name: 'john', title: "Mr.", homepage: "http://www.companytools.nl/") + User.create(name: 'doe', title: "Mr.", homepage: "http://www.companytools.nl/") + User.create(name: 'juan', title: "Mr.", homepage: "http://www.peritor.com/") + User.find_all_by_title_and_homepage!("Mr.", "http://www.companytools.nl/").map(&:name).should match_array %w[doe john] + end + + it "return raise a not found exception when the finder ends with an exclamation mark! and no records are found" do + expect { User.find_all_by_title_and_homepage!("Mr.", "http://www.companytools.nl/") }.to raise_error SimplyBased::RecordNotFound + end + it "find all records through an association" do + user1 = User.create(title: "Mr.") + user2 = User.create(title: "Mrs.") + post1 = Post.create user: user1 + post2 = Post.create user: user1 + post3 = Post.create user: user2 + Post.find_all_by_user(user1).should match_array [post1, post2] + end + + it "find all records through an association that raises when not found" do + user1 = User.create(title: "Mr.") + user2 = User.create(title: "Mrs.") + post1 = Post.create user: user1 + post2 = Post.create user: user1 + post3 = Post.create user: user2 + Post.find_all_by_user!(user1).should match_array [post1, post2] + end + end + end + +end diff --git a/spec/fixtures/couch.rb b/spec/fixtures/couch.rb new file mode 100644 index 0000000..d219100 --- /dev/null +++ b/spec/fixtures/couch.rb @@ -0,0 +1,354 @@ +class User + include SimplyBased::Model + validates_presence_of :title + + property :name + property :title + property :homepage + + has_many :posts + has_many :strict_posts + has_many :hemorrhoids + has_many :pains, through: :hemorrhoids + has_many :docs, class_name: "Document", foreign_key: "editor_id" + + view :by_name_and_created_at, key: [:name, :created_at] +end + +class Post + include SimplyBased::Model + + belongs_to :user + has_many_embedded :embedded_comments + view :by_user_id, key: :user_id +end + +class EmbeddedComment + include SimplyBased::Model + property :body + is_embedded_in :post + belongs_to :strict_post +end + +class Page + include SimplyBased::Model + + property :categories, type: Array + + validates_containment_of :categories, in: %w[one two three] +end + +class Directory + include SimplyBased::Model + + property :name + property :make_invalid + + has_ancestry + + validate :check_if_valid + + private + + def check_if_valid + errors.add(:make_invalid, :invalid) if make_invalid.present? + end +end + +class NamespacedDirectory + include SimplyBased::Model + + property :name + property :locale + + has_ancestry by_property: :locale +end + +class StrictPost + include SimplyBased::Model + + belongs_to :user + + validates_presence_of :user + has_many :embedded_comments +end + +class Comment + include SimplyBased::Model + + total_pages_method 'total_pages_modified' + current_page_method 'current_page_modified' + num_pages_method 'num_pages_modified' + per_page_method 'per_page_modified' + + belongs_to :user + belongs_to :network +end + +class Category + include SimplyBased::Model + + property :name + property :alias + property :parent + + validates_inclusion_of :name, in: ["food", "drinks", "party"], allow_blank: true +end + +class Document + include SimplyBased::Model + + belongs_to :author, class_name: "User" + belongs_to :editor, class_name: "User" +end + +class Tag + include SimplyBased::Model + + belongs_to :category + property :name +end + +class Instance + include SimplyBased::Model + has_one :identity +end + +class Identity + include SimplyBased::Model + belongs_to :instance + belongs_to :magazine +end + +class Magazine + include SimplyBased::Model + has_one :identity, dependent: :destroy +end + + + +class UniqueUser + include SimplyBased::Model + + property :name + validates_uniqueness_of :name +end + +class UniqueUserWithAView + include SimplyBased::Model + + view :by_name, key: :email + property :name + validates_uniqueness_of :name +end + +class CountMe + include SimplyBased::Model + + property :title +end + +class DontCountMe + include SimplyBased::Model + + property :title +end + +class Journal + include SimplyBased::Model + + has_many :memberships, dependent: :destroy + has_many :readers, through: :memberships, dependent: :destroy + property :foo +end + +class Reader + include SimplyBased::Model + + has_many :memberships, dependent: :destroy + has_many :journals, through: :memberships +end + +class Membership + include SimplyBased::Model + + belongs_to :reader + belongs_to :journal +end + +class Callbacker + include SimplyBased::Model + property :name + property :raise_on_save, type: :boolean, default: false + property :with_validation_error, type: :boolean, default: false + property :counter, type: Integer, default: 0 + + before_save ->{ self.counter += 1 } + + after_save :raise_error_after_save + before_validation do + errors.add(:base, :oops) if with_validation_error + end + + private + + def raise_error_after_save + raise "save is called and expected to to raise" if raise_on_save + end + +end + +class Hemorrhoid + include SimplyBased::Model + + enable_soft_delete + + view :by_nickname_and_size, key: [:nickname, :size] + + property :nickname + property :size + belongs_to :user + belongs_to :pain + belongs_to :spot + has_many :sub_hemorrhoids, dependent: :destroy + has_many :easy_sub_hemorrhoids, dependent: :destroy + has_many :rashs, dependent: :nullify + has_many :small_rashs, dependent: :nullify + + before_destroy :before_destroy_callback + after_destroy :after_destroy_callback + + def before_destroy_callback + end + + def after_destroy_callback + end +end + +class SubHemorrhoid + include SimplyBased::Model + + enable_soft_delete + + belongs_to :hemorrhoid +end + +class EasySubHemorrhoid + include SimplyBased::Model + + belongs_to :hemorrhoid +end + +class Rash + include SimplyBased::Model + + belongs_to :hemorrhoid +end + +class SmallRash + include SimplyBased::Model + + enable_soft_delete + + belongs_to :hemorrhoid +end + +class Pain + include SimplyBased::Model + + has_many :hemorrhoids + has_many :users, through: :hemorrhoids +end + +class Spot + include SimplyBased::Model + + has_one :hemorrhoid +end + +class Master + include SimplyBased::Model + + has_many :servants, dependent: :ignore +end + +class Servant + include SimplyBased::Model + + belongs_to :master +end + +class Issue + include SimplyBased::Model + + belongs_to :problem + belongs_to :big_problem + + property :name +end + +class Problem + include SimplyBased::Model + + has_many :issues + has_one :issue +end + +class BigProblem < Problem + +end + +class Server + include SimplyBased::Model + + property :hostname + + has_and_belongs_to_many :networks, storing_keys: true + has_and_belongs_to_many :subnets, storing_keys: true + has_and_belongs_to_many :ips, storing_keys: false +end + +class Network + include SimplyBased::Model + + property :klass + + has_and_belongs_to_many :servers, storing_keys: false + has_and_belongs_to_many :routers, storing_keys: false +end + +class Subnet < Network + has_and_belongs_to_many :servers, storing_keys: false +end + +class Ip + include SimplyBased::Model + + has_and_belongs_to_many :servers, storing_keys: true +end + +class Router + include SimplyBased::Model + enable_soft_delete + + property :hostname + + has_and_belongs_to_many :networks, storing_keys: true +end + +class Book + include SimplyBased::Model + + property :title + + has_and_belongs_to_many :authors, storing_keys: true +end + +class Author + include SimplyBased::Model + enable_soft_delete + + property :name + + has_and_belongs_to_many :books, storing_keys: false +end diff --git a/spec/has_and_belongs_to_many_spec.rb b/spec/has_and_belongs_to_many_spec.rb new file mode 100644 index 0000000..8a4d924 --- /dev/null +++ b/spec/has_and_belongs_to_many_spec.rb @@ -0,0 +1,639 @@ +require 'spec_helper' + +describe "has_and_belongs_to_many" do + + context "with has_and_belongs_to_many" do + it "create a fetch method for the associated objects" do + server = Server.new + server.should respond_to :networks + + network = Network.new + network.should respond_to :servers + end + + it "raise an error if another property with the same name already exists" do + expect { + class ::DoubleHasAdnBelongsToManyServer + include SimplyBased::Model + property :other_users + has_and_belongs_to_many :other_users + end + }.to raise_error RuntimeError + end + + it "fetch the associated objects" do + network = Network.create(klass: "A") + 3.times { + server = Server.new + server.network_ids = [network.id] + server.save! + } + network.servers.size.should eq 3 + end + + it "not add empty relations" do + # Checkbox assignment often gives an empty one to + # set array to [] when none is selected + server = Server.new + network = Network.create(klass: "Q") + server.network_ids = ["", nil, network.id] + server.network_ids.size.should eq 1 + server.save! + server.networks.size.should eq 1 + end + + it "fetch the associated objects from the other side of the relation" do + network = Network.create(klass: "A") + 3.times { + server = Server.new + server.network_ids = [network.id] + server.save! + } + Server.first.networks.size.should eq 1 + end + + it "set the parent object on the clients cache" do + expect( Network ).not_to receive(:find) + network = Network.create(klass: "A") + 3.times { + server = Server.new + server.add_network(network) + } + network.servers.first.networks.first.should eq network + end + + it "set the parent object on the clients cache from the other side of the relation" do + expect( Server ).not_to receive(:find) + network = Network.create(klass: "A") + 3.times { + server = Server.new + network.add_server(server) + } + network.servers.first.networks.first.should eq network + end + + it "work relations from both sides" do + network_a = Network.create(klass: "A") + network_b = Network.create(klass: "B") + 3.times { + server = Server.new + server.add_network(network_a) + server.add_network(network_b) + } + network_a.servers.size.should eq 3 + network_a.servers.each do |server| + server.networks.size.should eq 2 + end + network_b.servers.size.should eq 3 + network_b.servers.each do |server| + server.networks.size.should eq 2 + end + end + + it "work relations from both sides - regardless from where the add was called" do + network_a = Network.create(klass: "A") + network_b = Network.create(klass: "B") + 3.times { + server = Server.new + network_a.add_server(server) + network_b.add_server(server) + } + network_a.servers.size.should eq 3 + network_a.servers.each do |server| + server.networks.size.should eq 2 + end + network_b.servers.size.should eq 3 + network_b.servers.each do |server| + server.networks.size.should eq 2 + end + end + + context "limit" do + + it "be able to limit the result set" do + network = Network.create(klass: "A") + 3.times { + server = Server.new + server.add_network(network) + } + network.servers(limit: 2).size.should eq 2 + end + + it "use the given options in the cache-key" do + network = Network.create(klass: "A") + 3.times { + server = Server.new + server.add_network(network) + } + network.servers(limit: 2).size.should eq 2 + network.servers(limit: 3).size.should eq 3 + end + + it "be able to limit the result set - for both directions" do + network_a = Network.create(klass: "A") + network_b = Network.create(klass: "B") + 3.times { + server = Server.new + server.add_network(network_a) + server.add_network(network_b) + } + network_a.servers(limit: 2).size.should eq 2 + network_a.servers(limit: 3).size.should eq 3 + + network_a.servers.first.networks(limit: 2).size.should eq 2 + network_a.servers.first.networks(limit: 1).size.should eq 1 + end + end + + context "order" do + before do + @network = Network.create(klass: "A") + @network.created_at = Time.local(2000) + @network.save! + @network_b = Network.create(klass: "B") + @network_b.created_at = Time.local(2002) + @network_b.save! + 3.times do |i| + server = Server.new + server.add_network(@network) + server.add_network(@network_b) + server.created_at = Time.local(2000 + i) + server.save! + end + end + + it "support different order" do + expect{ @network.servers(order: :asc) }.not_to raise_error + expect{ @network.servers(order: :desc) }.not_to raise_error + end + + it "reverse the order if :desc" do + @network.servers(order: :desc).map(&:id).should eq @network.servers(order: :asc).map(&:id).reverse + server = @network.servers.first + server.networks(order: :desc).map(&:id).should eq server.networks(order: :asc).map(&:id).reverse + end + + it "work with the limit option" do + server = Server.new + server.add_network(@network) + server.add_network(@network_b) + @network.servers(order: :asc, limit: 3).map(&:id).reverse.should_not eq @network.servers(order: :desc, limit: 3).map(&:id) + server.networks(order: :asc, limit: 1).map(&:id).reverse.should_not eq server.networks(order: :desc, limit: 1).map(&:id) + end + end + + it "verify the given options for the accessor method" do + network = Network.create(klass: "A") + expect{ network.servers(foo: :bar) }.to raise_error ArgumentError + end + + it "verify the given options for the association defintion" do + expect { + Network.instance_eval do + has_and_belongs_to_many :foo, bar: :do + end + }.to raise_error ArgumentError + end + + it "only fetch objects of the correct type" do + network = Network.create(klass: "A") + server = Server.new + server.network_ids = [network.id] + server.save! + + comment = Comment.new + comment.network = network + comment.save! + + network.servers.size.should eq 1 + end + + it "getter should user cache" do + network = Network.create(klass: "A") + server = Server.new + server.network_ids = [network.id] + server.save! + network.servers + network.instance_variable_get("@servers")[:all].should eq [server] + end + + it "add methods to handle associated objects" do + network = Network.create(klass: "A") + network.should respond_to :add_server + network.should respond_to :remove_server + network.should respond_to :remove_all_servers + end + + it "add methods to handle associated objects - for the other side too" do + server = Server.create + server.should respond_to :add_network + server.should respond_to :remove_network + server.should respond_to :remove_all_networks + end + + it 'ignore the cache when requesting explicit reload' do + network = Network.create(klass: "A") + network.servers.should eq [] + server = Server.new + server.network_ids = [network.id] + server.save! + network.servers(force_reload: true).should eq [server] + end + + it "use the correct view when handling inheritance" do + network = Network.create + subnet = Subnet.create + server = Server.new + server.network_ids = [network.id] + server.save! + network.servers.size.should eq 1 + server.update_attributes(network_ids: nil, subnet_ids: [subnet.id]) + subnet.servers.size.should eq 1 + end + + context "when adding items" do + it "add the item to the internal cache" do + network = Network.new(klass: "C") + server = Server.new + network.servers.should eq [] + network.add_server(server) + network.servers.should eq [server] + network.instance_variable_get("@servers")[:all].should eq [server] + end + + it "raise an error when the added item is not an object of the expected class" do + network = Network.new + expect{ network.add_server('foo') }.to raise_error ArgumentError, "expected Server got String" + end + + it "save the added item" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + server.should_not be_new_record + end + + it "save the added item - from both directions" do + server = Server.new + network = Network.create(klass: "A") + server.add_network(network) + server.should_not be_new_record + end + + it 'set the forein key on the added object' do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + server.network_ids.should eq [network.id] + end + + it 'set the forein key on the added object - from both directions' do + server = Server.new + network = Network.create(klass: "A") + server.add_network(network) + server.network_ids.should eq [network.id] + end + + it "adding multiple times doesn't hurt" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + network.add_server(server) + server.add_network(network) + server.network_ids.should eq [network.id] + Server.find(server.id).network_ids.should eq [network.id] + end + end + + context "when removing items" do + it "should unset the foreign key" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + network.remove_server(server) + server.network_ids.should eq [] + end + + it "should unset the foreign key - from both directions" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + server.remove_network(network) + server.network_ids.should eq [] + end + + it "remove the item from the cache" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + network.servers.should include server + network.remove_server(server) + network.servers.any?{|s| server.id == s.id}.should_not be true + network.instance_variable_get("@servers")[:all].should eq [] + end + + it "remove the item from the cache - from both directions" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + server.networks.should include(network) + server.remove_network(network) + server.networks.any?{|n| network.id == n.id}.should_not be true + server.instance_variable_get("@networks")[:all].should eq [] + end + + it "save the removed item with the nullified foreign key" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + network.remove_server(server) + server = Server.find(server.id) + server.network_ids.should eq [] + end + + it "save the removed item with the nullified foreign key - from both directions" do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + server.remove_network(network) + server = Server.find(server.id) + server.network_ids.should eq [] + end + + it 'raise an error when another object is the owner of the object to be removed' do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + other_server = Server.create + expect{ network.remove_server(other_server) }.to raise_error ArgumentError + end + + it 'raise an error when another object is the owner of the object to be removed - from both directions' do + server = Server.new + network = Network.create(klass: "A") + network.add_server(server) + + other_network = Network.create + expect{ server.remove_network(other_network) }.to raise_error ArgumentError + end + + it 'raise an error when the object is the wrong type' do + expect{ Network.new.remove_server('foo') }.to raise_error ArgumentError, "expected Server got String" + expect{ Server.new.remove_network('foo') }.to raise_error ArgumentError, "expected Network got String" + end + end + + context "when removing all items" do + it 'nullify the foreign keys on all referenced items' do + server_1 = Server.new + server_2 = Server.new + network = Network.create(klass: "A") + network.add_server(server_1) + network.add_server(server_2) + network.remove_all_servers + server_1 = Server.find(server_1.id) + server_2 = Server.find(server_2.id) + server_1.network_ids.should eq [] + server_2.network_ids.should eq [] + end + + it 'nullify the foreign keys on all referenced items - from both directions' do + server_1 = Server.new + server_2 = Server.new + network = Network.create(klass: "A") + network.add_server(server_1) + network.add_server(server_2) + server_1.remove_all_networks + server_1 = Server.find(server_1.id) + server_2 = Server.find(server_2.id) + server_1.network_ids.should eq [] + server_2.network_ids.should eq [network.id] + end + + it 'empty the cache' do + server_1 = Server.new + server_2 = Server.new + network = Network.create(klass: "A") + network.add_server(server_1) + network.add_server(server_2) + network.remove_all_servers + network.servers.should eq [] + network.instance_variable_get("@servers")[:all].should eq [] + end + + it 'empty the cache - from both directions' do + server_1 = Server.new + server_2 = Server.new + network = Network.create(klass: "A") + network.add_server(server_1) + network.add_server(server_2) + server_1.remove_all_networks + server_1.networks.should eq [] + server_1.instance_variable_get("@networks")[:all].should eq [] + end + + context "when counting" do + before do + @network = Network.create(klass: "C") + @server = Server.create + end + + it "define a count method" do + @network.should respond_to :server_count + @server.should respond_to :network_count + end + + it "cache the result" do + @network.server_count.should eq 0 + Server.create(network_ids: [@network.id]) + @network.server_count.should eq 0 + @network.instance_variable_get("@server_count").should eq 0 + @network.instance_variable_set("@server_count", nil) + @network.server_count.should eq 1 + end + + it "cache the result - from both directions" do + @server.network_count.should eq 0 + @server.network_ids = [@network.id] + @server.save! + @server.network_count.should eq 0 + @server.instance_variable_get("@network_count").should eq 0 + @server.instance_variable_set("@network_count", nil) + @server.network_count.should eq 1 + end + + it "force reload even if cached" do + @network.server_count.should eq 0 + Server.create(network_ids: [@network.id]) + @network.server_count.should eq 0 + @network.server_count(force_reload: true).should eq 1 + end + + it "force reload even if cached - from both directions" do + @server.network_count.should eq 0 + @server.network_ids = [@network.id] + @server.save! + @server.network_count.should eq 0 + @server.network_count(force_reload: true).should eq 1 + end + + it "count the number of belongs_to objects" do + @network.server_count(force_reload: true).should eq 0 + Server.create(network_ids: [@network.id]) + @network.server_count(force_reload: true).should eq 1 + Server.create(network_ids: [@network.id]) + @network.server_count(force_reload: true).should eq 2 + Server.create(network_ids: [@network.id]) + @network.server_count(force_reload: true).should eq 3 + end + + it "count the number of belongs_to objects - from both directions" do + @server.network_count(force_reload: true).should eq 0 + @server.network_ids = [@network.id] + @server.save! + @server.network_count(force_reload: true).should eq 1 + @server.network_ids = [@network.id, Network.create.id] + @server.save! + @server.network_count(force_reload: true).should eq 2 + end + + it "not count non-releated objects" do + Server.all.each{|s| s.delete} + network_1 = Network.create(klass: "A") + network_2 = Network.create(klass: "B") + server_1 = Server.create + server_2 = Server.create(network_ids: [network_1.id]) + server_3 = Server.create(network_ids: [network_1.id, network_2.id]) + Server.count.should eq 3 + network_1.server_count.should eq 2 + network_2.server_count.should eq 1 + server_1.network_count.should eq 0 + server_2.network_count.should eq 1 + server_3.network_count.should eq 2 + end + + it "not count deleted objects" do + network = Network.create(klass: "A") + server = Server.create(network_ids: [network.id]) + network.server_count(force_reload: true).should eq 1 + server.delete + network.server_count(force_reload: true).should eq 0 + end + + it "not count deleted objects - from both directions" do + network = Network.create(klass: "A") + server = Server.create(network_ids: [network.id]) + server.network_count(force_reload: true).should eq 1 + network.delete + server.network_count(force_reload: true).should eq 0 + end + + end + end + + context "with deleted" do + it "not fetch deleted objects" do + # matrix gem removed in Ruby 3.4; skipping + network = Network.create(klass: "A") + server = Server.new + server.network_ids = [network.id] + server.save! + network.servers(force_reload: true).size.should eq 1 + expect{ server.delete }.to change{ Server.count }.by(-1).and change{ Network.count }.by(0) + network.servers(force_reload: true).size.should eq 0 + end + + it "not fetch deleted objects - from both directions" do + # matrix gem removed in Ruby 3.4; skipping + network = Network.create(klass: "A") + server = Server.new + server.network_ids = [network.id] + server.save! + server.networks(force_reload: true).size.should eq 1 + expect{ network.delete }.to change{ Network.count }.by(-1).and change{ Server.count }.by(0) + server.networks(force_reload: true).size.should eq 0 + server.reload.network_ids.should eq [] + end + end + + + context "with soft deleted" do + + it "not load soft deleted - items storing keys" do + network = Network.create + router = Router.new + network.add_router(router) + network.routers.size.should eq 1 + router.delete + Router.count.should eq 0 + Router.count(with_deleted: true).should eq 1 + network.routers(force_reload: true).size.should eq 0 + network.routers(force_reload: true, with_deleted: true).size.should eq 1 + router.delete! + network.routers(force_reload: true, with_deleted: true).size.should eq 0 + end + + it "not count soft deleted - items storing keys" do + network = Network.create + router = Router.new + network.add_router(router) + network.routers.size.should eq 1 + router.delete + Router.count.should eq 0 + Router.count(with_deleted: true).should eq 1 + network.router_count(force_reload: true).should eq 0 + network.router_count(force_reload: true, with_deleted: true).should eq 1 + router.delete! + network.router_count(force_reload: true, with_deleted: true).should eq 0 + end + + it "not load soft deleted - items not storing keys: not supported" do + book = Book.create + author = Author.create + author.add_book(book) + author.delete + Author.count.should eq 0 + Author.count(with_deleted: true).should eq 1 + book.authors(force_reload: true).size.should eq 0 + book.authors(force_reload: true, with_deleted: true).size.should eq 0 + end + + it "not count soft deleted - items not storing keys: not supported" do + book = Book.create + author = Author.create + author.add_book(book) + author.delete + Author.count.should eq 0 + Author.count(with_deleted: true).should eq 1 + book.author_count(force_reload: true).should eq 0 + book.author_count(force_reload: true, with_deleted: true).should eq 0 + end + + end + + context "when not persisted" do + it "not return objects when a record is not persisted on storing keys" do + router = Router.new + other_router = Router.create + network = Network.create + other_router.network_ids = [network.id] + other_router.save + router.networks.should be_empty + end + it "not return objects when a record is not persisted on non storing keys" do + router = Router.create + other_router = Router.create + other_network = Network.create + other_router.network_ids = [other_network.id] + network = Network.new + network.routers.should be_empty + end + end + + end +end diff --git a/spec/has_many_spec.rb b/spec/has_many_spec.rb new file mode 100644 index 0000000..d3aaba7 --- /dev/null +++ b/spec/has_many_spec.rb @@ -0,0 +1,476 @@ +require 'spec_helper' + +describe "HasMany" do + context "has_many" do + context "with has_many" do + it "create a fetch method for the associated objects" do + user = User.new + user.should respond_to :posts + end + + it "raise an error if another property with the same name already exists" do + expect { + class ::DoubleHasManyUser + include SimplyBased::Model + property :other_users + has_many :other_users + end + }.to raise_error RuntimeError + end + + it "fetch the associated objects" do + user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = user + post.save! + } + user.posts.size.should eq 3 + end + + it "set the parent object on the clients cache" do + expect( User ).not_to receive(:find) + user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = user + post.save! + } + post = user.posts.first + user.posts.first.user.should eq user + end + + context "limit" do + + it "be able to limit the result set" do + user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = user + post.save! + } + user.posts(:limit => 2).size.should eq 2 + end + + it "use the given options in the cache-key" do + user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = user + post.save! + } + user.posts(:limit => 2).size.should eq 2 + user.posts(:limit => 3).size.should eq 3 + end + + it "be able to limit the result set - also for through objects" do + @user = User.create(:title => "Mr.") + first_pain = Pain.create + frist_hemorrhoid = Hemorrhoid.create(:user => @user, :pain => first_pain) + @user.pains.should eq [first_pain] + second_pain = Pain.create + second_hemorrhoid = Hemorrhoid.create(:user => @user, :pain => second_pain) + @user.reload + @user.pains.size.should eq 2 + @user.pains(:limit => 1).size.should eq 1 + end + end + + context "order" do + before do + @user = User.create(:title => "Mr.") + 3.times { + post = Post.new + post.user = @user + post.save! + } + end + + it "support different order" do + expect{ @user.posts(:order => :asc) }.not_to raise_error + expect { @user.posts(:order => :desc) }.not_to raise_error + end + + it "reverse the order if :desc" do + @user.posts(:order => :desc).map(&:id).should eq @user.posts(:order => :asc).map(&:id).reverse + end + + it "work with the limit option" do + last_post = Post.create(:user => @user) + @user.posts(:order => :asc, :limit => 3).map(&:id).reverse.should_not eq @user.posts(:order => :desc, :limit => 3).map(&:id) + end + end + + it "verify the given options for the accessor method" do + user = User.create(:title => "Mr.") + expect { user.posts(:foo => false) }.to raise_error ArgumentError + end + + it "verify the given options for the association defintion" do + expect { + User.instance_eval do + has_many :foo, :bar => :do + end + }.to raise_error ArgumentError + end + + it "only fetch objects of the correct type" do + user = User.create(:title => "Mr.") + post = Post.new + post.user = user + post.save! + + comment = Comment.new + comment.user = user + comment.save! + + user.posts.size.should eq 1 + end + + it "getter should user cache" do + user = User.create(:title => "Mr.") + post = Post.new + post.user = user + post.save! + user.posts + user.instance_variable_get("@posts")[:all].should eq [post] + end + + it "add methods to handle associated objects" do + user = User.new(:title => "Mr.") + user.should respond_to :add_post + user.should respond_to :remove_post + user.should respond_to :remove_all_posts + end + + it 'ignore the cache when requesting explicit reload' do + user = User.create(:title => "Mr.") + user.posts.should eq [] + post = Post.new + post.user = user + post.save! + user.posts(:force_reload => true).should eq [post] + end + + it "use the correct view when handling inheritance" do + problem = Problem.create + big_problem = BigProblem.create + issue = Issue.create(:name => 'Thing', :problem => problem) + problem.issues.size.should eq 1 + issue.update_attributes(:problem_id => nil, :big_problem_id => big_problem.id) + big_problem.issues.size.should eq 1 + end + + context "when adding items" do + it "add the item to the internal cache" do + daddy = User.new(:title => "Mr.") + item = Post.new + daddy.posts.should eq [] + daddy.add_post(item) + daddy.posts.should eq [item] + daddy.instance_variable_get("@posts")[:all].should eq [item] + end + + it "raise an error when the added item is not an object of the expected class" do + user = User.new + expect { user.add_post('foo') }.to raise_error(ArgumentError, "expected Post got String") + end + + it "save the added item" do + post = Post.new + user = User.create(:title => "Mr.") + user.add_post(post) + post.should_not be_a_new_record + end + + it 'set the forein key on the added object' do + post = Post.new + user = User.create(:title => "Mr.") + user.add_post(post) + post.user_id.should eq user.id + end + end + + context "when removing items" do + it "should unset the foreign key" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + + user.remove_post(post) + post.user_id.should be nil + end + + it "remove the item from the cache" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + user.posts.should include post + user.remove_post(post) + user.posts.any?{|p| post.id == p.id}.should_not be true + user.instance_variable_get("@posts")[:all].should eq [] + end + + it "save the removed item with the nullified foreign key" do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + + user.remove_post(post) + post = Post.find(post.id) + post.user_id.should be nil + end + + it 'raise an error when another object is the owner of the object to be removed' do + user = User.create(:title => "Mr.") + mrs = User.create(:title => "Mrs.") + post = Post.create(:user => user) + expect{ mrs.remove_post post }.to raise_error ArgumentError + end + + it 'raise an error when the object is the wrong type' do + user = User.new + expect{ user.remove_post 'foo' }.to raise_error(ArgumentError, 'expected Post got String') + end + + it "delete the object when dependent:destroy" do + Category.instance_eval do + has_many :tags, :dependent => :destroy + end + + category = Category.create(:name => "food") + tag = Tag.create(:name => "food", :category => category) + tag.should_not be_new + category.remove_tag(tag) + + Tag.find(:all).should eq [] + end + + it "not nullify or delete dependents if the options is set to :ignore when removing" do + master = Master.create + master_id = master.id + servant = Servant.create(:master => master) + master.remove_servant(servant) + servant.reload.master_id.should eq master_id + end + + it "not nullify or delete dependents if the options is set to :ignore when deleting" do + master = Master.create + master_id = master.id + servant = Servant.create(:master => master) + master.destroy + servant.reload.master_id.should eq master_id + end + + end + + context "when removing all items" do + it 'nullify the foreign keys on all referenced items' do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + post2 = Post.create(:user => user) + user.remove_all_posts + post = Post.find(post.id) + post2 = Post.find(post2.id) + post.user_id.should be nil + post2.user_id.should be nil + end + + it 'empty the cache' do + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + post2 = Post.create(:user => user) + user.remove_all_posts + user.posts.should eq [] + user.instance_variable_get("@posts")[:all].should eq [] + end + + context "when counting" do + before do + @user = User.create(:title => "Mr.") + end + + it "define a count method" do + @user.should respond_to :post_count + end + + it "cache the result" do + @user.post_count.should eq 0 + Post.create(:user => @user) + @user.post_count.should eq 0 + @user.instance_variable_get("@post_count").should eq 0 + @user.instance_variable_set("@post_count", nil) + @user.post_count.should eq 1 + end + + it "force reload even if cached" do + @user.post_count.should eq 0 + Post.create(:user => @user) + @user.post_count.should eq 0 + @user.post_count(:force_reload => true).should eq 1 + end + + it "count the number of belongs_to objects" do + @user.post_count(:force_reload => true).should eq 0 + Post.create(:user => @user) + @user.post_count(:force_reload => true).should eq 1 + Post.create(:user => @user) + @user.post_count(:force_reload => true).should eq 2 + end + + it "not count foreign objects" do + @user.post_count.should eq 0 + Post.create(:user => nil) + Post.create(:user => User.create(:title => 'Doc')) + @user.post_count.should eq 0 + Post.count.should eq 2 + end + + it "not count delete objects" do + hemorrhoid = Hemorrhoid.create(:user => @user) + @user.hemorrhoid_count.should eq 1 + hemorrhoid.delete + @user.hemorrhoid_count(:force_reload => true).should eq 0 + @user.hemorrhoid_count(:force_reload => true, :with_deleted => true).should eq 1 + end + + it "work with has_many :through" do + @user.pain_count.should eq 0 + first_pain = Pain.create + frist_hemorrhoid = Hemorrhoid.create(:user => @user, :pain => first_pain) + @user.pains.should eq [first_pain] + @user.pain_count(:force_reload => true).should eq 1 + + second_pain = Pain.create + second_hemorrhoid = Hemorrhoid.create(:user => @user, :pain => second_pain) + @user.pain_count(:force_reload => true).should eq 2 + end + + end + end + + context 'when destroying the parent objects' do + it "delete relations when dependent is destroy" do + Category.instance_eval do + has_many :tags, :dependent => :destroy + end + + category = Category.create(:name => "food") + tag = Tag.create(:name => "food", :category => category) + + Tag.find(:all).should eq [tag] + category.destroy + Tag.find(:all).should eq [] + end + + it "nullify relations when dependent is nullify" do + + user = User.create(:title => "Mr.") + post = Post.create(:user => user) + + user.destroy + post = Post.find(post.id) + post.user_id.should be nil + end + + it "nullify the foreign key even if validation forbids" do + user = User.create(:title => "Mr.") + post = StrictPost.create(:user => user) + + user.destroy + post = StrictPost.find(post.id) + post.user_id.should be nil + end + end + end + + context "with has_many :trough" do + before do + @journal_1 = Journal.create + @journal_2 = Journal.create + @reader_1 = Reader.create + @reader_2 = Reader.create + end + + it "raise an exception if there is no :through relation" do + + expect { + class FooHasManyThroughBar + include SimplyBased::Model + has_many :foos, :through => :bars + end + }.to raise_error ArgumentError + end + + it "define a getter" do + @journal_1.should respond_to :readers + @reader_1.should respond_to :journals + end + + it "load the objects through" do + membership = Membership.new + membership.journal = @journal_1 + membership.reader = @reader_1 + membership.save.should be true + + membership.journal.should eq @journal_1 + membership.reader.should eq @reader_1 + @journal_1.reload.memberships.should eq [membership] + @journal_1.reload.memberships.should eq [membership] + + @journal_1.readers.should eq [@reader_1] + @reader_1.journals.should eq [@journal_1] + + membership_2 = Membership.new + membership_2.journal = @journal_1 + membership_2.reader = @reader_2 + membership_2.save.should be true + + @journal_1.reload.readers.map(&:id).sort.should eq [@reader_1.id, @reader_2.id].sort + @reader_1.reload.journals.map(&:id).sort.should eq [@journal_1.id] + @reader_2.reload.journals.map(&:id).sort.should eq [@journal_1.id] + + membership_3 = Membership.new + membership_3.journal = @journal_2 + membership_3.reader = @reader_2 + membership_3.save.should be true + + @journal_1.reload.readers.map(&:id).sort.should eq [@reader_1.id, @reader_2.id].sort + @journal_2.reload.readers.map(&:id).sort.should eq [@reader_2.id].sort + @reader_1.reload.journals.map(&:id).sort.should eq [@journal_1.id] + @reader_2.reload.journals.map(&:id).sort.should eq [@journal_1.id, @journal_2.id].sort + + membership_3.destroy + + @journal_1.reload.readers.map(&:id).sort.should eq [@reader_1.id, @reader_2.id].sort + @journal_2.reload.readers.should eq [] + @reader_1.reload.journals.map(&:id).sort.should eq [@journal_1.id] + @reader_2.reload.journals.map(&:id).sort.should eq [@journal_1.id] + end + + it "verify the given options" do + expect{ @journal_1.readers(:foo => true) }.to raise_error ArgumentError + end + + it "not try to destroy/nullify through-objects on parent object delete" do + membership = Membership.new + membership.journal = @journal_1 + membership.reader = @reader_1 + membership.save.should be true + + @reader_1.reload + @journal_1.reload + + expect_any_instance_of( Journal ).not_to receive(:readers) + expect_any_instance_of( Reader ).not_to receive("journal=") + + @journal_1.delete + end + end + + it "caches the parent relation" do + existing_user = User.create title: "User" + Post.create user: existing_user + Post.create user: existing_user + user = User.find(existing_user.id) + expect{ user.posts.each.map(&:user) }.not_to exceed_query_limit 1 + end + end +end diff --git a/spec/has_one_spec.rb b/spec/has_one_spec.rb new file mode 100644 index 0000000..0c9eb74 --- /dev/null +++ b/spec/has_one_spec.rb @@ -0,0 +1,139 @@ +require 'spec_helper' + +describe 'has_one' do + it 'adds a getter method' do + expect(Instance.new).to respond_to(:identity) + end + + it 'raises an error if another property with the same name already exists' do + expect { + class ::DoubleHasOneUser + include SimplyBased::Model + property :user + has_one :user + end + }.to raise_error RuntimeError + end + + it 'fetches the object when invoking the getter' do + instance = Instance.create + identity = Identity.create(instance: instance) + expect(instance.identity).to eq identity + end + + it 'sets the parent object on the clients cache' do + expect(Instance).not_to receive(:find) + instance = Instance.create + identity = Identity.create(instance: instance) + expect(instance.identity.instance).to eq instance + end + + it 'uses the correct view when handling inheritance' do + problem = Problem.create + big_problem = BigProblem.create + issue = Issue.create(name: 'Thing', problem: problem) + expect(problem.issue).to eq issue + issue.update_attributes(problem_id: nil, big_problem_id: big_problem.id) + expect(big_problem.issue).to eq issue + end + + it 'verifies the given options for the accessor method' do + instance = Instance.create + expect { instance.identity(foo: :var) }.to raise_error ArgumentError + end + + it 'verifies the given options for the association definition' do + expect { + User.instance_eval do + has_one :foo, bar: :do + end + }.to raise_error ArgumentError + end + + it 'stores the fetched object into the cache' do + instance = Instance.create + identity = Identity.create(instance: instance) + instance.identity + expect(instance.instance_variable_get('@identity')).to eq identity + end + + it 'does not fetch from the database when object is in cache' do + instance = Instance.create + identity = Identity.create(instance: instance) + instance.identity + expect(SimplyBased.database).not_to receive(:view) + instance.identity + end + + it 'updates the foreign object to have the owners id in the foreign key' do + instance = Instance.create + identity = Identity.create + instance.identity = identity + identity.reload + expect(identity.instance_id).to eq instance.id + end + + it 'updates the cache when setting' do + instance = Instance.create + identity = Identity.create + instance.identity = identity + expect(SimplyBased).not_to receive(:database) + expect(instance.identity).to eq identity + end + + it 'sets the foreign key value to nil when assigning nil' do + instance = Instance.create + identity = Identity.create(instance: instance) + instance.identity = nil + identity = Identity.find(identity.id) + expect(identity.instance_id).to be_nil + end + + it 'checks the class' do + instance = Instance.create + expect { instance.identity = 'foo' }.to raise_error ArgumentError, /expected Identity got String/ + end + + it 'deletes the dependent objects when dependent is set to destroy' do + identity = Identity.create + mag = Magazine.create + mag.identity = identity + mag.identity = nil + expect(Identity.find_by_id(identity.id)).to be_nil + end + + it 'unsets the id on the foreign object when a new object is set' do + instance = Instance.create + identity = Identity.create(instance: instance) + identity2 = Identity.create + instance.identity = identity2 + identity = Identity.find(identity.id) + expect(identity.instance_id).to be_nil + end + + it 'deletes the foreign object when a new object is set and dependent is set to destroy' do + identity = Identity.create + identity2 = Identity.create + mag = Magazine.create + mag.identity = identity + mag.identity = identity2 + expect(Identity.find_by_id(identity.id)).to be_nil + end + + it 'deletes the foreign object when parent is destroyed and dependent is set to destroy' do + identity = Identity.create + mag = Magazine.create + mag.identity = identity + mag.destroy + expect(Identity.find_by_id(identity.id)).to be_nil + end + + it 'nullifies the foreign objects foreign key when parent is destroyed' do + identity = Identity.create + instance = Instance.create + instance.identity = identity + instance.destroy + identity = Identity.find(identity.id) + expect(identity.instance_id).to be_nil + end +end diff --git a/spec/include_relations_spec.rb b/spec/include_relations_spec.rb new file mode 100644 index 0000000..c5a90a1 --- /dev/null +++ b/spec/include_relations_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +describe "Include relation, aggregate many queries to one simplified one for performance" do + context "initialized posts" do + before do + @user = User.create :title => 'Include relation posts user' + @post1 = Post.create(:user => @user) + @post2 = Post.create(:user => @user) + @user.reload + @post1 = Post.find(@post1.id) + @post2 = Post.find(@post2.id) + end + + it "have proper relations without include_relation" do + @user.posts.should match_array [@post1, @post2] + end + + it "have proper relations with include_relation" do + [@user].include_relation(:posts) + #assert_equal [@post1, @post2].sort_by(&:id), @user.posts.sort_by(&:id) + @user.posts.should match_array [@post1, @post2] + end + + it "have proper relations with include_relation called twice" do + [@user].include_relation(:posts) + [@user].include_relation(:posts) + @user.posts.should match_array [@post1, @post2] + end + end + + context '...' do + it "includes has_many relations and sets the reverse relation on has many" do + user1 = User.create title: "User1" + user2 = User.create title: "User2" + Post.create user: user1 + Post.create user: user1 + Post.create user: user2 + Post.create user: user2 + users = nil + expect{ users = User.all.include_relation(:posts) }.not_to exceed_query_limit 2 # users.all and adding posts to both users + expect{ users.map(&:posts).flatten.map(&:user) }.not_to perform_any_queries # reverse relation user must be set on each post + end + end +end diff --git a/spec/instance_lifecycle_spec.rb b/spec/instance_lifecycle_spec.rb new file mode 100644 index 0000000..40d4a32 --- /dev/null +++ b/spec/instance_lifecycle_spec.rb @@ -0,0 +1,226 @@ +require 'spec_helper' + +describe 'Instance lifecycle' do + context 'design documents' do + let(:db_url) { SimplyBased.database_url } + + it 'deletes all' do + expect(SimplyBased::Model.delete_all_design_documents(db_url)).to eq 0 + user = User.create + Post.create(user: user) + user.posts + expect(SimplyBased::Model.delete_all_design_documents(db_url)).to eq 1 + end + + it 'compacts all' do + expect(SimplyBased::Model.compact_all_design_documents(db_url)).to eq 0 + user = User.create + Post.create(user: user) + user.posts + expect(SimplyBased::Model.compact_all_design_documents(db_url)).to eq 1 + end + end + + context 'when creating instances' do + it 'populates the attributes' do + user = User.create(title: 'Mr.', name: 'Host Master') + expect(user.title).to eq 'Mr.' + expect(user.name).to eq 'Host Master' + end + + it 'saves the instance' do + user = User.create(title: 'Mr.') + expect(user).not_to be_new_record + end + + context 'with a bang' do + it 'does not raise an exception when saving succeeded' do + expect { User.create!(title: 'Mr.') }.not_to raise_error + end + + it 'saves the user' do + user = User.create!(title: 'Mr.') + expect(user).not_to be_new_record + end + + it 'raises an error when the validations failed' do + expect { User.create!(title: nil) }.to raise_error(RuntimeError, /Validations failed/) + end + end + + context 'with a block' do + it 'calls the block with the record' do + user = User.create do |u| + u.title = 'Mr.' + end + expect(user.title).to eq 'Mr.' + end + + it 'saves the record' do + user = User.create do |u| + u.title = 'Mr.' + end + expect(user).not_to be_new_record + end + + it 'assigns attributes via the hash' do + user = User.create(title: 'Mr.') do |u| + u.name = 'Host Master' + end + expect(user.title).to eq 'Mr.' + expect(user.name).to eq 'Host Master' + end + end + end + + context 'when saving an instance' do + it 'saves the instance' do + user = User.new(title: 'Mr.') + expect(user).to be_new_record + user.save + expect(user).not_to be_new_record + end + + context 'when using save!' do + it 'raises an exception when a validation is not fulfilled' do + user = User.new + expect { user.save! }.to raise_error(RuntimeError, /Validations failed/) + end + end + + context 'when using save(false)' do + it 'does not run the validations' do + user = User.new + user.save(false) + expect(user).not_to be_new + expect(user).not_to be_dirty + end + end + end + + context 'when destroying an instance' do + it 'removes the instance' do + user = User.create(title: 'Mr') + expect { user.destroy }.to change { User.find(:all).size }.by(-1) + end + + it 'returns the frozen instance' do + user = User.create(title: 'Mr') + expect(user.destroy).to eq user + end + end + + context 'when updating attributes' do + it 'merges in the updated attributes' do + user = User.create(title: 'Mr.') + user.update_attributes(title: 'Mrs.') + expect(user.title).to eq 'Mrs.' + end + + it 'saves the instance' do + user = User.create(title: 'Mr.') + user.update_attributes(title: 'Mrs.') + expect(user).not_to be_dirty + end + end + + context 'when counting' do + context 'when counting all' do + it 'returns the number of objects in the database' do + CountMe.create(title: 'Mr.') + CountMe.create(title: 'Mrs.') + expect(CountMe.find(:all).size).to eq 2 + expect(CountMe.count).to eq 2 + end + + it 'only counts the correct class' do + CountMe.create(title: 'Mr.') + DontCountMe.create(title: 'Foo') + expect(CountMe.find(:all).size).to eq 1 + expect(CountMe.count).to eq 1 + end + end + + context 'when counting by prefix' do + it 'returns the number of matching objects' do + CountMe.create(title: 'Mr.') + CountMe.create(title: 'Mrs.') + expect(CountMe.find_all_by_title('Mr.').size).to eq 1 + expect(CountMe.count_by_title('Mr.')).to eq 1 + end + + it 'only counts the correct class' do + CountMe.create(title: 'Mr.') + DontCountMe.create(title: 'Mr.') + expect(CountMe.find_all_by_title('Mr.').size).to eq 1 + expect(CountMe.count_by_title('Mr.')).to eq 1 + end + end + end + + context 'when reloading an instance' do + it 'reloads new attributes from the database' do + user = User.create(title: 'Mr.', name: 'Host Master') + user2 = User.find(user.id) + user2.update_attributes(title: 'Mrs.', name: 'Hostess Masteress') + user.reload + expect(user.title).to eq 'Mrs.' + expect(user.name).to eq 'Hostess Masteress' + end + + it 'removes attributes that are no longer in the database' do + user = User.create(title: 'Mr.', name: 'Host Master') + expect(user.name).not_to be_nil + same_user_in_different_thread = User.find(user.id) + same_user_in_different_thread.name = nil + same_user_in_different_thread.save! + expect(user.reload.name).to be_nil + end + + it 'also removes foreign key attributes that are no longer in the database' do + user = User.create(title: 'Mr.', name: 'Host Master') + post = Post.create(user: user) + expect(post.user_id).not_to be_nil + same_post_in_different_thread = Post.find(post.id) + same_post_in_different_thread.user = nil + same_post_in_different_thread.save! + expect(post.reload.user_id).to be_nil + end + + it 'is not dirty after reloading' do + user = User.create(title: 'Mr.', name: 'Host Master') + user2 = User.find(user.id) + user2.update_attributes(title: 'Mrs.', name: 'Hostess Masteress') + user.reload + expect(user).not_to be_dirty + end + + it 'ensures that association caches for has_many are cleared' do + user = User.create(title: 'Mr.', name: 'Host Master') + post = Post.create(user: user) + expect(user.posts.size).to eq 1 + expect(user.instance_variable_get('@posts')).not_to be_nil + user.reload + expect(user.instance_variable_get('@posts')).to be_nil + expect(user.posts.first).not_to be_nil + end + + it 'ensures that association caches for belongs_to are cleared' do + user = User.create(title: 'Mr.', name: 'Host Master') + post = Post.create(user: user) + post.user + expect(post.instance_variable_get('@user')).not_to be_nil + post.reload + expect(post.instance_variable_get('@user')).to be_nil + expect(post.user).not_to be_nil + end + + it 'updates the revision' do + user = User.create(title: 'Mr.', name: 'Host Master') + user2 = User.find(user.id) + user2.update_attributes(title: 'Mrs.', name: 'Hostess Masteress') + user.reload + expect(user._rev).to eq user2._rev + end + end +end diff --git a/spec/instance_methods_spec.rb b/spec/instance_methods_spec.rb new file mode 100644 index 0000000..24616ce --- /dev/null +++ b/spec/instance_methods_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' +describe "Instance methods" do + describe '#serializable_hash' do + it 'returns a proper hash'do + User.new(title: 'Include relation posts user').serializable_hash.should eq( + 'id' => nil, + "created_at" => nil, + "updated_at" => nil, + "name" => nil, + "title" => "Include relation posts user", + "homepage" => nil + ) + end + end +end diff --git a/spec/local_attachment_spec.rb b/spec/local_attachment_spec.rb new file mode 100644 index 0000000..2720889 --- /dev/null +++ b/spec/local_attachment_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe 'has_local_attached' do + before do + class DefaultStyleTest + include SimplyBased::Model + property :title + has_local_attached :file, default_style: :not_original + end + end + + it 'uses default_style when no style given for url' do + obj = DefaultStyleTest.new + obj.file_file_name = 'test.jpg' + + expect(obj.file.url).to eq '/system/file/tmp/not_original.jpg' + end + + it 'uses default_style when no style given for path' do + obj = DefaultStyleTest.new + obj.file_file_name = 'test.jpg' + + expect(obj.file.path).to end_with '/system/file/tmp/not_original.jpg' + end + + it 'allows explicit style override' do + obj = DefaultStyleTest.new + obj.file_file_name = 'test.jpg' + + expect(obj.file.url(:thumb)).to eq '/system/file/tmp/thumb.jpg' + end + + it 'uses default_style from attachment_registry' do + config = DefaultStyleTest.attachment_registry[:file] + expect(config[:default_style]).to eq :not_original + end +end diff --git a/spec/mass_assignment_protection_spec.rb b/spec/mass_assignment_protection_spec.rb new file mode 100644 index 0000000..3d6747c --- /dev/null +++ b/spec/mass_assignment_protection_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe 'Mass assignment protection' do + context 'when using attr_protected' do + before do + Category.instance_eval do + @_accessible_attributes = [] + attr_protected :parent, :alias + end + end + + it 'does not allow to set with mass assignment using attributes=' do + item = Category.new + item.attributes = { parent: 'a', name: 'c' } + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + + it 'does not allow to set with mass assignment using attributes= - ignore string vs. symbol' do + item = Category.new + item.attributes = { 'parent' => 'a', 'name' => 'c' } + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + + it 'does not allow to set with mass assignment using the constructor' do + item = Category.new(parent: 'a', name: 'c') + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + + it 'does not allow to set with mass assignment using update_attributes' do + item = Category.new + item.update_attributes(parent: 'a', name: 'c') + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + end + + context 'attr_accessible' do + before do + Category.instance_eval do + @_protected_attributes = [] + attr_accessible :name + end + end + + it 'does not allow to set with mass assignment using attributes=' do + item = Category.new + item.attributes = { parent: 'a', name: 'c' } + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + + it 'does not allow to set with mass assignment using the constructor' do + item = Category.new(parent: 'a', name: 'c') + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + + it 'does not allow to set with mass assignment using update_attributes' do + item = Category.new + item.update_attributes(parent: 'a', name: 'c') + expect(item.name).to eq 'c' + expect(item.parent).to be_nil + end + end +end diff --git a/spec/pagination_spec.rb b/spec/pagination_spec.rb new file mode 100644 index 0000000..c0fc2b4 --- /dev/null +++ b/spec/pagination_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +RSpec.describe 'Pagination' do + it "does not raise error when page and per_page are specified" do + expect{ User.all(page: 1, per_page: 2) }.not_to raise_error + end + + it "respond to pagination methods" do + paginated = User.all(page: 1, per_page: 2 ) + paginated.current_page.should eq 1 + paginated.num_pages.should eq 1 + paginated.per_page.should eq 2 + end + + it "respond to modified pagination methods" do + paginated = Comment.all(page: 1, per_page: 2 ) + paginated.current_page_modified.should eq 1 + paginated.num_pages_modified.should eq 1 + paginated.per_page_modified.should eq 2 + end + + it "display individual objects on pages with per_page is one" do + u1 = User.create(title: 'user1', created_at: Time.now ) + u2 = User.create(title: 'user2', created_at: Time.now + 5.minutes ) + u3 = User.create(title: 'user3', created_at: Time.now + 10.minutes ) + User.all.should eq [u1, u2, u3] # normal behaviour + User.all(per_page: 1).first.should eq u1 # default to page 1 + User.all(page: 2, per_page: 1).first.should eq u2 + User.all(page: 3, per_page: 1).first.should eq u3 + end + + it "paginates all finder" do + 15.times{|i| User.create(title: "user#{i}", created_at: i.minutes.from_now) } + User.count.should eq 15 + result = User.all(page: 2, per_page: 2) + result.current_page.should eq 2 + result.per_page.should eq 2 + result.map(&:title).sort.should eq ['user2', 'user3'] + result.total_entries.should eq 15 + result.total_count.should eq 15 + result.num_pages.should eq 8 + end + + it "paginate find_all_by finders" do + 6.times{|i| User.create(title: "user#{i}", homepage: 'http://localhost/1') } + 9.times{|i| User.create(title: "user#{i + 6}", homepage: 'http://localhost/2') } + User.count.should eq 15 + User.find_all_by_homepage('http://localhost/1').size.should eq 6 + result = User.find_all_by_homepage('http://localhost/1', page: 2, per_page: 2) + result.current_page.should eq 2 + result.per_page.should eq 2 + result.map(&:title).sort.should eq ['user2', 'user3'] + result.total_entries.should eq 6 + result.total_count.should eq 6 + result.num_pages.should eq 3 + end + + it "paginate has and belongs to many view" do + server = Server.create + for klass in 'A'..'Z' + server.add_network Network.create( klass: klass) + end + res = Network.with_pagination_options(startkey: [server.id], endkey: ["#{server.id}\u9999"], page: 1, per_page: 100, reduce: false, include_docs: true) do |o| + Network.database.view(Network.association_network_has_and_belongs_to_many_servers(o)) + end + res.size.should eq 26 + end + it "paginate has and belongs to many view with non trivial options" do + server = Server.create + for klass in 'A'..'Z' + server.add_network Network.create( klass: klass) + end + res = Network.with_pagination_options(startkey: [server.id], endkey: ["#{server.id}\u9999"], page: 2, per_page: 3, reduce: false, include_docs: true) do |o| + Network.database.view(Network.association_network_has_and_belongs_to_many_servers(o)) + end + res.size.should eq 3 + end +end diff --git a/spec/redis_replacements_spec.rb b/spec/redis_replacements_spec.rb new file mode 100644 index 0000000..38f8714 --- /dev/null +++ b/spec/redis_replacements_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +# Couchbase-backed replacements for common Redis uses: atomic counters, a Rails +# cache store, and a distributed lock — all on the same cluster as the models. +RSpec.describe 'Redis replacements' do + describe SimplyBased::Counter do + subject(:counter) { described_class.new } + + it 'atomically increments, seeding at the initial value' do + expect(counter.increment('c:hits')).to eq(0) # created at initial 0 + expect(counter.increment('c:hits')).to eq(1) + expect(counter.increment('c:hits', 5)).to eq(6) + expect(counter.get('c:hits')).to eq(6) + end + + it 'decrements and resets' do + counter.increment('c:n', 0, initial: 10) + expect(counter.decrement('c:n', 3)).to eq(7) + expect(counter.reset('c:n', 0)).to eq(0) + expect(counter.get('c:n')).to eq(0) + end + end + + describe SimplyBased::Cache do + subject(:cache) { described_class.new(namespace: 'test') } + + it 'works as an ActiveSupport::Cache::Store (read/write/fetch/delete)' do + cache.write('k', { 'a' => 1 }) + expect(cache.read('k')).to eq({ 'a' => 1 }) + expect(cache.fetch('k2') { [1, 2, 3] }).to eq([1, 2, 3]) + expect(cache.read('k2')).to eq([1, 2, 3]) + cache.delete('k') + expect(cache.read('k')).to be_nil + end + end + + describe SimplyBased::Lock do + subject(:lock) { described_class.new } + + it 'grants the lock to one holder and blocks a second acquire' do + handle = lock.acquire('job:1', ttl: 30) + expect(handle).not_to be_nil + expect(lock.acquire('job:1', ttl: 30)).to be_nil # already held + lock.release('job:1', handle) + expect(lock.acquire('job:1', ttl: 30)).not_to be_nil # released → free + end + + it 'runs a critical section with with_lock' do + ran = false + lock.with_lock('job:2') { ran = true } + expect(ran).to be(true) + # lock auto-released after the block → re-acquirable + expect(lock.acquire('job:2')).not_to be_nil + end + end +end diff --git a/spec/retry_on_error_spec.rb b/spec/retry_on_error_spec.rb new file mode 100644 index 0000000..f2d6ace --- /dev/null +++ b/spec/retry_on_error_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe 'Retry on error' do + it 'retries the save on connection error' do + user = User.create(name: 'Mickey Mouse', title: 'Dr.', homepage: 'www.gmx.de') + error = Errno::ECONNREFUSED.new('Connection refused - connect(2)') + expect(SimplyBased.database).to receive(:save_document).once.and_raise(error).ordered + expect(SimplyBased.database).to receive(:save_document).once.and_return(true).ordered + + user.name = 'bert' + expect(user.save).to be true + end + + it 'retries the save! on connection error' do + error = Errno::ECONNREFUSED.new('Connection refused - connect(2)') + expect(SimplyBased.database).to receive(:save_document).once.and_raise(error).ordered + expect(SimplyBased.database).to receive(:save_document).once.and_return(true).ordered + + user = User.create(name: 'Mickey Mouse', title: 'Dr.', homepage: 'www.gmx.de') + expect(user).to be_truthy + end + + it 're-raises the error if retried several times' do + error = Errno::ECONNREFUSED.new('Connection refused - connect(2)') + expect(SimplyBased.database).to receive(:save_document).exactly(3).times.and_raise(error) + + expect { + User.create(name: 'Mickey Mouse', title: 'Dr.', homepage: 'www.gmx.de') + }.to raise_error(Errno::ECONNREFUSED) + end +end diff --git a/spec/s3_spec.rb b/spec/s3_spec.rb new file mode 100644 index 0000000..d5fbf84 --- /dev/null +++ b/spec/s3_spec.rb @@ -0,0 +1,262 @@ +require 'spec_helper' + +class CouchLogItem + include SimplyBased::Model + has_s3_attached :log_data, bucket: 'bucket-for-monsieur', access_key: 'abcdef', secret_access_key: 'secret!' +end + +describe 'S3 attachment' do + let(:client) { instance_double(Aws::S3::Client) } + + before do + CouchLogItem._s3_options[:log_data][:bucket] = 'bucket-for-monsieur' + CouchLogItem._s3_options[:log_data][:location] = :us + CouchLogItem._s3_options[:log_data][:permissions] = 'private' + CouchLogItem._s3_options[:log_data][:after_delete] = :nothing + CouchLogItem._s3_options[:log_data][:logger] = nil + + allow(Aws::S3::Client).to receive(:new).and_return(client) + allow(client).to receive(:put_object) + allow(client).to receive(:delete_object) + end + + let(:log_item) do + item = CouchLogItem.new + item.instance_variable_set(:@_s3_client, nil) + item.instance_variable_set(:@_s3_bucket, nil) + item + end + + context 'when saving the attachment' do + it 'creates an S3 client' do + log_item.log_data = 'Yay! It logged!' + expect(Aws::S3::Client).to receive(:new).with( + access_key_id: 'abcdef', + secret_access_key: 'secret!', + region: 'us-east-1', + logger: nil + ).and_return(client) + allow(client).to receive(:head_bucket) + log_item.save + end + + it 'uploads the file' do + log_item.log_data = 'Yay! It logged!' + allow(client).to receive(:head_bucket) + expect(client).to receive(:put_object).with( + bucket: 'bucket-for-monsieur', + key: anything, + body: 'Yay! It logged!', + acl: 'private' + ) + log_item.save + end + + it 'also uploads on save!' do + log_item.log_data = 'Yay! It logged!' + allow(client).to receive(:head_bucket) + expect(client).to receive(:put_object) + log_item.save! + end + + it 'uses the specified bucket' do + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + log_item.log_data = 'Yay! It logged!' + expect(client).to receive(:head_bucket).with(bucket: 'mybucket') + expect(client).to receive(:put_object).with(hash_including(bucket: 'mybucket')) + log_item.save + end + + it 'creates the bucket if it does not exist' do + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + log_item.log_data = 'Yay! log me' + expect(client).to receive(:head_bucket).with(bucket: 'mybucket') + .and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket')) + expect(client).to receive(:create_bucket).with( + bucket: 'mybucket', + create_bucket_configuration: nil + ) + allow(client).to receive(:put_object) + log_item.save + end + + it 'creates an EU bucket with location constraint' do + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + CouchLogItem._s3_options[:log_data][:location] = :eu + log_item.log_data = 'Yay! log me' + expect(client).to receive(:head_bucket).with(bucket: 'mybucket') + .and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket')) + expect(client).to receive(:create_bucket).with( + bucket: 'mybucket', + create_bucket_configuration: { location_constraint: 'eu-west-1' } + ) + allow(client).to receive(:put_object) + log_item.save + end + + it 'raises an error if the bucket is not ours' do + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + CouchLogItem._s3_options[:log_data][:location] = :eu + log_item.log_data = 'Yay! log me too' + expect(client).to receive(:head_bucket).with(bucket: 'mybucket') + .and_raise(Aws::S3::Errors::NoSuchBucket.new(nil, 'NoSuchBucket')) + expect(client).to receive(:create_bucket).with( + bucket: 'mybucket', + create_bucket_configuration: { location_constraint: 'eu-west-1' } + ).and_raise(Aws::S3::Errors::BucketAlreadyExists.new(nil, 'BucketAlreadyExists')) + + expect { log_item.save }.to raise_error(ArgumentError) + end + + it 'passes the logger to the S3 client' do + log_item.log_data = 'Yay! log me' + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + CouchLogItem._s3_options[:log_data][:logger] = Logger.new(nil) + + expect(Aws::S3::Client).to receive(:new).with( + access_key_id: 'abcdef', + secret_access_key: 'secret!', + region: 'us-east-1', + logger: kind_of(Logger) + ).and_return(client) + allow(client).to receive(:head_bucket) + log_item.save + CouchLogItem._s3_options[:log_data][:logger] = nil + end + + it 'does not upload when not changed' do + allow(client).to receive(:head_bucket) + expect(client).not_to receive(:put_object) + log_item.save + end + + it 'uses full class name and id as key' do + log_item.log_data = 'Yay!' + allow(client).to receive(:head_bucket) + expect(client).to receive(:put_object).with(hash_including(key: /couch_log_items\/log_data\//)) + log_item.save + end + + it 'marks attachment not dirty after upload' do + log_item.log_data = 'Yay!' + allow(client).to receive(:head_bucket) + log_item.save + expect(log_item.instance_variable_get(:@_s3_attachments)[:log_data][:dirty]).to be_falsy + end + + it 'stores attachment when validations succeed' do + log_item.log_data = 'Yay!' + allow(client).to receive(:head_bucket) + expect(client).to receive(:put_object) + log_item.save + end + + xit 'does not store attachment when validations fail' do + log_item.log_data = 'Yay!' + allow(client).to receive(:head_bucket) + allow(log_item).to receive(:valid?).and_return(false) + expect(client).not_to receive(:put_object) + log_item.save + end + + it 'serializes non-string data to json' do + log_item.log_data = ['one log entry', 'and another one'] + allow(client).to receive(:head_bucket) + expect(client).to receive(:put_object).with(hash_including(body: '["one log entry","and another one"]')) + log_item.save + end + + context 'attachment size' do + before do + allow(client).to receive(:head_bucket) + end + + it 'stores on upload' do + log_item.log_data = 'abc' + log_item.save + expect(log_item.log_data_size).to eq 3 + end + + it 'updates size on change' do + log_item.log_data = 'abc' + log_item.save + expect(log_item.log_data_size).to eq 3 + log_item.log_data = 'example' + log_item.save + expect(log_item.log_data_size).to eq 7 + end + + it 'stores size of json attachments' do + log_item.log_data = ['abc'] + log_item.save + expect(log_item.log_data_size).to eq ['abc'].to_json.size + end + end + end + + context 'when fetching the data' do + let(:resp) { double('response', body: double(read: 'Yay!')) } + + before do + allow(client).to receive(:get_object).and_return(resp) + end + + it 'creates a configured S3 connection' do + CouchLogItem._s3_options[:log_data][:bucket] = 'mybucket' + CouchLogItem._s3_options[:log_data][:location] = :eu + + expect(Aws::S3::Client).to receive(:new).with( + access_key_id: 'abcdef', + secret_access_key: 'secret!', + region: 'eu-west-1', + logger: nil + ).and_return(client) + + log_item.log_data + end + + it 'fetches data from s3' do + log_item.instance_variable_set(:@_s3_attachments, {}) + expect(client).to receive(:get_object).with( + bucket: 'bucket-for-monsieur', + key: anything + ).and_return(resp) + expect(log_item.log_data).to eq 'Yay!' + end + + it 'does not mark attachment as dirty' do + log_item.instance_variable_set(:@_s3_attachments, {}) + log_item.log_data + expect(log_item._s3_attachments[:log_data][:dirty]).to be_falsy + end + + it 'does not fetch if already set' do + log_item.log_data = 'Yay!' + expect(client).not_to receive(:get_object) + expect(log_item.log_data).to eq 'Yay!' + end + end + + context 'when deleting' do + before do + CouchLogItem._s3_options[:log_data][:after_delete] = :nothing + allow(client).to receive(:head_bucket) + log_item.log_data = 'Yatzzee' + log_item.save + end + + it 'does nothing to S3' do + expect(client).not_to receive(:delete_object) + log_item.delete + end + + it 'deletes on S3 if configured' do + CouchLogItem._s3_options[:log_data][:after_delete] = :delete + expect(client).to receive(:delete_object).with( + bucket: 'bucket-for-monsieur', + key: anything + ) + log_item.delete + end + end +end diff --git a/spec/soft_deletable_spec.rb b/spec/soft_deletable_spec.rb new file mode 100644 index 0000000..3247e8f --- /dev/null +++ b/spec/soft_deletable_spec.rb @@ -0,0 +1,453 @@ +require 'spec_helper' + +def recreate_db! + SimplyBased.database.drop_database! +end + +RSpec.describe 'SoftDeletable' do + context 'when using soft deletable' do + it 'knows when it is enabled' do + expect(Hemorrhoid.soft_deleting_enabled?).to be true + expect(User.soft_deleting_enabled?).to be false + end + + it 'defines a :deleted_at attribute' do + h = Hemorrhoid.new + expect(h).to respond_to(:deleted_at) + expect(h).to respond_to(:deleted_at=) + expect(Hemorrhoid.soft_delete_attribute).to eq :deleted_at + end + + it 'defines hard delete methods' do + h = Hemorrhoid.new + expect(h).to respond_to(:destroy!) + expect(h).to respond_to(:delete!) + end + + context 'when deleting' do + before do + @user = User.new(name: 'BigT', title: 'Dr.') + @user.save! + @hemorrhoid = Hemorrhoid.new + @hemorrhoid.user = @user + @hemorrhoid.save! + end + + it 'does not delete the object but populates the soft_delete_attribute' do + now = Time.now + allow(Time).to receive(:now).and_return(now) + expect(@hemorrhoid.deleted_at).to be_nil + expect(@hemorrhoid.delete).to be_truthy + expect(@hemorrhoid.deleted_at).to eq now + end + + it 'survives reloads with the new attribute' do + expect(@hemorrhoid.deleted_at).to be_nil + expect(@hemorrhoid.delete).to be_truthy + @hemorrhoid.reload + expect(@hemorrhoid.deleted_at).not_to be_nil + end + + it 'knows when it is deleted' do + expect(@hemorrhoid.deleted?).to be false + @hemorrhoid.delete + expect(@hemorrhoid.deleted?).to be true + end + + it 'does not consider objects without soft-deleted as deleted' do + expect(@user.deleted?).to be false + @user.delete + expect(@user.deleted?).to be false + end + + it 'does not delete in DB' do + expect(SimplyBased.database).not_to receive(:destroy_document) + @hemorrhoid.destroy + end + + it 'really deletes with callbacks' do + expect(SimplyBased.database).to receive(:destroy_document).with(@hemorrhoid, true) + @hemorrhoid.destroy! + end + + it 'really deletes without callbacks if the object was soft-deleted before' do + expect(SimplyBased.database).to receive(:destroy_document).with(@hemorrhoid, false) + @hemorrhoid.destroy + @hemorrhoid.destroy! + end + + context 'callbacks' do + it 'still fires the callbacks' do + @hemorrhoid = Hemorrhoid.create + $before = nil + $after = nil + def @hemorrhoid.before_destroy_callback + $before = 'now' + end + + def @hemorrhoid.after_destroy_callback + $after = 'now' + end + + @hemorrhoid.destroy + + expect($before).not_to be_nil + expect($after).not_to be_nil + end + + it 'does not fire the callbacks on the real destroy if the object is already deleted' do + @hemorrhoid = Hemorrhoid.create + def @hemorrhoid.before_destroy_callback + raise "Callback called even though #{skip_callbacks.inspect}" + end + + def @hemorrhoid.after_destroy_callback + raise "Callback called even though #{skip_callbacks.inspect}" + end + + def @hemorrhoid.deleted? + true + end + + expect { @hemorrhoid.destroy! }.not_to raise_error + end + + it 'fires the callbacks on the real destroy if the object is not deleted' do + @hemorrhoid = Hemorrhoid.create + $before = nil + $after = nil + def @hemorrhoid.before_destroy_callback + $before = 'now' + end + + def @hemorrhoid.after_destroy_callback + $after = 'now' + end + + @hemorrhoid.destroy! + + expect($before).not_to be_nil + expect($after).not_to be_nil + end + end + + context 'when handling the dependent objects' do + before do + @sub = SubHemorrhoid.new + @sub.hemorrhoid = @hemorrhoid + @sub.save! + + @easy_sub = EasySubHemorrhoid.new + @easy_sub.hemorrhoid = @hemorrhoid + @easy_sub.save! + + @rash = Rash.new + @rash.hemorrhoid = @hemorrhoid + @rash.save! + + @hemorrhoid.reload + end + + it 'deletes them' do + @hemorrhoid.delete + @sub.reload + expect(@sub.deleted?).to be true + expect { EasySubHemorrhoid.find(@easy_sub.id, with_deleted: true) }.to raise_error(SimplyBased::RecordNotFound) + @rash = Rash.find(@rash.id) + expect(@rash.hemorrhoid_id).to be_nil + end + + it 'really deletes them if the parent is really deleted' do + @hemorrhoid.delete! + expect { EasySubHemorrhoid.find(@sub.id, with_deleted: true) }.to raise_error(SimplyBased::RecordNotFound) + + expect { EasySubHemorrhoid.find(@easy_sub.id, with_deleted: true) }.to raise_error(SimplyBased::RecordNotFound) + + @rash = Rash.find(@rash.id) + expect(@rash.hemorrhoid_id).to be_nil + end + + it 'nullifies dependents if they are soft-deletable and deleted' do + small_rash = SmallRash.create(hemorrhoid: @hemorrhoid) + @hemorrhoid.reload + @hemorrhoid.destroy + expect(@hemorrhoid.deleted?).to be true + small_rash = SmallRash.find(small_rash.id) + expect(small_rash.hemorrhoid_id).to be_nil + end + + it 'does not nullify dependents if they are soft-deletable and not deleted' do + small_rash = SmallRash.create(hemorrhoid: @hemorrhoid) + @hemorrhoid.reload + expect(@hemorrhoid.deleted?).to be false + small_rash = SmallRash.find(small_rash.id) + expect(small_rash.hemorrhoid_id).not_to be_nil + expect(small_rash.hemorrhoid_id).to eq @hemorrhoid.id + end + end + end + + context 'when loading' do + before do + @user = User.new(name: 'BigT', title: 'Dr.') + @user.save! + @hemorrhoid = Hemorrhoid.new + @hemorrhoid.user = @user + @hemorrhoid.save! + end + + context 'by id' do + it 'is not found by default' do + @hemorrhoid.destroy + expect { Hemorrhoid.find(@hemorrhoid.id) }.to raise_error(SimplyBased::RecordNotFound) + end + + it 'is found if supplied with :with_deleted' do + @hemorrhoid.destroy + + expect(Hemorrhoid.find(@hemorrhoid.id, with_deleted: true)).not_to be_nil + end + + it 'is not found if it is really gone' do + old_id = @hemorrhoid.id + @hemorrhoid.destroy! + + expect { Hemorrhoid.find(old_id) }.to raise_error(SimplyBased::RecordNotFound) + end + + it 'always reloads' do + @hemorrhoid.destroy + expect { @hemorrhoid.reload }.not_to raise_error + expect(@hemorrhoid.deleted_at).not_to be_nil + end + end + + context 'all' do + before do + recreate_db! + @hemorrhoid = Hemorrhoid.create + expect(@hemorrhoid.destroy).to be_truthy + expect(@hemorrhoid.reload.deleted?).to be true + end + + it 'does not load deleted' do + expect(Hemorrhoid.find(:all)).to eq [] + expect(Hemorrhoid.find(:all, with_deleted: false)).to eq [] + end + + it 'loads non-deleted' do + hemorrhoid = Hemorrhoid.create + expect(Hemorrhoid.find(:all)).not_to eq [] + expect(Hemorrhoid.find(:all, with_deleted: false)).not_to eq [] + end + + it 'loads deleted if asked to' do + expect(Hemorrhoid.find(:all, with_deleted: true).map(&:id)).to eq [@hemorrhoid.id] + end + end + + context 'first' do + before do + recreate_db! + @hemorrhoid = Hemorrhoid.create + expect(@hemorrhoid.destroy).to be_truthy + expect(@hemorrhoid.reload.deleted?).to be true + end + + it 'does not load deleted' do + expect(Hemorrhoid.find(:first)).to be_nil + expect(Hemorrhoid.find(:first, with_deleted: false)).to be_nil + end + + it 'loads non-deleted' do + hemorrhoid = Hemorrhoid.create + expect(Hemorrhoid.find(:first)).not_to be_nil + expect(Hemorrhoid.find(:first)).to be_a(Hemorrhoid) + expect(Hemorrhoid.find(:first, with_deleted: false)).not_to be_nil + expect(Hemorrhoid.find(:first, with_deleted: false)).to be_a(Hemorrhoid) + end + + it 'loads deleted if asked to' do + expect(Hemorrhoid.find(:first, with_deleted: true)).to eq @hemorrhoid + end + end + + context 'find_by and find_all_by' do + before do + recreate_db! + @hemorrhoid = Hemorrhoid.create(nickname: 'Claas', size: 3) + @hemorrhoid.destroy + end + + context 'find_by' do + it 'does not load deleted' do + expect(Hemorrhoid.find_by_nickname('Claas')).to be_nil + expect(Hemorrhoid.find_by_nickname('Claas', with_deleted: false)).to be_nil + + expect(Hemorrhoid.find_by_nickname_and_size('Claas', 3)).to be_nil + expect(Hemorrhoid.find_by_nickname_and_size('Claas', 3, with_deleted: false)).to be_nil + end + + it 'loads non-deleted' do + hemorrhoid = Hemorrhoid.create(nickname: 'OtherNick', size: 3) + expect(Hemorrhoid.find_by_nickname('OtherNick', with_deleted: true).id).to eq hemorrhoid.id + expect(Hemorrhoid.find_by_nickname('OtherNick').id).to eq hemorrhoid.id + end + + it 'loads deleted if asked to' do + expect(Hemorrhoid.find_by_nickname('Claas', with_deleted: true)).not_to be_nil + expect(Hemorrhoid.find_by_nickname('Claas', with_deleted: true).id).to eq @hemorrhoid.id + + expect(Hemorrhoid.find_by_nickname_and_size('Claas', 3, with_deleted: true)).not_to be_nil + expect(Hemorrhoid.find_by_nickname_and_size('Claas', 3, with_deleted: true).id).to eq @hemorrhoid.id + end + end + + context 'find_all_by' do + it 'does not load deleted' do + expect(Hemorrhoid.find_all_by_nickname('Claas')).to eq [] + expect(Hemorrhoid.find_all_by_nickname('Claas', with_deleted: false)).to eq [] + + expect(Hemorrhoid.find_all_by_nickname_and_size('Claas', 3)).to eq [] + expect(Hemorrhoid.find_all_by_nickname_and_size('Claas', 3, with_deleted: false)).to eq [] + end + + it 'loads non-deleted' do + hemorrhoid = Hemorrhoid.create(nickname: 'Lampe', size: 4) + expect(Hemorrhoid.find_all_by_nickname('Lampe').map(&:id)).to eq [hemorrhoid.id] + end + + it 'loads deleted if asked to' do + expect(Hemorrhoid.find_all_by_nickname('Claas', with_deleted: true).map(&:id)).to eq [@hemorrhoid.id] + expect(Hemorrhoid.find_all_by_nickname_and_size('Claas', 3, with_deleted: true).map(&:id)).to eq [@hemorrhoid.id] + end + end + + it 'reuses the same view - when find_all_by is called first' do + expect(Hemorrhoid.find_all_by_nickname('Claas')).to eq [] + expect(Hemorrhoid.find_by_nickname('Claas')).to be_nil + end + + it 'reuses the same view - when find_by is called first' do + expect(Hemorrhoid.find_by_nickname('Claas')).to be_nil + expect(Hemorrhoid.find_all_by_nickname('Claas')).to eq [] + end + end + + context 'by relation' do + before do + @hemorrhoid.destroy + end + + context 'has_many' do + it 'does not load deleted by default' do + expect(@user.hemorrhoids(force_reload: true)).to eq [] + end + + it 'loads deleted if asked to' do + expect(@user.hemorrhoids(force_reload: true, with_deleted: true).map(&:id)).to eq [@hemorrhoid.id] + end + end + + context 'has_many :through' do + before do + @user = User.create(name: 'BigT', title: 'Dr.') + @pain = Pain.create + + @hemorrhoid = Hemorrhoid.new + @hemorrhoid.user = @user + @hemorrhoid.pain = @pain + @hemorrhoid.save! + + @hemorrhoid.destroy + end + + it 'does not load deleted by default' do + expect(@user.pains).to eq [] + end + + it 'loads deleted if asked to' do + expect(@user.pains(with_deleted: true).map(&:id)).to eq [@pain.id] + end + end + + context 'has_one' do + before do + @spot = Spot.create + + @hemorrhoid = Hemorrhoid.new + @hemorrhoid.spot = @spot + @hemorrhoid.save! + + @hemorrhoid.destroy + end + + it 'does not load deleted by default' do + expect(@spot.hemorrhoid(force_reload: true)).to be_nil + end + + it 'loads deleted if asked to' do + expect(@spot.hemorrhoid(force_reload: true, with_deleted: true).id).to eq @hemorrhoid.id + end + end + + context 'belongs_to' do + before do + @hemorrhoid = Hemorrhoid.new + @hemorrhoid.save! + + @sub = SubHemorrhoid.new + @sub.hemorrhoid = @hemorrhoid + @sub.save! + + @hemorrhoid.destroy + end + + it 'does not load deleted by default' do + @sub.reload + expect(@sub.hemorrhoid(force_reload: true)).to be_nil + end + + it 'loads deleted if asked to' do + @sub.reload + expect(@sub.hemorrhoid(force_reload: true, with_deleted: true).id).to eq @hemorrhoid.id + end + end + end + end + + context 'when counting' do + before do + @hemorrhoid = Hemorrhoid.create(nickname: 'Claas') + expect(@hemorrhoid.destroy).to be_truthy + expect(@hemorrhoid.reload.deleted?).to be true + end + + it 'does not count deleted' do + expect(Hemorrhoid.count).to eq 0 + expect(Hemorrhoid.count(with_deleted: false)).to eq 0 + end + + it 'counts non-deleted' do + hemorrhoid = Hemorrhoid.create(nickname: 'Claas') + expect(Hemorrhoid.count).to eq 1 + expect(Hemorrhoid.count(with_deleted: false)).to eq 1 + end + + it 'counts deleted if asked to' do + expect(Hemorrhoid.count(with_deleted: true)).to eq 1 + end + + context 'count_by' do + it 'does not count deleted' do + expect(Hemorrhoid.count_by_nickname('Claas')).to eq 0 + expect(Hemorrhoid.count_by_nickname('Claas', with_deleted: false)).to eq 0 + end + + it 'counts deleted if asked to' do + expect(Hemorrhoid.count_by_nickname('Claas', with_deleted: true)).to eq 1 + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..cb2e14b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,28 @@ +require 'pry' +require 'simply_based' +require 'couchbase' +require 'fixtures/couch' + +COUCHBASE_URL = ENV.fetch( + 'COUCHBASE_URL', + 'couchbase://Administrator:password@localhost/simply_based_test' +) + +Dir.glob('spec/support/**/*.rb').each { |f| require f.sub(%r{^spec/}, '') } + +RSpec.configure do |config| + config.expect_with(:rspec) { |c| c.syntax = [:expect, :should] } + config.color = true + config.filter_run_excluding :couchdb_only # CouchDB-mechanic specs with no Couchbase equivalent + + config.before(:suite) do + SimplyBased.database_url = COUCHBASE_URL + SimplyBased.database.create_database! # primary index + end + + config.before(:each) do + $performed_queries = [] + SimplyBased.database_url = COUCHBASE_URL + SimplyBased.database.drop_database! # clear documents between examples + end +end diff --git a/spec/support/adapter_contract.rb b/spec/support/adapter_contract.rb new file mode 100644 index 0000000..ce5d6be --- /dev/null +++ b/spec/support/adapter_contract.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +# +# The contract every SimplyBased::Adapter must satisfy, as shared examples. +# A backend proves parity by including it: +# +# RSpec.describe SimplyBased::Adapters::CouchRest do +# it_behaves_like 'a simply_couch adapter' +# end +# +# This same file is what the simply_couch-couchbase gem runs against a live +# Couchbase to demonstrate the backend is swappable. It assumes the suite's +# before(:each) leaves SimplyBased.database pointing at a fresh, empty store. + +# A minimal model used only by the contract — independent of the fixture graph +# so the examples port cleanly to any backend. +class ContractWidget + include SimplyBased::Model + property :title + property :score, type: Integer + validates :title, presence: true +end + +RSpec.shared_examples 'a simply_couch adapter' do + let(:adapter) { SimplyBased.database } + + def build_widget(attrs = {}) + ContractWidget.new({ title: 'a' }.merge(attrs)) + end + + describe '#save_document + #load_document' do + it 'persists a new document and assigns _id/_rev' do + w = build_widget(title: 'hello', score: 3) + expect(adapter.save_document(w)).to eq(true) + expect(w._id).not_to be_nil + expect(w._rev).not_to be_nil + end + + it 'reconstructs the stored doc into its model class via the ruby_class tag' do + w = build_widget(title: 'hello', score: 7) + adapter.save_document(w) + loaded = adapter.load_document(w._id) + expect(loaded).to be_a(ContractWidget) + expect(loaded.title).to eq('hello') + expect(loaded.score).to eq(7) + expect(loaded.database).to eq(adapter) + end + + it 'returns nil (does not raise) when the id is absent' do + expect(adapter.load_document('does-not-exist')).to be_nil + end + + it 'refuses to persist an invalid document' do + expect(adapter.save_document(build_widget(title: nil))).to eq(false) + end + + it 'updates an existing doc and rolls _rev forward' do + w = build_widget(title: 'v1') + adapter.save_document(w) + first_rev = w._rev + w.title = 'v2' + adapter.save_document(w) + expect(w._rev).not_to eq(first_rev) + expect(adapter.load_document(w._id).title).to eq('v2') + end + end + + describe '#destroy_document' do + it 'removes the document' do + w = build_widget + adapter.save_document(w) + id = w._id + adapter.destroy_document(w) + expect(adapter.load_document(id)).to be_nil + end + end + + describe 'bulk operations' do + it 'bulk_saves, bulk_loads (reconstructed) and bulk_destroys' do + a = build_widget(title: 'a') + b = build_widget(title: 'b') + result = adapter.bulk_save([a, b]) + expect(result[:saved].size).to eq(2) + expect(a._id).not_to be_nil + + loaded = adapter.bulk_load([a._id, b._id]) + expect(loaded).to all(be_a(ContractWidget)) + expect(loaded.map(&:title).sort).to eq(%w[a b]) + + adapter.bulk_destroy([a, b]) + expect(adapter.bulk_load([a._id, b._id])).to eq([]) + end + + it 'bulk_load drops ids that are missing' do + a = build_widget + adapter.save_document(a) + expect(adapter.bulk_load([a._id, 'missing']).map(&:_id)).to eq([a._id]) + end + end + + describe 'optimistic concurrency' do + it 'maps a stale-write clash to SimplyBased::Conflict' do + w = build_widget(title: 'orig') + adapter.save_document(w) + stale = adapter.load_document(w._id) + w.title = 'winner' + adapter.save_document(w) # advances the stored revision + stale.title = 'loser' + expect { adapter.save_document(stale) }.to raise_error(SimplyBased::Conflict) + end + end +end diff --git a/spec/support/query_limit.rb b/spec/support/query_limit.rb new file mode 100644 index 0000000..d0bf7cf --- /dev/null +++ b/spec/support/query_limit.rb @@ -0,0 +1,50 @@ +# Query-count instrumentation for SimplyBased. The Store appends each KV op and +# N1QL statement to $performed_queries (see SimplyBased::Store#_instrument), so +# these matchers measure database round-trips without touching any driver. +# +# NOTE: absolute query counts differ from the CouchDB original — Couchbase +# resolves multi-id loads as individual KV gets rather than one _bulk_docs call, +# so specs asserting exact CouchDB counts are tagged :couchdb_counts. +$performed_queries = [] + +RSpec::Matchers.define :exceed_query_limit do |expected| + match do |block| + query_count(&block) > expected + end + + failure_message_when_negated do |actual| + "Expected to run maximum #{expected} queries, got #{@executed_queries}\n" \ + "queries:\n - #{$performed_queries.map { |q| q[:url] }.join("\n - ")}" + end + + def query_count(&block) + $performed_queries = [] + block.call + @executed_queries = $performed_queries.size + end + + def supports_block_expectations? + true + end +end + +RSpec::Matchers.define :perform_any_queries do |expected| + match do |block| + query_count(&block) > 0 + end + + failure_message_when_negated do |actual| + "Expected to run no queries, got #{@executed_queries}\n" \ + "Extra queries:\n - #{$performed_queries.map { |q| q[:url] }.join("\n - ")}" + end + + def query_count(&block) + $performed_queries = [] + block.call + @executed_queries = $performed_queries.size + end + + def supports_block_expectations? + true + end +end diff --git a/spec/validations_spec.rb b/spec/validations_spec.rb new file mode 100644 index 0000000..6c711b5 --- /dev/null +++ b/spec/validations_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' + +describe 'validations' do + context "with additional validations" do + context "with validates_inclusion_of" do + it "validate inclusion of an attribute in an array" do + category = Category.new(name: "other") + category.save.should be false + end + + it "add an error message" do + category = Category.new(name: "other") + category.valid? + category.errors.full_messages.first.should match /is not included in the list/ + end + + it "allow blank" do + category = Category.new(name: nil) + category.should be_valid + end + end + + context "with validates_format_of" do + class ValidatedUser + include SimplyBased::Model + property :name + validates_format_of :name, with: /Paul/ + end + + it 'validate the format and fail when not matched' do + user = ValidatedUser.new(name: "John") + user.should_not be_valid + end + + it 'succeed when matched' do + user = ValidatedUser.new(name: "Paul") + user.should be_valid + end + + it 'fail when empty' do + user = ValidatedUser.new(name: nil) + user.should_not be_valid + end + + context "with allow_blank" do + class ValidatedBlankUser + include SimplyBased::Model + property :name + validates_format_of :name, with: /Paul/, allow_blank: true + end + + it 'not fail when nil' do + user = ValidatedBlankUser.new(name: nil) + user.should be_valid + end + + it 'not fail when empty string' do + user = ValidatedBlankUser.new(name: '') + user.should be_valid + end + + it 'fail when not matching' do + user = ValidatedBlankUser.new(name: 'John') + user.should_not be_valid + end + + it 'not fail when matching' do + user = ValidatedBlankUser.new(name: 'Paul') + user.should be_valid + end + + end + end + + context "with validates_uniqueness_of" do + #it "add a view on the unique attribute" do + #UniqueUser.by_name + #end + + it "set an error when a different with the same instance exists" do + UniqueUser.create(name: "Host Master") + user = nil + expect{ user = UniqueUser.create(name: "Host Master") }.not_to change{ UniqueUser.count } + user.should_not be_valid + end + + it "not have an error when we're the only one around" do + user = UniqueUser.create(name: "Host Master") + user.should_not be_new_record + end + + it "not have an error when it's the same instance" do + user = UniqueUser.create(name: "Host Master") + user = UniqueUser.find(user.id) + user.should be_valid + end + + it 'have a nice error message' do + UniqueUser.create(name: "Host Master") + user = nil + expect{ user = UniqueUser.create(name: "Host Master") }.not_to change{ UniqueUser.count } + user.errors[:name].should eq ["is already taken"] + end + + it 'create a view to check with' do + UniqueUser.should respond_to :by_name + UniqueUser.by_name.send(:options)[:key].should eq :name + end + + it 'not overwrite the view when a custom one already exists' do + UniqueUserWithAView.by_name.send(:options)[:key].should eq :email + end + end + + context "equality" do + it "know when two objects are equal" do + user = UniqueUser.create(name: "Host Master") + other_user = UniqueUser.create(name: "The other one") + user.should_not eq other_user + end + + it "not bail when comparing with non-SimplyBased objects" do + user = UniqueUser.create(name: "Host Master") + 5.should_not eq user + user.should_not eq 5 + end + end + + context "containment" do + it "not raise an error when no name is set" do + expect{ Page.new.save }.not_to raise_error + end + + it "be valid when argument is contained in specification" do + page = Page.new(categories: %w[one three]) + page.should be_valid + end + + it "not be valid when attribute containes value not withing specified containment" do + page = Page.new(categories: %w[one four]) + page.should_not be_valid + end + end + + end +end diff --git a/spec/view_token_safety_spec.rb b/spec/view_token_safety_spec.rb new file mode 100644 index 0000000..637fd3b --- /dev/null +++ b/spec/view_token_safety_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe "CouchDB view token safety", :couchdb_only do + describe "SimplyBased.assert_safe_view_token!" do + it "passes valid class / association / property names through unchanged" do + expect { + SimplyBased.assert_safe_view_token!('User', :posts, 'Namespaced::Thing', :editor_id, nil) + }.not_to raise_error + end + + it "accepts nested arrays and ignores nil/empty tokens" do + expect { + SimplyBased.assert_safe_view_token!([:locale, nil], '', :position) + }.not_to raise_error + end + + it "raises on characters that could break out of the generated JavaScript" do + ["a'b", 'a"b', 'a\\b', "a;b", "a)b", "a{b", "a(b", "a\nb"].each do |bad| + expect { + SimplyBased.assert_safe_view_token!(bad) + }.to raise_error(SimplyBased::Error), "expected #{bad.inspect} to be rejected" + end + end + end + + it "rejects an association declared with an unsafe name at definition time" do + expect { + Class.new do + include SimplyBased::Model + belongs_to :"evil'); emit(doc,1);//" + end + }.to raise_error(SimplyBased::Error) + end +end diff --git a/spec/views_spec.rb b/spec/views_spec.rb new file mode 100644 index 0000000..db24323 --- /dev/null +++ b/spec/views_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +class CustomViewUser + include SimplyBased::Model + + property :tags + view :by_tags, type: SimplyBased::Model::Views::ArrayPropertyViewSpec, key: :tags +end + +describe 'Custom couch views' do + context 'with array views' do + it 'finds objects with one match of the array' do + CustomViewUser.create(tags: ['agile', 'cool', 'extreme']) + CustomViewUser.create(tags: ['agile']) + expect(CustomViewUser.find_all_by_tags('agile').size).to eq 2 + end + + it 'finds the object when the property is not an array' do + CustomViewUser.create(tags: 'agile') + expect(CustomViewUser.find_all_by_tags('agile').size).to eq 1 + end + end +end