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