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:
GregandClaude Opus 4.8 committed 2026-06-20 13:58:47 -05:00
commit 86299b8827
84 files changed
+9904

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/.bundle/
/vendor/bundle/
Gemfile.lock
*.gem
+16
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
source 'https://rubygems.org'
gemspec
group :test do
gem 'rspec'
gem 'pry'
end
gem 'aws-sdk-s3'
gem 'rexml'
+21
View File
@@ -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.
+112
View File
@@ -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.
+15
View File
@@ -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
+23
View File
@@ -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
+41
View File
@@ -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
+77
View File
@@ -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
+68
View File
@@ -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
+36
View File
@@ -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
+156
View File
@@ -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
+331
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
en:
errors:
messages:
taken: is already taken
no_parent: should be parent object
+203
View File
@@ -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
+308
View File
@@ -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
+173
View File
@@ -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
+260
View File
@@ -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
+202
View File
@@ -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
+90
View File
@@ -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
+141
View File
@@ -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
+36
View File
@@ -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
+30
View File
@@ -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
+197
View File
@@ -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
+211
View File
@@ -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
+77
View File
@@ -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
+177
View File
@@ -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
+188
View File
@@ -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
+75
View File
@@ -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
+25
View File
@@ -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
+438
View File
@@ -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, &block; end
def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, &block; end
def self.around_#{cb}(*args, &block); set_callback :#{cb}, :around, *args, &block; 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, &block; end
def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, &block; 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
+11
View File
@@ -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
+28
View File
@@ -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
+7
View File
@@ -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
+25
View File
@@ -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
+2
View File
@@ -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
+23
View File
@@ -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
+135
View File
@@ -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
+460
View File
@@ -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
+34
View File
@@ -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
+7
View File
@@ -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
+289
View File
@@ -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
+16
View File
@@ -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
+217
View File
@@ -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
+40
View File
@@ -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
+40
View File
@@ -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
+56
View File
@@ -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
+118
View File
@@ -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
+112
View File
@@ -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
+19
View File
@@ -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
+173
View File
@@ -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
+45
View File
@@ -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
+236
View File
@@ -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
+354
View File
@@ -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
+639
View File
@@ -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
+476
View File
@@ -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
+139
View File
@@ -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
+43
View File
@@ -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
+226
View File
@@ -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
+15
View File
@@ -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
+37
View File
@@ -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
+68
View File
@@ -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
+78
View File
@@ -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
+56
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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
+453
View File
@@ -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
+28
View File
@@ -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
+111
View File
@@ -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
+50
View File
@@ -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
+146
View File
@@ -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
+34
View File
@@ -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
+23
View File
@@ -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