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 <noreply@anthropic.com>
This commit is contained in:
commit
86299b8827
84 files changed
+9904
No files matched your search
@@ -0,0 +1,4 @@
|
||||
/.bundle/
|
||||
/vendor/bundle/
|
||||
Gemfile.lock
|
||||
*.gem
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
source 'https://rubygems.org'
|
||||
gemspec
|
||||
group :test do
|
||||
gem 'rspec'
|
||||
gem 'pry'
|
||||
end
|
||||
gem 'aws-sdk-s3'
|
||||
gem 'rexml'
|
||||
+21
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
en:
|
||||
errors:
|
||||
messages:
|
||||
taken: is already taken
|
||||
no_parent: should be parent object
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
# <name>_file_name, <name>_content_type, <name>_file_size, <name>_updated_at
|
||||
#
|
||||
# Files stored at: public/system/<attachment>/<id>/original.<ext>
|
||||
# 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
require 'simply_based/model/views/array_property_view_spec'
|
||||
require 'simply_based/model/views/deleted_model_view_spec'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+354
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+262
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user