SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM
A from-scratch fork of simply_couch's model layer that runs on Couchbase natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`. - SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++ finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or JavaScript map/reduce — the hand-written association views become N1QL (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array index, or KV multi-get on the storing side). - Redis replacements on the same cluster: Counter (atomic increment/decrement), Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob), Lock (insert-if-absent + TTL + CAS-checked release). - Runs the simply_couch test suite against live Couchbase: 378/432 applicable examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded). - Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded N1QL) and a few pagination/lifecycle edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
86299b8827
84 files changed
+9904
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# use ISO8601 as timeformat
|
||||
class Date
|
||||
def to_json(*a)
|
||||
%|"#{as_json}"|
|
||||
end
|
||||
|
||||
def as_json(*args)
|
||||
iso8601
|
||||
end
|
||||
|
||||
def self.json_create string
|
||||
return nil if string.nil?
|
||||
Date.parse(string)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
require 'active_support/time'
|
||||
# use ISO8601 as timeformat
|
||||
class Time
|
||||
def to_json(*a)
|
||||
%|"#{as_json}"|
|
||||
end
|
||||
|
||||
def as_json(*args)
|
||||
getutc.iso8601
|
||||
end
|
||||
|
||||
def self.json_create string
|
||||
return nil if string.nil?
|
||||
d = DateTime.parse(string.to_s).new_offset
|
||||
self.utc(d.year, d.month, d.day, d.hour, d.min, d.sec).in_time_zone
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport::TimeWithZone.class_eval do
|
||||
def as_json(*args)
|
||||
utc.iso8601
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# Please move me to a proper location
|
||||
class String
|
||||
def property_name
|
||||
underscore.gsub('/','__').gsub('::','__')
|
||||
end
|
||||
end
|
||||
|
||||
# Guard on a sentinel that only this file defines (SimplyBased::VERSION), not on
|
||||
# the bare SimplyBased constant. A plugin gem (e.g. simply_couch-couchbase) may
|
||||
# open `module SimplyBased` for its own version/namespace before this file loads;
|
||||
# guarding on the bare module would then skip the library's whole initialization.
|
||||
unless defined?(SimplyBased::VERSION)
|
||||
$:<<(File.expand_path(File.dirname(__FILE__) + "/lib"))
|
||||
require 'simply_based/instance_methods'
|
||||
require 'simply_based/class_methods_base'
|
||||
|
||||
module SimplyBased
|
||||
VERSION = '0.1.0'
|
||||
class Error < RuntimeError; end
|
||||
class RecordNotFound < RuntimeError; end
|
||||
class NotImplementedError < RuntimeError; end
|
||||
class ModelNotInstantiatedError < RuntimeError; end
|
||||
class NotFound < RuntimeError; end
|
||||
|
||||
# No-op retained for source compatibility with SimplyCouch. SimplyBased
|
||||
# never interpolates values into JavaScript views — queries are N1QL with
|
||||
# bound parameters — so there is nothing to sanitize. Returns its tokens.
|
||||
def self.assert_safe_view_token!(*tokens)
|
||||
tokens
|
||||
end
|
||||
end
|
||||
|
||||
require 'simply_based/document_codec'
|
||||
require 'simply_based/database_config'
|
||||
require 'simply_based/model'
|
||||
require 'simply_based/store'
|
||||
require 'simply_based/redis'
|
||||
require 'core_ext/time'
|
||||
require 'core_ext/date'
|
||||
end
|
||||
class SimplyBased::Conflict < StandardError; end
|
||||
@@ -0,0 +1,77 @@
|
||||
module SimplyBased
|
||||
module ClassMethods
|
||||
module Base
|
||||
def get_class_from_name(klass_name)
|
||||
# Try to find class from property definition
|
||||
if property = _find_property(klass_name) and property.respond_to?(:options) and property.options[:class_name].present?
|
||||
property.options[:class_name].constantize
|
||||
else # Fall back to name guessing
|
||||
base = klass_name.to_s.gsub('__','/')
|
||||
base = base.classify unless base[0,1] =~ /[A-Z]/
|
||||
base.constantize
|
||||
end
|
||||
end
|
||||
|
||||
def foreign_key
|
||||
name.underscore.gsub('/','__').gsub('::','__') + "_id"
|
||||
end
|
||||
|
||||
def foreign_property
|
||||
name.underscore.gsub('/','__').gsub('::','__')
|
||||
end
|
||||
|
||||
def attr_protected(*args)
|
||||
@_protected_attributes ||= []
|
||||
@_protected_attributes += args.to_a
|
||||
end
|
||||
|
||||
def attr_accessible(*args)
|
||||
@_accessible_attributes ||= []
|
||||
@_accessible_attributes += args.to_a
|
||||
end
|
||||
|
||||
def _find_property(name)
|
||||
properties.find{|property| property.name == name}
|
||||
end
|
||||
|
||||
# Namespace aware method of creating proper class names
|
||||
def find_association_class_name(association_name)
|
||||
(name.split('::')[0..-2] + [association_name.to_s.singularize.camelize]).join('::')
|
||||
end
|
||||
|
||||
# More compatibility with active record plugins
|
||||
def primary_key
|
||||
'id'
|
||||
end
|
||||
|
||||
def view(view_name, options)
|
||||
# SimplyBased serves associations and lookups with N1QL, not CouchDB
|
||||
# JavaScript views. Hand-written map/reduce registrations
|
||||
# (:map_function / :reduce_function / :type => :custom|:raw) are no-ops —
|
||||
# the association layer queries the Store directly. Structured model
|
||||
# views (all_documents, find_by_*; no :map_function) still register.
|
||||
return if options[:map_function] || options[:reduce_function] ||
|
||||
[:custom, :raw].include?(options[:type])
|
||||
super(view_name, options)
|
||||
end
|
||||
|
||||
# Fix for paperclip > 3.5.2
|
||||
def after_commit(*); end
|
||||
|
||||
# Get documents by ids and bulk update attributes
|
||||
def bulk_update(ids, pairs)
|
||||
# Bulk load documents
|
||||
# Map to doc
|
||||
# Filter out errors, or none founds (compact)
|
||||
# Select the Couch objects
|
||||
docs = database.bulk_load(ids).select{|d| d.is_a?(SimplyBased::Model)}
|
||||
for doc in docs
|
||||
pairs.each_pair do |k, v|
|
||||
doc.send("#{k}=", v) if doc.respond_to?("#{k}=")
|
||||
end
|
||||
doc.save # Should become a bulk update in the future
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'active_support/core_ext/module/attribute_accessors'
|
||||
|
||||
module SimplyBased
|
||||
# Global Couchbase configuration.
|
||||
#
|
||||
# Set once at boot:
|
||||
# SimplyBased.database_url =
|
||||
# "couchbase://user:pass@localhost/my_bucket?scope=_default&collection=_default"
|
||||
#
|
||||
# Per-request override (multi-tenant): define a top-level Current class with a
|
||||
# `couch_database` attribute and set `Current.couch_database =
|
||||
# SimplyBased.database_for(...)`. Block-scoped: SimplyBased.with_database(db){}.
|
||||
#
|
||||
# Fallback chain for Model#database:
|
||||
# 1. Model's own use_database URL
|
||||
# 2. Current.couch_database (request-scoped)
|
||||
# 3. SimplyBased.database_url (global default)
|
||||
|
||||
mattr_accessor :database_url
|
||||
mattr_accessor :s3_defaults
|
||||
|
||||
# Load S3 defaults from a YAML config file with ERB support.
|
||||
def self.load_s3_config(path, env = nil)
|
||||
env ||= defined?(Rails) ? Rails.env : 'development'
|
||||
raw = File.read(path.to_s)
|
||||
yaml = defined?(ERB) ? ERB.new(raw).result : raw
|
||||
config = YAML.safe_load(yaml, permitted_classes: [], permitted_symbols: [], aliases: true) || {}
|
||||
self.s3_defaults = (config[env] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
# Returns a Store for the given Couchbase connection URL, cached by URL.
|
||||
def self.database_for(url)
|
||||
return nil unless url
|
||||
databases[url] ||= Store.new(url)
|
||||
end
|
||||
|
||||
def self.database
|
||||
current_database || database_for(database_url) || fallback_database
|
||||
end
|
||||
|
||||
def self.with_database(database)
|
||||
previous = current_database
|
||||
self.current_database = database
|
||||
yield
|
||||
ensure
|
||||
self.current_database = previous
|
||||
end
|
||||
|
||||
def self.current_database
|
||||
defined?(::Current) && ::Current.respond_to?(:couch_database) ? ::Current.couch_database : nil
|
||||
end
|
||||
|
||||
def self.current_database=(db)
|
||||
::Current.couch_database = db if defined?(::Current) && ::Current.respond_to?(:couch_database=)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.databases
|
||||
@_databases ||= {}
|
||||
end
|
||||
|
||||
def self.fallback_database
|
||||
database_for(ENV['COUCHBASE_URL'] || 'couchbase://Administrator:password@localhost/simply_based')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SimplyBased
|
||||
# Reconstructs a stored JSON document (a plain Hash) back into its model
|
||||
# instance, independently of any database driver.
|
||||
#
|
||||
# simply_couch tags every persisted document with a "ruby_class" key (see
|
||||
# SimplyBased::Model::Persistence::Json#to_hash), and every model defines
|
||||
# .json_create. Together they let an adapter rehydrate a model from a hash
|
||||
# without relying on a particular driver's JSON-decoding hook.
|
||||
#
|
||||
# The CouchRest adapter lets couchrest decode for it (it sets
|
||||
# JSON.create_id = 'ruby_class', so JSON.parse already returns model objects).
|
||||
# Adapters whose driver returns plain hashes (e.g. couchbase) call
|
||||
# DocumentCodec.from_hash to get the same result.
|
||||
module DocumentCodec
|
||||
module_function
|
||||
|
||||
# hash -> model instance. Returns the input unchanged when it is nil or
|
||||
# carries no recognised ruby_class tag (so raw/system rows pass through).
|
||||
def from_hash(hash)
|
||||
return hash if hash.nil?
|
||||
klass_name = hash['ruby_class'] || hash[:ruby_class]
|
||||
return hash unless klass_name
|
||||
klass = constantize(klass_name)
|
||||
return hash unless klass.respond_to?(:json_create)
|
||||
klass.json_create(hash)
|
||||
end
|
||||
|
||||
def constantize(name)
|
||||
Object.const_get(name)
|
||||
rescue NameError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
# This file is a tool extension to simply stored. Its intention is to not
|
||||
# change anything of an existing implementation, but to hugely speedup existing
|
||||
# implementations. I will illustrate this given the example relation types:
|
||||
# Person has_many posts and belongs to a group, Post has many comments, Comment belongs to Writer
|
||||
# If for a reason you have one page where you want to display all of these objects
|
||||
# (Person, Post, Comment) you can ofcourse create a view returning all these
|
||||
# objects with a smart key for some handy selection. This will probably end up
|
||||
# in a controller implementation:
|
||||
# @persons = view_result.select{|r| r.is_a?(Person)}
|
||||
# @posts = view_result.select{|r| r.is_a?(Post)}
|
||||
# @comments = view_result.select{|r| r.is_a?(Comment)}
|
||||
# @writers = view_result.select{|r| r.is_a?(Writer)}
|
||||
# This probably is the recommended way of solving problems in most cases, but sometimes
|
||||
# because you are lazy or some other obscure reason, you want to use the standard
|
||||
# SimplyBased behaviour, but not wait too long. For example, if I have a list of 40 persons,
|
||||
# all having 5 posts that all have 10 comments belonging to a writer, getting all these
|
||||
# through their standard relations:
|
||||
# @persons.each{ |person| person.posts.each{ |post| post.comments.each{ |comment| puts person.group.name + comment.writer.name } } }
|
||||
# This will result in 40 * 5 * 10 + 40 = 2040 queries to the database. Doing exactly the same thing using this script
|
||||
# will look like:
|
||||
# @persons = Person.all.include_relation( :group, posts: { conmments: :writer } )
|
||||
# The useless script above will not take 2040 queries but:
|
||||
# 1 (persons) + 1 (group) + 1 (posts) + 1 (comments) + 1 (write) = 5 queries
|
||||
# This makes a difference.
|
||||
# Issues:
|
||||
# * Supported relation types:
|
||||
# * has_many
|
||||
# * belongs_to
|
||||
# * belongs_to relations, that have no value (nil) will be queried again.
|
||||
# That would make the calculation above: 5 + number of persons without a group + number of comments without a writer
|
||||
# * Little test coverage
|
||||
class Array
|
||||
def include_relation(*relations_arg)
|
||||
return self if empty?
|
||||
relations = {}
|
||||
database = nil
|
||||
database = relations_arg.last.delete(:database) if relations_arg.last.is_a?(Hash) and relations_arg.last.has_key?(:database)
|
||||
database ||= self.first&.class&.database
|
||||
raise ArgumentError, "Cannot include relations without a database — pass :database option or ensure models respond to .database" unless database
|
||||
|
||||
# Make sure relations is a Hash, process up to two levels for recursion
|
||||
# keys with value nil will not have a followup
|
||||
relations_arg.each do |arg|
|
||||
if arg.is_a?(Symbol)
|
||||
relations[arg] = nil
|
||||
elsif arg.is_a?(Hash)
|
||||
arg.each{|k, v| relations[k] = v}
|
||||
elsif arg.is_a?(Array)
|
||||
arg.each do |v|
|
||||
if arg.is_a?(Symbol)
|
||||
relations[v] = nil
|
||||
elsif arg.is_a?(Hash)
|
||||
arg.each{|k, v| relations[k] = v}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# For now, assume an array of only one datatype
|
||||
klass = first.class
|
||||
|
||||
relations.each do |relation, followup|
|
||||
property = klass.properties.find{|p| p.name == relation}
|
||||
unless property
|
||||
warn "Attempt to include_relations #{relation} on #{klass.name} but does not have supporting relation", uplevel: 1
|
||||
next
|
||||
end
|
||||
case property
|
||||
when SimplyBased::Model::HasMany::Property then
|
||||
other_class = property.options[:class_name].constantize
|
||||
other_property = other_class.properties.find{|p| p.is_a?(SimplyBased::Model::BelongsTo::Property) && p.options[:class_name] == klass.name}
|
||||
#TODO riase when soft_delete is enabled
|
||||
view_name = "by_#{other_property.name}_id"
|
||||
raise "Cannot include has_many relation #{other_class.name.underscore.pluralize} on #{klass.name} when view :#{view_name}, key: :#{other_property.name}_id is not defined on #{other_class.name}" unless other_class.views[view_name].present?
|
||||
relation_objects = other_class.database.view(other_class.send(view_name, keys: collect(&:id))) #not working yet
|
||||
if followup # deeper nested including
|
||||
case followup
|
||||
when Hash
|
||||
then relation_objects.include_relation(followup.merge(database: database))
|
||||
else
|
||||
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
||||
end
|
||||
end
|
||||
|
||||
for obj in self
|
||||
found_relation_objects = relation_objects.select{|r| r.send("#{other_property.name}_id") == obj.id}
|
||||
|
||||
# Make sure every object has a cached value, no more loading is done
|
||||
obj.instance_variable_set("@#{relation}", {all: []}) unless obj.instance_variable_get("@#{relation}").try('[]', :all)
|
||||
if found_relation_objects.any?
|
||||
obj.instance_variable_get("@#{relation}")[:all] |= found_relation_objects
|
||||
if reverse_property_name = other_class.properties.find{|p| p.is_a?(SimplyBased::Model::BelongsTo::Property) && p.options[:class_name] == klass.name }.try(:name)
|
||||
found_relation_objects.each{|relation_object| relation_object.instance_variable_set("@#{reverse_property_name}", obj)}
|
||||
end
|
||||
end
|
||||
end
|
||||
when SimplyBased::Model::BelongsTo::Property then
|
||||
key = "#{relation}_id"
|
||||
# Collect keys for all objects
|
||||
keys = []
|
||||
each do |obj|
|
||||
next unless obj.is_a?(SimplyBased::Model) && obj.respond_to?(key)
|
||||
keys << obj.send(key)
|
||||
end
|
||||
|
||||
# Get from the database
|
||||
relation_objects = database.bulk_load(keys.compact.uniq)
|
||||
if followup # deeper nested including
|
||||
case followup
|
||||
when Hash
|
||||
then relation_objects.include_relation(followup.merge(database: database))
|
||||
else
|
||||
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
||||
end
|
||||
end
|
||||
|
||||
# Set to attributes
|
||||
each do |obj|
|
||||
obj.instance_variable_set("@#{relation}", relation_objects.find{|o| o.id == obj.send(key)})
|
||||
end
|
||||
when SimplyBased::Model::HasAndBelongsToMany::Property
|
||||
if property.options[:storing_keys]
|
||||
key = "#{relation.to_s.singularize}_ids"
|
||||
# Collect relation ids for all objects
|
||||
relation_ids = []
|
||||
each do |obj|
|
||||
next unless obj.is_a?(SimplyBased::Model) && obj.respond_to?(key) && obj.send(key).present?
|
||||
relation_ids += obj.send(key)
|
||||
end
|
||||
# Create unique list of ids, this will optimize stuff and synchronize the object ids
|
||||
relation_ids = relation_ids.flatten.compact.uniq
|
||||
|
||||
# Get from the database
|
||||
relation_objects = database.bulk_load(relation_ids)
|
||||
each do |obj|
|
||||
obj.instance_variable_set("@#{relation}", {all: relation_objects.select{|o| Array.wrap(obj.send(key)).include?(o.id)}})
|
||||
end
|
||||
if followup # deeper nested including
|
||||
case followup
|
||||
when Hash
|
||||
then relation_objects.include_relation(followup.merge(database: database))
|
||||
else
|
||||
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
# Alias method as plural form
|
||||
def include_relations(*args)
|
||||
include_relation(*args)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,331 @@
|
||||
module SimplyBased
|
||||
module InstanceMethods
|
||||
def initialize(attributes = {}, &blk)
|
||||
super(_remove_protected_attributes(attributes))
|
||||
blk.call(self) if blk
|
||||
end
|
||||
def ==(other)
|
||||
# Unsaved records (no _id) only equal themselves — otherwise two distinct
|
||||
# new instances would compare equal (both _id/_rev nil) and collide in
|
||||
# Set/uniq/include?, which the associations rely on.
|
||||
return equal?(other) if _id.nil?
|
||||
other.kind_of?(SimplyBased::Model) && other._id == _id && other._rev == _rev
|
||||
end
|
||||
|
||||
def eql?(other)
|
||||
self.==(other)
|
||||
end
|
||||
|
||||
def save(validate = true)
|
||||
validate = validate[:validate] if validate.is_a?(Hash) && validate.has_key?(:validate)
|
||||
result = retry_on_conflict do
|
||||
retry_on_connection_error do
|
||||
self.class.database.save_document(self, validate)
|
||||
end
|
||||
end
|
||||
# ActiveModel implementations
|
||||
@previously_changed = changes
|
||||
#@changed_attributes.clear
|
||||
return result
|
||||
end
|
||||
|
||||
def save!
|
||||
result = retry_on_conflict do
|
||||
retry_on_connection_error do
|
||||
self.class.database.save_document!(self)
|
||||
end
|
||||
end
|
||||
# ActiveModel implementations
|
||||
@previously_changed = changes
|
||||
#@changed_attributes.clear
|
||||
return result
|
||||
end
|
||||
|
||||
def destroy(override_soft_delete=false)
|
||||
check_and_destroy_dependents
|
||||
if self.class.soft_deleting_enabled? && !override_soft_delete
|
||||
# soft-delete
|
||||
_mark_as_deleted
|
||||
else
|
||||
if self.class.soft_deleting_enabled? && deleted?
|
||||
# really deleting a previously soft-deleted object - skipping callbacks
|
||||
self.class.database.destroy_document(self, false)
|
||||
else # deleting a normal object or a soft-deletable object that was not soft-deleted before
|
||||
self.class.database.destroy_document(self, true)
|
||||
end
|
||||
freeze
|
||||
end
|
||||
self
|
||||
end
|
||||
alias :delete :destroy
|
||||
|
||||
def update_attributes(attributes = {})
|
||||
parent_id_present = attributes.delete(:parent_id) || attributes.delete('parent_id')
|
||||
self.attributes = attributes
|
||||
if parent_id_present
|
||||
if valid?
|
||||
self.parent_id = parent_id_present
|
||||
save # only for return value, revalidate etc.
|
||||
else
|
||||
return false
|
||||
end
|
||||
else
|
||||
save
|
||||
end
|
||||
end
|
||||
alias_method :update, :update_attributes
|
||||
|
||||
def attributes=(attr)
|
||||
super(_remove_protected_attributes(attr))
|
||||
end
|
||||
|
||||
def reload
|
||||
@children = nil
|
||||
@descendants = nil
|
||||
instance = self.class.find(_id, :with_deleted => true)
|
||||
instance.attributes.each do |attribute, value|
|
||||
send "#{attribute}=", value
|
||||
end
|
||||
self._rev = instance._rev
|
||||
self._attachments = instance._attachments # CouchDB internal, not a property
|
||||
clear_changes_information
|
||||
reset_association_caches
|
||||
self
|
||||
end
|
||||
|
||||
def deleted?
|
||||
if self.class.soft_deleting_enabled?
|
||||
!send(self.class.soft_delete_attribute).nil?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def serializable_hash(*)
|
||||
{'id' => self.id}.merge attributes
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def retry_on_connection_error(max_retries = 2, &blk)
|
||||
retry_count = 0
|
||||
begin
|
||||
blk.call
|
||||
rescue Errno::ECONNREFUSED => e
|
||||
if retry_count < max_retries
|
||||
retry_count += 1
|
||||
retry
|
||||
else
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def retry_on_conflict(max_retries = 2, &blk)
|
||||
retry_count = 0
|
||||
begin
|
||||
_reset_conflict_information
|
||||
blk.call
|
||||
rescue SimplyBased::Conflict => e
|
||||
if self.class.auto_conflict_resolution_on_save && retry_count < max_retries && try_to_merge_conflict
|
||||
retry_count += 1
|
||||
retry
|
||||
else
|
||||
_decorate_with_conflict_details(e) if e.is_a?(SimplyBased::Conflict)
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _decorate_with_conflict_details(exception)
|
||||
if @_conflict_information.present?
|
||||
def exception.metaclass
|
||||
class << self
|
||||
self
|
||||
end
|
||||
end
|
||||
local_conflict_information = @_conflict_information
|
||||
exception.metaclass.send(:define_method, :message){ "409 Conflict - conflict on attributes: #{local_conflict_information.inspect}" }
|
||||
end
|
||||
end
|
||||
|
||||
def try_to_merge_conflict
|
||||
original = self.class.find(id)
|
||||
our_attributes = self.attributes.dup
|
||||
their_attributes = original.attributes.dup
|
||||
_clear_non_relevant_attributes(our_attributes)
|
||||
_clear_non_relevant_attributes(their_attributes)
|
||||
if _merge_possible?(our_attributes, their_attributes)
|
||||
_copy_non_conflicting_attributes(our_attributes, their_attributes)
|
||||
self._rev = original._rev
|
||||
@_document['_rev'] = original._rev if @_document # keep CouchDB cache in sync
|
||||
true
|
||||
else
|
||||
@_conflict_information = _conflicting_attributes(our_attributes, their_attributes)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def _reset_conflict_information
|
||||
@_conflict_information = nil
|
||||
end
|
||||
|
||||
def _clear_non_relevant_attributes(attr_list)
|
||||
[:updated_at, :created_at, :id, :rev, :_id, :_rev].each do |skipped_attribute|
|
||||
attr_list.delete(skipped_attribute)
|
||||
end
|
||||
end
|
||||
|
||||
def _copy_non_conflicting_attributes(our_attributes, their_attributes)
|
||||
their_attributes.each do |attr_name, their_value|
|
||||
if !self.public_send("#{attr_name}_changed?") && our_attributes[attr_name] != their_value
|
||||
self.public_send("#{attr_name}=", their_value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _merge_possible?(our_attributes, their_attributes)
|
||||
_conflicting_attributes(our_attributes, their_attributes).empty?
|
||||
end
|
||||
|
||||
def _conflicting_attributes(our_attributes, their_attributes)
|
||||
their_attributes.keys.delete_if do |attr_name|
|
||||
_attribute_not_in_conflict?(attr_name, our_attributes[attr_name], their_attributes[attr_name])
|
||||
end
|
||||
end
|
||||
|
||||
def _attribute_not_in_conflict?(attr_name, our_value, their_value)
|
||||
our_value == their_value || # same
|
||||
!self.public_send("#{attr_name}_changed?") || # we didn't change
|
||||
self.public_send("#{attr_name}_changed?") && their_value == self.public_send("#{attr_name}_was") # we changed and they kept the original
|
||||
end
|
||||
|
||||
def reset_association_caches
|
||||
self.class.properties.each do |property|
|
||||
if property.respond_to?(:association?) && property.association?
|
||||
instance_variable_set("@#{property.name}", nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _remove_protected_attributes(attrs)
|
||||
return {} if attrs.blank?
|
||||
attrs = attrs.dup.stringify_keys
|
||||
(self.class.instance_variable_get(:@_protected_attributes) || []).map(&:to_s).each do |protected_attribute|
|
||||
attrs.delete(protected_attribute)
|
||||
end
|
||||
|
||||
accessible_attributes = (self.class.instance_variable_get(:@_accessible_attributes) || []).map(&:to_s)
|
||||
|
||||
if accessible_attributes.present?
|
||||
attrs.each do |attr_key, attr_value|
|
||||
attrs.delete(attr_key) unless accessible_attributes.include?(attr_key)
|
||||
end
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def check_and_destroy_dependents
|
||||
self.class.properties.each do |property|
|
||||
if property.respond_to?(:association?) and property.association?
|
||||
if property.is_a?(SimplyBased::Model::HasAndBelongsToMany::Property)
|
||||
has_and_belongs_to_many_clean_up_after_destroy(property)
|
||||
else
|
||||
next unless property.options[:dependent]
|
||||
next if property.options[:through]
|
||||
dependents = send(property.name, :force_reload => true)
|
||||
dependents = [dependents] unless dependents.is_a?(Array)
|
||||
dependents.reject{|d| d.nil?}.each do |dependent|
|
||||
case property.options[:dependent]
|
||||
when :destroy
|
||||
dependent.destroy
|
||||
when :ignore
|
||||
# skip
|
||||
else
|
||||
# nullify
|
||||
unless dependent.class.soft_deleting_enabled? && dependent.deleted?
|
||||
foreign_property = self.class.foreign_property
|
||||
foreign_property_without_namespace = foreign_property.to_s.split('__').last
|
||||
if dependent.respond_to?("#{foreign_property}=")
|
||||
dependent.public_send("#{foreign_property}=", nil)
|
||||
elsif dependent.respond_to?("#{foreign_property_without_namespace}=")
|
||||
dependent.public_send("#{foreign_property_without_namespace}=", nil)
|
||||
end
|
||||
dependent.save(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _default_view_options(options = {})
|
||||
view_options = {:include_docs => true, :reduce => false}
|
||||
view_options[:descending] = options[:descending] if options[:descending]
|
||||
if view_options[:descending]
|
||||
view_options[:startkey] = ["#{id}\u9999"]
|
||||
view_options[:endkey] = [id]
|
||||
else
|
||||
view_options[:startkey] = [id]
|
||||
view_options[:endkey] = ["#{id}\u9999"]
|
||||
end
|
||||
view_options[:limit] = options[:limit] if options[:limit]
|
||||
view_options
|
||||
end
|
||||
|
||||
def find_one_associated(from, to, options = {})
|
||||
options = {
|
||||
:limit => 1,
|
||||
:descending => true
|
||||
}.update(options)
|
||||
find_associated(from, to, options).first
|
||||
end
|
||||
|
||||
# belongs_to / has_many / has_one: docs of `from` whose scalar foreign-key
|
||||
# column points at this record. N1QL equality, served by the Store.
|
||||
def _association_foreign_column(to, options)
|
||||
base = (options[:foreign_key] || to.name.singularize.property_name.foreign_key).to_s.sub(/_id$/, '')
|
||||
"#{base}_id"
|
||||
end
|
||||
|
||||
def find_associated(from, to, options = {})
|
||||
self.class.database.find_by_foreign_key(
|
||||
self.class.get_class_from_name(from),
|
||||
_association_foreign_column(to, options), id,
|
||||
with_deleted: options[:with_deleted], limit: options[:limit],
|
||||
descending: options[:descending], skip: options[:skip])
|
||||
end
|
||||
|
||||
def count_associated(from, to, options = {})
|
||||
self.class.database.count_by_foreign_key(
|
||||
self.class.get_class_from_name(from),
|
||||
_association_foreign_column(to, options), id,
|
||||
with_deleted: options[:with_deleted])
|
||||
end
|
||||
|
||||
# has_and_belongs_to_many (non-storing side): docs of `from` whose array
|
||||
# column contains this record's id. N1QL `ANY ... SATISFIES`, served by the
|
||||
# Store. (The storing side is a KV multi-get, handled in the habtm getter.)
|
||||
def find_associated_via_join_view(from, to, options = {})
|
||||
self.class.database.find_by_array_contains(
|
||||
self.class.get_class_from_name(from), options[:foreign_key].to_s, id,
|
||||
with_deleted: options[:with_deleted], limit: options[:limit],
|
||||
descending: options[:descending], skip: options[:skip])
|
||||
end
|
||||
|
||||
def count_associated_via_join_view(from, to, options = {})
|
||||
self.class.database.count_by_array_contains(
|
||||
self.class.get_class_from_name(from), options[:foreign_key].to_s, id,
|
||||
with_deleted: options[:with_deleted])
|
||||
end
|
||||
|
||||
def _mark_as_deleted
|
||||
run_callbacks :destroy do
|
||||
send("#{self.class.soft_delete_attribute}=", Time.now)
|
||||
save(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
en:
|
||||
errors:
|
||||
messages:
|
||||
taken: is already taken
|
||||
no_parent: should be parent object
|
||||
@@ -0,0 +1,203 @@
|
||||
require 'active_model'
|
||||
require 'json'
|
||||
# Hydrate parsed JSON back into model objects via the 'ruby_class' tag (see
|
||||
# Persistence::Json#json_create and SimplyBased::DocumentCodec).
|
||||
JSON.create_id = 'ruby_class'
|
||||
|
||||
require 'active_support'
|
||||
unless {}.respond_to?(:assert_valid_keys)
|
||||
require 'active_support/core_ext'
|
||||
end
|
||||
I18n.load_path << File.join(File.expand_path(File.dirname(__FILE__)), 'locale', 'en.yml')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../simply_based')
|
||||
require 'simply_based/model/database'
|
||||
require 'simply_based/model/validations'
|
||||
require 'simply_based/model/pagination_options'
|
||||
require 'simply_based/model/association_property'
|
||||
require 'simply_based/model/properties'
|
||||
require 'simply_based/model/ancestry'
|
||||
require 'simply_based/model/finders'
|
||||
require 'simply_based/model/find_by'
|
||||
require 'simply_based/model/belongs_to'
|
||||
require 'simply_based/model/embedded_in'
|
||||
require 'simply_based/model/has_many'
|
||||
require 'simply_based/model/has_many_embedded'
|
||||
require 'simply_based/model/has_and_belongs_to_many'
|
||||
require 'simply_based/model/has_one'
|
||||
require 'simply_based/model/attachments'
|
||||
require 'simply_based/model/pagination'
|
||||
require 'simply_based/model/persistence'
|
||||
require 'simply_based/model/bulk'
|
||||
require 'simply_based/model/view'
|
||||
require 'simply_based/model/views'
|
||||
require 'simply_based/include_relation'
|
||||
|
||||
module SimplyBased
|
||||
module Model
|
||||
def self.included(clazz)
|
||||
clazz.send(:include, Persistence)
|
||||
clazz.send(:include, InstanceMethods)
|
||||
clazz.send(:extend, ClassMethods)
|
||||
|
||||
clazz.instance_eval do
|
||||
attr_accessor :_accessible_attributes, :_protected_attributes
|
||||
|
||||
view :all_documents, :key => :created_at
|
||||
end
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
include SimplyBased::ClassMethods::Base
|
||||
include SimplyBased::Model::Database
|
||||
include SimplyBased::Model::Bulk
|
||||
include SimplyBased::Model::Validations
|
||||
include SimplyBased::Model::BelongsTo
|
||||
include SimplyBased::Model::EmbeddedIn
|
||||
include SimplyBased::Model::HasMany
|
||||
include SimplyBased::Model::HasManyEmbedded
|
||||
include SimplyBased::Model::HasAndBelongsToMany
|
||||
include SimplyBased::Model::HasOne
|
||||
include SimplyBased::Model::Finders
|
||||
include SimplyBased::Model::FindBy
|
||||
include SimplyBased::Model::Pagination
|
||||
include SimplyBased::Model::PaginationOptions
|
||||
include SimplyBased::Model::Ancestry
|
||||
|
||||
# Declare an S3 attachment.
|
||||
# Lazy-loads + includes SimplyBased::Model::Attachment::S3.
|
||||
def has_s3_attached(name, options = {})
|
||||
require 'simply_based/model/attachment/s3'
|
||||
include SimplyBased::Model::Attachment::S3::InstanceMethods unless included_modules.include?(SimplyBased::Model::Attachment::S3::InstanceMethods)
|
||||
SimplyBased::Model::Attachment::S3.define_s3_attached(self, name, options)
|
||||
end
|
||||
# Declare a CouchDB inline attachment.
|
||||
# Lazy-loads + includes SimplyBased::Model::Attachment::Couch.
|
||||
def has_couch_attached(name, options = {})
|
||||
require 'simply_based/model/attachment/couch'
|
||||
include SimplyBased::Model::Attachment::Couch unless included_modules.include?(SimplyBased::Model::Attachment::Couch)
|
||||
SimplyBased::Model::Attachment::Couch.define_couch_attached(self, name, options)
|
||||
end
|
||||
|
||||
# Declare a local filesystem attachment (Paperclip replacement).
|
||||
# Lazy-loads + includes SimplyBased::Model::Attachment::Local.
|
||||
def has_local_attached(name, styles: {}, default_url: nil, default_style: :original, content_type: nil)
|
||||
require 'simply_based/model/attachment/local'
|
||||
include SimplyBased::Model::Attachment::Local unless included_modules.include?(SimplyBased::Model::Attachment::Local)
|
||||
SimplyBased::Model::Attachment::Local.define_local_attached(self, name, styles: styles, default_url: default_url, default_style: default_style, content_type: content_type)
|
||||
end
|
||||
|
||||
def create(attributes = {}, &blk)
|
||||
instance = new(attributes, &blk)
|
||||
instance.save
|
||||
instance
|
||||
end
|
||||
|
||||
def create!(attributes = {}, &blk)
|
||||
instance = new(attributes, &blk)
|
||||
instance.save!
|
||||
instance
|
||||
end
|
||||
|
||||
def enable_soft_delete(property_name = :deleted_at)
|
||||
@_soft_delete_attribute = property_name.to_sym
|
||||
property property_name, :type => Time
|
||||
_define_hard_delete_methods
|
||||
_define_soft_delete_views
|
||||
end
|
||||
|
||||
def soft_delete_attribute
|
||||
@_soft_delete_attribute
|
||||
end
|
||||
|
||||
def soft_deleting_enabled?
|
||||
!soft_delete_attribute.nil?
|
||||
end
|
||||
|
||||
def split_design_documents_per_view(enabled = true)
|
||||
@_split_design_documents = enabled
|
||||
end
|
||||
|
||||
def split_design_documents?
|
||||
@_split_design_documents || false
|
||||
end
|
||||
|
||||
def auto_conflict_resolution_on_save
|
||||
@auto_conflict_resolution_on_save.nil? ? true : @auto_conflict_resolution_on_save
|
||||
end
|
||||
|
||||
def auto_conflict_resolution_on_save=(val)
|
||||
@auto_conflict_resolution_on_save = val
|
||||
end
|
||||
|
||||
def method_missing(name, *args)
|
||||
if name.to_s =~ /^find_by/
|
||||
_define_find_by(name, *args)
|
||||
elsif name.to_s =~ /^find_all_by/
|
||||
_define_find_all_by(name, *args)
|
||||
elsif name.to_s =~ /^count_by/
|
||||
_define_count_by(name, *args)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def respond_to_missing?(name, include_private = false)
|
||||
name.to_s =~ /^(find_by|find_all_by|count_by)/ || super
|
||||
end
|
||||
|
||||
def _define_hard_delete_methods
|
||||
define_method("destroy!") do
|
||||
destroy(true)
|
||||
end
|
||||
|
||||
define_method("delete!") do
|
||||
destroy(true)
|
||||
end
|
||||
end
|
||||
|
||||
def _define_soft_delete_views
|
||||
view :all_documents_without_deleted, :type => SimplyBased::Model::Views::DeletedModelViewSpec
|
||||
end
|
||||
|
||||
def _define_cache_accessors(name, options)
|
||||
define_method "_get_cached_#{name}" do
|
||||
instance_variable_get("@#{name}") || {}
|
||||
end
|
||||
|
||||
define_method "_set_cached_#{name}" do |value, cache_key|
|
||||
cached = send("_get_cached_#{name}")
|
||||
cached[cache_key] = value
|
||||
instance_variable_set("@#{name}", cached)
|
||||
end
|
||||
|
||||
define_method "_cache_key_for" do |opt|
|
||||
opt.blank? ? :all : opt.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def extract_association_options(local_options = nil)
|
||||
forced_reload = false
|
||||
with_deleted = false
|
||||
limit = nil
|
||||
descending = false
|
||||
skip = nil
|
||||
|
||||
if local_options
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted, :limit, :order)
|
||||
forced_reload = local_options.delete(:force_reload)
|
||||
with_deleted = local_options[:with_deleted]
|
||||
limit = local_options[:limit]
|
||||
descending = (local_options[:order] == :desc) ? true : false
|
||||
skip = local_options[:skip]
|
||||
end
|
||||
return [forced_reload, with_deleted, limit, descending, skip]
|
||||
end
|
||||
|
||||
# CouchDB design-document maintenance has no Couchbase equivalent — N1QL uses
|
||||
# GSIs, managed by the Store. Kept as no-ops for source compatibility.
|
||||
def self.delete_all_design_documents(*); 0; end
|
||||
def self.compact_all_design_documents(*); 0; end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,308 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Ancestry
|
||||
module InstanceMethods
|
||||
def children
|
||||
return @children if @children
|
||||
if root_property = self.class.ancestry_by_property
|
||||
@children = self.class.database.view(self.class.children_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false))
|
||||
else
|
||||
@children = self.class.database.view(self.class.children_view(startkey: [id], endkey: [id, {}], reduce: false))
|
||||
end
|
||||
@children
|
||||
end
|
||||
def children=(val)
|
||||
@old_descendants = descendants
|
||||
@children = val
|
||||
@children.map(&:subtree) # preload children hierarchy
|
||||
@new_descendants = []
|
||||
for child in @children
|
||||
for descendant in child.descendants
|
||||
@new_descendants << descendant
|
||||
end
|
||||
end
|
||||
@old_descendants.each{|d| d.instance_variable_set('@parent', nil); d.path_ids = [d.id]} # old descendants become root
|
||||
(@old_descendants - @new_descendants).map(&:save) # Persist old descendants not present in new descendants
|
||||
|
||||
# update by_property of children if it differs from parent (locale or ... orther field is required to have the same values)
|
||||
if root_property = self.class.ancestry_by_property
|
||||
self_by_property = send(root_property)
|
||||
for child in @children | @new_descendants
|
||||
child.send("#{root_property}=", self_by_property) if self_by_property != child.send(root_property)
|
||||
end
|
||||
end
|
||||
|
||||
self.class.set_parent(self, @children) # recurring update of parent in subtree of children
|
||||
@descendants = (@children | @new_descendants)
|
||||
clear_cached_ancestors
|
||||
|
||||
# Return wether all children can be saved :)
|
||||
(@descendants).map(&:save).all?
|
||||
end
|
||||
|
||||
# reset cache attributes for ancestors
|
||||
def clear_cached_ancestors
|
||||
ansi = self
|
||||
while ancestor = ansi.instance_variable_get('@parent').presence
|
||||
ancestor.instance_variable_set('@descendants', nil)
|
||||
ancestor.instance_variable_set('@children', nil)
|
||||
ansi = ancestor
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
def add_child(child)
|
||||
unless children.include?(child)
|
||||
@children ||= []
|
||||
@children += [child]
|
||||
child.parent = self
|
||||
|
||||
# update by_property of child if it differs from parent
|
||||
if root_property = self.class.ancestry_by_property
|
||||
child.send("#{root_property}=", send(root_property)) if send(root_property) != child.send(root_property)
|
||||
end
|
||||
@descendants = nil # reload descendants if requested
|
||||
end
|
||||
child
|
||||
end
|
||||
|
||||
# Get all descendants
|
||||
def descendants
|
||||
return @descendants if @descendants
|
||||
return @descendants = [] if id.blank?
|
||||
if root_property = self.class.ancestry_by_property
|
||||
@descendants = self.class.database.view(self.class.subtree_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false)).sort_by!{|d| [d.path_ids.size, d.position]}
|
||||
else
|
||||
@descendants = self.class.database.view(self.class.subtree_view(startkey: [id], endkey: [id, {}], reduce: false)).sort_by{|d| [d.path_ids.size, d.position]}
|
||||
end
|
||||
@children = self.class.build_tree(@descendants)
|
||||
@descendants
|
||||
end
|
||||
|
||||
# Find subtree of a given page and set children with the result (important to get same children object ids as in descendants)
|
||||
def subtree
|
||||
@children = self.class.build_tree(descendants)
|
||||
end
|
||||
|
||||
# Triggered before save
|
||||
def update_tree_path
|
||||
return false unless id
|
||||
newpath = ( (parent && parent.path_ids) || []) + [id]
|
||||
if path_ids != newpath
|
||||
path_ids_will_change!
|
||||
self.path_ids = newpath
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
# Triggered after create, because needs id
|
||||
def create_tree_path
|
||||
return false unless id
|
||||
newpath = ( (parent && parent.path_ids) || []) + [id]
|
||||
return true if path_ids == newpath
|
||||
self.path_ids = newpath
|
||||
save
|
||||
end
|
||||
|
||||
def parent
|
||||
return @parent if @parent
|
||||
return @parent = self.class.find(parent_id) if parent_id.present?
|
||||
nil
|
||||
end
|
||||
|
||||
def parent=(val)
|
||||
if @parent != val
|
||||
@parent = val
|
||||
update_tree_path
|
||||
@parent.children += [self] unless @parent.children.include?(self)
|
||||
save
|
||||
end
|
||||
val
|
||||
end
|
||||
|
||||
def parent_id=(val)
|
||||
if parent_id != val.presence
|
||||
@parent = nil
|
||||
@parent_id = val.presence
|
||||
subtree if persisted?
|
||||
path_ids_will_change!
|
||||
if @parent_id
|
||||
@parent = parent
|
||||
self.path_ids = parent.path_ids + [id]
|
||||
self.class.set_parent(self, @children) if persisted?
|
||||
else
|
||||
self.path_ids = [id]
|
||||
self.class.set_parent(self, @children) if persisted?
|
||||
end
|
||||
@descendants.map(&:save) if persisted? && save
|
||||
end
|
||||
end
|
||||
|
||||
def parent_id
|
||||
@parent_id ||= parent_ids.last
|
||||
end
|
||||
|
||||
def parent_ids
|
||||
(path_ids || [])[0..-2]
|
||||
end
|
||||
|
||||
def ancestors
|
||||
return [] unless parent_ids.any?
|
||||
return [parent] if parent_ids.size == 1 # optimization, parent is pre-loaded many times
|
||||
self.class.database.bulk_load(parent_ids)
|
||||
end
|
||||
def parents
|
||||
ancestors
|
||||
end
|
||||
|
||||
def tree_path
|
||||
ancestors + [self]
|
||||
end
|
||||
|
||||
def tree_depth
|
||||
(path_ids || [nil]).size - 1
|
||||
end
|
||||
end
|
||||
|
||||
module TreeBuilder
|
||||
|
||||
end
|
||||
|
||||
# Add ancestry logic to your model
|
||||
# class Page
|
||||
# include SimplyBased::Model
|
||||
# has_ancestry
|
||||
# end
|
||||
# or
|
||||
# class Page
|
||||
# include SimplyBased::Model
|
||||
# property :locale
|
||||
# has_ancestry by_property: :locale
|
||||
# end
|
||||
def has_ancestry(options = {})
|
||||
@ancestry_by_property = nil
|
||||
def self.ancestry_by_property
|
||||
@ancestry_by_property
|
||||
end
|
||||
options = {order_by: :position}.merge(options)
|
||||
SimplyBased.assert_safe_view_token!(name, options[:by_property], options[:order_by])
|
||||
order_by = case options[:order_by]
|
||||
when Symbol then "doc['#{options[:order_by]}']"
|
||||
when Array then "[#{options[:order_by].map{|o| "doc['#{o}']"}.join(', ')}]"
|
||||
else "doc['position']"
|
||||
end
|
||||
property :path_ids, type: Array, default: []
|
||||
property :position, type: Integer, default: 0
|
||||
if options[:by_property].present?
|
||||
property options[:by_property] unless property_names.include?(options[:by_property])
|
||||
@ancestry_by_property = options[:by_property]
|
||||
by_property_view_prefix = options[:by_property].present? ? "doc['#{options[:by_property]}'], " : ''
|
||||
end
|
||||
|
||||
view :subtree_view, type: :custom, include_docs: true, map_function: %|function(doc){
|
||||
if(doc['ruby_class'] == '#{name}' && doc.path_ids){
|
||||
for(var i = 0; i < doc.path_ids.length - 1; i++){
|
||||
emit([#{by_property_view_prefix}doc.path_ids[i], #{order_by}], 1);
|
||||
}
|
||||
}
|
||||
}|, reduce_function: "_sum"
|
||||
|
||||
view :children_view, type: :custom, include_docs: true, map_function: %|function(doc){
|
||||
if(doc['ruby_class'] == '#{name}' && doc.path_ids){
|
||||
emit([#{by_property_view_prefix}doc.path_ids.slice(-2,-1)[0], #{order_by}], 1);
|
||||
}
|
||||
}|, reduce_function: "_sum"
|
||||
view :roots_view, conditions: "doc.path_ids && doc.path_ids.length == 1", key: [options[:by_property].presence, options[:order_by]].compact
|
||||
include SimplyBased::Model::Ancestry::InstanceMethods
|
||||
extend SimplyBased::Model::Ancestry::ClassMethods
|
||||
before_update :update_tree_path
|
||||
after_create :create_tree_path
|
||||
end
|
||||
module ClassMethods
|
||||
def roots(options = {})
|
||||
if root_property = ancestry_by_property
|
||||
if options.blank?
|
||||
options = {}
|
||||
elsif options.is_a?(Symbol)
|
||||
options = {startkey: [options.to_s], endkey: [options.to_s, {}]}
|
||||
elsif options.is_a?(String)
|
||||
options = {startkey: [options], endkey: [options, {}]}
|
||||
elsif options.keys.include?(root_property)
|
||||
root_key = options.delete(root_property)
|
||||
options[:startkey] = [root_key.to_s]
|
||||
options[:endkey] = [root_key.to_s, {}]
|
||||
end
|
||||
end
|
||||
options[:reduce] = false
|
||||
database.view(roots_view(options))
|
||||
end
|
||||
|
||||
def full_tree(options = {})
|
||||
if root_property = ancestry_by_property
|
||||
if options.blank?
|
||||
records = all
|
||||
elsif options.is_a?(Array)
|
||||
records = options
|
||||
elsif (options.is_a?(Symbol) || options.is_a?(String))
|
||||
records = send("find_all_by_#{root_property}", options.to_s)
|
||||
elsif options.is_a?(Hash) && options.keys.include?(root_property)
|
||||
root_key = options.delete(root_property)
|
||||
records = send("find_all_by_#{root_property}", root_key.to_s)
|
||||
else
|
||||
records = options[:records].presence || all
|
||||
end
|
||||
else
|
||||
records = options.is_a?(Array) ? options : (options.present? && options[:records].presence) || all
|
||||
end
|
||||
build_tree(records) #.first.children
|
||||
end
|
||||
|
||||
# Build a tree from a flat set of pages making use of the path attribute
|
||||
def build_tree(pages = nil)
|
||||
pages ||= all
|
||||
return pages if pages.empty? # Do not process empty array
|
||||
tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children
|
||||
old_tree_slice = tree_wrapper.children
|
||||
new_tree_slice = []
|
||||
pages.sort_by!{|p| [p.path_ids.size, p.position]}
|
||||
root_depth = pages.first.path_ids.size
|
||||
current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one
|
||||
for page in pages
|
||||
old_tree_slice << page and next if page.path_ids.size == root_depth # fill first slice with roots
|
||||
|
||||
# Move further if new depth is reached
|
||||
if page.path_ids.size > current_depth
|
||||
old_tree_slice = new_tree_slice
|
||||
new_tree_slice = []
|
||||
current_depth = page.path_ids.size
|
||||
end
|
||||
parent = old_tree_slice.find{|r| r.id == page.path_ids[-2]} # path id before last is parent id
|
||||
next unless parent # page is not associated in tree
|
||||
|
||||
# Initialize children if needed
|
||||
parent.instance_variable_set('@children', []) unless parent.instance_variable_get('@children').is_a?(Array)
|
||||
|
||||
# Avoid database call on deepest children
|
||||
page.instance_variable_set('@children', []) unless page.instance_variable_get('@children').is_a?(Array)
|
||||
parent.instance_variable_get('@children') << page
|
||||
page.instance_variable_set('@parent', parent)
|
||||
page.instance_variable_set('@parent_id', parent.id)
|
||||
new_tree_slice << page
|
||||
end
|
||||
|
||||
#TODO: setting @descendants from cache as option to avoid database call when descendants is required
|
||||
tree_wrapper.children
|
||||
end
|
||||
|
||||
# Recursive set parents
|
||||
def set_parent(parent, children)
|
||||
for child in children.sort_by!(&:position)
|
||||
child.instance_variable_set('@parent', parent)
|
||||
child.update_tree_path
|
||||
set_parent child, child.children
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
class AssociationProperty
|
||||
attr_reader :name, :options
|
||||
|
||||
def dirty?(object)
|
||||
false
|
||||
end
|
||||
|
||||
def build(object, json)
|
||||
end
|
||||
|
||||
def serialize(json, object)
|
||||
end
|
||||
alias :value :serialize
|
||||
|
||||
def supports_dirty?
|
||||
false
|
||||
end
|
||||
|
||||
def association?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Attachment
|
||||
# CouchDB inline attachments — declarative, lazy-loaded.
|
||||
#
|
||||
# Two levels of API:
|
||||
#
|
||||
# 1. Low-level (instance methods):
|
||||
# put_couch_attachment(name, data, content_type: 'image/png')
|
||||
# fetch_couch_attachment(name)
|
||||
# delete_couch_attachment(name)
|
||||
#
|
||||
# 2. High-level (class macro):
|
||||
# has_couch_attached :invoice
|
||||
#
|
||||
# record.invoice = File.read('invoice.pdf') # auto-detects content_type
|
||||
# record.invoice # fetches binary data
|
||||
# record.invoice_content_type # => 'application/pdf'
|
||||
# record.invoice_size # => 24501
|
||||
#
|
||||
module Couch
|
||||
def self.included(base)
|
||||
base.after_save :_save_pending_couch_attachments
|
||||
end
|
||||
|
||||
# ── Low-level instance methods ──────────────────────────────────
|
||||
|
||||
def put_couch_attachment(name, file, content_type: 'binary/octet-stream')
|
||||
result = _couchrest_database.put_attachment(
|
||||
to_hash, name, file, content_type: content_type
|
||||
)
|
||||
self._rev = result['rev'] if result['ok']
|
||||
self._attachments = (_attachments || {}).merge(name => {})
|
||||
result
|
||||
end
|
||||
|
||||
def fetch_couch_attachment(name)
|
||||
_couchrest_database.fetch_attachment(to_hash, name)
|
||||
rescue CouchRest::NotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def delete_couch_attachment(name)
|
||||
result = _couchrest_database.delete_attachment(to_hash, name)
|
||||
self._rev = result['rev'] if result['ok']
|
||||
self._attachments = (_attachments || {}).except(name) if result['ok']
|
||||
result
|
||||
end
|
||||
|
||||
def couch_attachment_names
|
||||
(_attachments || {}).keys
|
||||
end
|
||||
|
||||
def couch_attachment?(name)
|
||||
couch_attachment_names.include?(name.to_s)
|
||||
end
|
||||
|
||||
# ── High-level: has_couch_attached ──────────────────────────────
|
||||
|
||||
def self.define_couch_attached(base, name, options = {})
|
||||
options = {
|
||||
content_type: nil,
|
||||
filename: nil # template: '{updated_at|iso8601date}-invoice.pdf'
|
||||
}.merge(options)
|
||||
|
||||
# Properties for metadata
|
||||
base.property :"#{name}_content_type"
|
||||
base.property :"#{name}_size"
|
||||
|
||||
# Resolve the attachment filename
|
||||
base.define_method(:"#{name}_filename") do
|
||||
template = options[:filename] || name.to_s
|
||||
if template.include?('{')
|
||||
_resolve_filename_template(template)
|
||||
else
|
||||
template
|
||||
end
|
||||
end
|
||||
|
||||
# Getter — fetches from CouchDB on first access
|
||||
base.define_method(name) do
|
||||
cache_key = :"@_couch_attachment_#{name}"
|
||||
return instance_variable_get(cache_key) if instance_variable_defined?(cache_key)
|
||||
|
||||
filename = public_send(:"#{name}_filename")
|
||||
data = fetch_couch_attachment(filename)
|
||||
instance_variable_set(cache_key, data)
|
||||
data
|
||||
end
|
||||
|
||||
# Setter — queues attachment for save
|
||||
base.define_method(:"#{name}=") do |value|
|
||||
cache_key = :"@_couch_attachment_#{name}"
|
||||
instance_variable_set(cache_key, value)
|
||||
|
||||
# Detect content type
|
||||
content_type = options[:content_type] || _detect_content_type(value)
|
||||
public_send(:"#{name}_content_type=", content_type) if value
|
||||
|
||||
# Track size
|
||||
size = value.respond_to?(:bytesize) ? value.bytesize : value.to_s.bytesize
|
||||
public_send(:"#{name}_size=", size) if value
|
||||
|
||||
# Queue for save
|
||||
@_pending_couch_attachments ||= {}
|
||||
@_pending_couch_attachments[name] = {
|
||||
filename: public_send(:"#{name}_filename"),
|
||||
file: value,
|
||||
content_type: content_type
|
||||
}
|
||||
value
|
||||
end
|
||||
|
||||
# URL getter
|
||||
base.define_method(:"#{name}_url") do
|
||||
db_url = _couchrest_database.root
|
||||
doc_id = _id
|
||||
filename = public_send(:"#{name}_filename")
|
||||
"#{db_url}/#{doc_id}/#{filename}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def _save_pending_couch_attachments
|
||||
return unless @_pending_couch_attachments&.any?
|
||||
|
||||
@_pending_couch_attachments.each do |_name, opts|
|
||||
put_couch_attachment(opts[:filename], opts[:file], content_type: opts[:content_type])
|
||||
end
|
||||
@_pending_couch_attachments = nil
|
||||
end
|
||||
|
||||
def _detect_content_type(data)
|
||||
return nil unless data.is_a?(String) || data.respond_to?(:read)
|
||||
# Try to detect from magic bytes using the `marcel` gem if available,
|
||||
# otherwise fall back to octet-stream.
|
||||
if defined?(Marcel)
|
||||
content = data.respond_to?(:read) ? data.read : data
|
||||
Marcel::MimeType.for(StringIO.new(content.to_s))
|
||||
else
|
||||
'binary/octet-stream'
|
||||
end
|
||||
rescue StandardError
|
||||
'binary/octet-stream'
|
||||
end
|
||||
|
||||
def _resolve_filename_template(template)
|
||||
template.gsub(/\{(\w+)(?:\|(\w+))?\}/) do
|
||||
method = Regexp.last_match(1)
|
||||
format = Regexp.last_match(2)
|
||||
value = respond_to?(method) ? send(method) : nil
|
||||
if format && value.respond_to?(:"to_#{format}")
|
||||
value.send(:"to_#{format}")
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _couchrest_database
|
||||
if respond_to?(:database) && database.respond_to?(:couchrest_database)
|
||||
database.couchrest_database
|
||||
else
|
||||
self.class.database.couchrest_database
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Attachment
|
||||
# Drop-in replacement for Paperclip in SimplyBased/CouchDB models.
|
||||
#
|
||||
# Usage:
|
||||
# class Image
|
||||
# include SimplyBased::Model
|
||||
# has_local_attached :file, styles: {
|
||||
# medium: "354x1000>",
|
||||
# thumb: "160x1250>"
|
||||
# }
|
||||
# end
|
||||
#
|
||||
# Auto-declares CouchDB properties:
|
||||
# <name>_file_name, <name>_content_type, <name>_file_size, <name>_updated_at
|
||||
#
|
||||
# Files stored at: public/system/<attachment>/<id>/original.<ext>
|
||||
# Uses MiniMagick (ImageMagick) for thumbnail generation.
|
||||
module Local
|
||||
# Proxy object returned by attachment getters (e.g. `image.file`).
|
||||
# Mimics the Paperclip::Attachment API that views expect.
|
||||
class Proxy
|
||||
def initialize(record, name)
|
||||
@record = record
|
||||
@name = name
|
||||
end
|
||||
|
||||
def present?
|
||||
@record.public_send(:"#{@name}_file_name").present?
|
||||
end
|
||||
|
||||
def blank?
|
||||
!present?
|
||||
end
|
||||
|
||||
def file?
|
||||
return false unless present?
|
||||
File.exist?(path.to_s)
|
||||
end
|
||||
alias exist? file?
|
||||
|
||||
def url(style = nil)
|
||||
@record.public_send(:"#{@name}_url", style)
|
||||
end
|
||||
|
||||
def path(style = nil)
|
||||
@record.public_send(:"#{@name}_path", style)
|
||||
end
|
||||
|
||||
def original_filename
|
||||
@record.public_send(:"#{@name}_file_name")
|
||||
end
|
||||
|
||||
def content_type
|
||||
@record.send(:"#{@name}_content_type")
|
||||
end
|
||||
|
||||
def size
|
||||
@record.send(:"#{@name}_file_size")
|
||||
end
|
||||
|
||||
def method_missing(method, *args, &block)
|
||||
if @record.respond_to?(method, true)
|
||||
@record.send(method, *args, &block)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def respond_to_missing?(method, include_private = false)
|
||||
@record.respond_to?(method, include_private) || super
|
||||
end
|
||||
end
|
||||
|
||||
def self.define_local_attached(base, name, styles: {}, default_url: nil, default_style: :original, content_type: nil)
|
||||
# Auto-declare CouchDB properties for attachment metadata
|
||||
base.property :"#{name}_file_name"
|
||||
base.property :"#{name}_content_type"
|
||||
base.property :"#{name}_file_size", type: Integer
|
||||
base.property :"#{name}_updated_at", type: Time
|
||||
|
||||
# Auto-validate content type (replaces Paperclip validates_attachment_content_type)
|
||||
if content_type
|
||||
base.validate :"#{name}_content_type_must_be_allowed"
|
||||
base.define_method(:"#{name}_content_type_must_be_allowed") do
|
||||
fname = send(:"#{name}_file_name")
|
||||
ctype = send(:"#{name}_content_type")
|
||||
return if fname.blank? || ctype.blank?
|
||||
allowed = content_type.is_a?(Array) ? content_type : [content_type]
|
||||
unless allowed.any? { |t| ctype&.start_with?(t) || ctype == t }
|
||||
errors.add(name, "must be one of: #{allowed.join(', ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Move files from tmp/ to real ID directory after first save
|
||||
base.after_save :"_move_attachment_#{name}_from_tmp"
|
||||
base.define_method(:"_move_attachment_#{name}_from_tmp") do
|
||||
token = instance_variable_get(:"@_attachment_#{name}_tmp_token")
|
||||
return unless token
|
||||
instance_variable_set(:"@_attachment_#{name}_tmp_token", nil)
|
||||
|
||||
tmp_dir = Rails.root.join('public', 'system', name.to_s, token)
|
||||
next unless File.directory?(tmp_dir)
|
||||
|
||||
real_dir = Rails.root.join('public', 'system', name.to_s, _id)
|
||||
if File.directory?(real_dir)
|
||||
Dir.glob(File.join(tmp_dir, '*')).each { |f| FileUtils.mv(f, File.join(real_dir, File.basename(f))) }
|
||||
FileUtils.rmdir(tmp_dir) rescue nil
|
||||
else
|
||||
FileUtils.mv(tmp_dir.to_s, real_dir.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
# Register configuration
|
||||
base.attachment_registry[name] = {
|
||||
styles: styles,
|
||||
default_url: default_url,
|
||||
default_style: default_style
|
||||
}
|
||||
|
||||
# ---- Setter: model.file = uploaded_file ----
|
||||
base.define_method(:"#{name}=") do |uploaded|
|
||||
if uploaded.nil? || (uploaded.respond_to?(:empty?) && uploaded.empty?)
|
||||
send(:"#{name}_file_name=", nil)
|
||||
send(:"#{name}_content_type=", nil)
|
||||
send(:"#{name}_file_size=", nil)
|
||||
send(:"#{name}_updated_at=", nil)
|
||||
return
|
||||
end
|
||||
|
||||
config = self.class.attachment_registry[name]
|
||||
|
||||
original_filename = if uploaded.respond_to?(:original_filename)
|
||||
uploaded.original_filename
|
||||
elsif uploaded.respond_to?(:path)
|
||||
File.basename(uploaded.path)
|
||||
else
|
||||
'file'
|
||||
end
|
||||
ext = File.extname(original_filename)
|
||||
ext = '.bin' if ext.blank?
|
||||
|
||||
# Invalidate memoized proxy
|
||||
remove_instance_variable(:"@_attachment_proxy_#{name}") if instance_variable_defined?(:"@_attachment_proxy_#{name}")
|
||||
|
||||
content_type = uploaded.respond_to?(:content_type) ? uploaded.content_type : nil
|
||||
file_size = uploaded.respond_to?(:size) ? uploaded.size : nil
|
||||
|
||||
content = if uploaded.respond_to?(:read)
|
||||
data = uploaded.read
|
||||
uploaded.rewind if uploaded.respond_to?(:rewind)
|
||||
data
|
||||
elsif uploaded.respond_to?(:path) && File.exist?(uploaded.path)
|
||||
File.binread(uploaded.path)
|
||||
elsif uploaded.respond_to?(:tempfile)
|
||||
uploaded.tempfile.read
|
||||
else
|
||||
uploaded.to_s
|
||||
end
|
||||
|
||||
record_id = if respond_to?(:id) && id.present?
|
||||
id.to_s
|
||||
else
|
||||
token = SecureRandom.urlsafe_base64(8)
|
||||
instance_variable_set(:"@_attachment_#{name}_tmp_token", token)
|
||||
token
|
||||
end
|
||||
base_dir = Rails.root.join('public', 'system', name.to_s, record_id)
|
||||
FileUtils.mkdir_p(base_dir)
|
||||
|
||||
begin
|
||||
original_path = base_dir.join("original#{ext}")
|
||||
File.binwrite(original_path, content)
|
||||
|
||||
config[:styles].each do |style_name, geometry|
|
||||
style_path = base_dir.join("#{style_name}#{ext}")
|
||||
begin
|
||||
image = MiniMagick::Image.open(original_path.to_s)
|
||||
image.resize(geometry)
|
||||
image.write(style_path.to_s)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[HasAttachment] Could not generate #{style_name} for #{name}: #{e.message}")
|
||||
FileUtils.cp(original_path, style_path)
|
||||
end
|
||||
end
|
||||
|
||||
send(:"#{name}_file_name=", original_filename)
|
||||
send(:"#{name}_content_type=", content_type)
|
||||
send(:"#{name}_file_size=", file_size || content.bytesize)
|
||||
send(:"#{name}_updated_at=", Time.current)
|
||||
rescue StandardError => e
|
||||
errors.add(name, "could not be processed: #{e.message}")
|
||||
Rails.logger.error("[HasAttachment] Error processing #{name}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
# ---- Getter: model.file → Proxy (memoized) ----
|
||||
base.define_method(name) do
|
||||
cache_key = :"@_attachment_proxy_#{name}"
|
||||
instance_variable_get(cache_key) || instance_variable_set(cache_key, Proxy.new(self, name))
|
||||
end
|
||||
|
||||
# ---- Presence check: model.file? ----
|
||||
base.define_method(:"#{name}?") do
|
||||
public_send(name)&.exist?
|
||||
end
|
||||
|
||||
# ---- URL helper: model.file_url(:thumb) ----
|
||||
base.define_method(:"#{name}_url") do |style_name = nil|
|
||||
config = self.class.attachment_registry[name]
|
||||
style_name ||= config[:default_style]
|
||||
|
||||
fname = send(:"#{name}_file_name")
|
||||
if fname.blank?
|
||||
return config[:default_url] if config[:default_url]
|
||||
return nil
|
||||
end
|
||||
|
||||
ext = File.extname(fname)
|
||||
record_id = if respond_to?(:id) && id.present?
|
||||
id.to_s
|
||||
else
|
||||
instance_variable_get(:"@_attachment_#{name}_tmp_token") || 'tmp'
|
||||
end
|
||||
"/system/#{name}/#{record_id}/#{style_name}#{ext}"
|
||||
end
|
||||
|
||||
# ---- Backward compat: *_path derives from *_url ----
|
||||
base.define_method(:"#{name}_path") do |style_name = nil|
|
||||
url = send(:"#{name}_url", style_name)
|
||||
return nil if url.nil?
|
||||
root = defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
|
||||
root.join('public', url.sub(%r{^/}, '')).to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Backward compat: old code calls has_attachment directly.
|
||||
# Delegate to define_local_attached.
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def has_attachment(name, styles: {}, default_url: nil, default_style: :original)
|
||||
Local.define_local_attached(self, name, styles: styles, default_url: default_url, default_style: default_style)
|
||||
end
|
||||
|
||||
def attachment_registry
|
||||
@_has_attachment_registry ||= {}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,202 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Attachment
|
||||
# S3 attachment strategy — uses aws-sdk-s3.
|
||||
#
|
||||
# Lazy-loaded via has_s3_attached / has_s3_attachment macro on SimplyBased::Model.
|
||||
#
|
||||
# Config (per environment):
|
||||
# SimplyBased.s3_defaults = { bucket: 'myapp', access_key: '...', ... }
|
||||
# SimplyBased.load_s3_config(Rails.root.join('config', 's3.yml'))
|
||||
#
|
||||
# Usage:
|
||||
# class Report
|
||||
# include SimplyBased::Model
|
||||
# has_s3_attached :pdf
|
||||
# end
|
||||
#
|
||||
# report.pdf = File.read('monthly.pdf')
|
||||
# report.save
|
||||
# report.pdf_url # presigned S3 URL
|
||||
#
|
||||
module S3
|
||||
module InstanceMethods
|
||||
def _s3_options
|
||||
self.class._s3_options
|
||||
end
|
||||
|
||||
def s3_client
|
||||
@_s3_client ||= begin
|
||||
creds = _s3_options.values.first
|
||||
Aws::S3::Client.new(
|
||||
access_key_id: creds[:access_key],
|
||||
secret_access_key: creds[:secret_access_key],
|
||||
region: _s3_region(creds),
|
||||
logger: creds[:logger]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def s3_bucket(name)
|
||||
return @_s3_bucket if @_s3_bucket
|
||||
opts = _s3_options[name]
|
||||
begin
|
||||
s3_client.head_bucket(bucket: opts[:bucket])
|
||||
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchBucket
|
||||
s3_client.create_bucket(
|
||||
bucket: opts[:bucket],
|
||||
create_bucket_configuration: _s3_region(opts) == 'us-east-1' ? nil : { location_constraint: _s3_region(opts) }
|
||||
)
|
||||
rescue Aws::S3::Errors::Forbidden, Aws::S3::Errors::BucketAlreadyOwnedByYou
|
||||
# bucket exists but we can't check — proceed
|
||||
end
|
||||
@_s3_bucket = opts[:bucket]
|
||||
rescue StandardError => e
|
||||
raise ArgumentError, "Could not access/create S3 bucket '#{name}': #{e}"
|
||||
end
|
||||
|
||||
def save(validate = true)
|
||||
update_attachment_sizes
|
||||
if ret = super(validate)
|
||||
save_attachments
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
def save!(*args)
|
||||
update_attachment_sizes
|
||||
super
|
||||
save_attachments
|
||||
end
|
||||
|
||||
def delete(*args)
|
||||
delete_attachments
|
||||
super
|
||||
end
|
||||
|
||||
def destroy(*args)
|
||||
delete_attachments
|
||||
super
|
||||
end
|
||||
|
||||
def save_attachments
|
||||
return unless id.present?
|
||||
if @_s3_attachments
|
||||
@_s3_attachments.each do |name, attachment|
|
||||
if attachment[:dirty]
|
||||
value = attachment[:value].is_a?(String) ? attachment[:value] : attachment[:value].to_json
|
||||
s3_bucket(name)
|
||||
s3_client.put_object(
|
||||
bucket: _s3_options[name][:bucket],
|
||||
key: s3_attachment_key(name),
|
||||
body: value,
|
||||
acl: _s3_options[name][:permissions]
|
||||
)
|
||||
attachment[:dirty] = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_attachments
|
||||
return unless id.present?
|
||||
(@_s3_attachments || {}).each do |name, attachment|
|
||||
if _s3_options[name][:after_delete] == :delete
|
||||
s3_client.delete_object(
|
||||
bucket: _s3_options[name][:bucket],
|
||||
key: s3_attachment_key(name)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_attachment_sizes
|
||||
if @_s3_attachments
|
||||
@_s3_attachments.each do |name, attachment|
|
||||
if attachment[:dirty]
|
||||
value = attachment[:value].is_a?(String) ? attachment[:value] : attachment[:value].to_json
|
||||
public_send("#{name}_size=", (value.size rescue nil))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def s3_attachment_key(name)
|
||||
"#{self.class.name.tableize}/#{name}/#{id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def _s3_region(opts)
|
||||
case opts[:location]
|
||||
when :eu then 'eu-west-1'
|
||||
when :us, nil then 'us-east-1'
|
||||
else opts[:location].to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.define_s3_attached(base, name, options = {})
|
||||
require 'aws-sdk-s3'
|
||||
|
||||
name = name.to_sym
|
||||
base.singleton_class.class_eval { attr_accessor :_s3_options }
|
||||
|
||||
# Merge: model options > global s3_defaults > gem defaults
|
||||
base.property :"#{name}_size" if base.respond_to?(:property)
|
||||
|
||||
defaults = (SimplyBased.s3_defaults || {}).merge(options)
|
||||
raise ArgumentError, "No bucket name specified for attachment #{name}" if defaults[:bucket].blank?
|
||||
defaults = {
|
||||
permissions: 'private',
|
||||
ssl: true,
|
||||
location: :us,
|
||||
ca_file: nil,
|
||||
after_delete: :nothing,
|
||||
logger: nil
|
||||
}.update(defaults)
|
||||
base._s3_options ||= {}
|
||||
base._s3_options[name] = defaults
|
||||
|
||||
_define_s3_accessors(base, name)
|
||||
base.attr_reader :_s3_attachments
|
||||
base.include InstanceMethods
|
||||
end
|
||||
|
||||
def self._define_s3_accessors(base, name)
|
||||
base.define_method(name) do
|
||||
unless @_s3_attachments && @_s3_attachments[name]
|
||||
@_s3_attachments = { name => {} }
|
||||
resp = s3_client.get_object(
|
||||
bucket: _s3_options[name][:bucket],
|
||||
key: s3_attachment_key(name)
|
||||
)
|
||||
@_s3_attachments[name][:value] = resp.body.read
|
||||
end
|
||||
@_s3_attachments[name][:value]
|
||||
end
|
||||
|
||||
base.define_method("#{name}=") do |value|
|
||||
@_s3_attachments ||= {}
|
||||
@_s3_attachments[name] ||= {}
|
||||
@_s3_attachments[name].update(value: value, dirty: true)
|
||||
value
|
||||
end
|
||||
|
||||
base.define_method("#{name}_url") do
|
||||
opts = _s3_options[name]
|
||||
signer = Aws::S3::Presigner.new(client: s3_client)
|
||||
signer.presigned_url(
|
||||
:get_object,
|
||||
bucket: opts[:bucket],
|
||||
key: s3_attachment_key(name),
|
||||
expires_in: opts[:permissions] == 'private' ? 300 : 3600
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# CouchDB inline attachment support.
|
||||
# Adds put_attachment, fetch_attachment, delete_attachment, and attachment_names
|
||||
# to any SimplyBased model.
|
||||
#
|
||||
# Usage:
|
||||
# class Invoice
|
||||
# include SimplyBased::Model
|
||||
# include SimplyBased::Model::Attachments
|
||||
# end
|
||||
#
|
||||
# invoice.put_attachment('invoice.pdf', file, content_type: 'application/pdf')
|
||||
# invoice.fetch_attachment('invoice.pdf') # => file data
|
||||
# invoice.delete_attachment('invoice.pdf')
|
||||
# invoice.attachment_names # => ['invoice.pdf', 'logo.png']
|
||||
#
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Attachments
|
||||
def self.included(base)
|
||||
base.after_save :_save_pending_attachments
|
||||
end
|
||||
|
||||
# Upload a file as a CouchDB inline attachment on this document.
|
||||
# The attachment is stored immediately — no need to call save separately.
|
||||
# Returns the CouchDB result hash.
|
||||
def put_attachment(name, file, content_type: 'binary/octet-stream')
|
||||
result = _couchrest_database.put_attachment(
|
||||
to_hash, name, file, content_type: content_type
|
||||
)
|
||||
self._rev = result['rev'] if result['ok']
|
||||
result
|
||||
end
|
||||
|
||||
# Fetch an attachment's binary data from CouchDB.
|
||||
# Returns nil if the attachment doesn't exist.
|
||||
def fetch_attachment(name)
|
||||
_couchrest_database.fetch_attachment(to_hash, name)
|
||||
rescue RestClient::ResourceNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
# Queue an attachment for upload on next save.
|
||||
# Useful when building a new document that hasn't been saved yet
|
||||
# (no _rev available for immediate put_attachment).
|
||||
def add_attachment(name, file, content_type: 'binary/octet-stream')
|
||||
@_pending_attachments ||= {}
|
||||
@_pending_attachments[name] = { file: file, content_type: content_type }
|
||||
end
|
||||
|
||||
# Delete an attachment from this document.
|
||||
# The attachment is removed immediately — no need to call save separately.
|
||||
def delete_attachment(name)
|
||||
result = _couchrest_database.delete_attachment(to_hash, name)
|
||||
self._rev = result['rev'] if result['ok']
|
||||
result
|
||||
end
|
||||
|
||||
# List all attachment names on this document.
|
||||
def attachment_names
|
||||
(_attachments || {}).keys
|
||||
end
|
||||
|
||||
# Check if an attachment exists.
|
||||
def attachment?(name)
|
||||
attachment_names.include?(name.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def _save_pending_attachments
|
||||
return unless @_pending_attachments&.any?
|
||||
|
||||
@_pending_attachments.each do |name, opts|
|
||||
put_attachment(name, opts[:file], content_type: opts[:content_type])
|
||||
end
|
||||
@_pending_attachments = nil
|
||||
end
|
||||
|
||||
def _couchrest_database
|
||||
if respond_to?(:database) && database.respond_to?(:couchrest_database)
|
||||
database.couchrest_database
|
||||
else
|
||||
self.class.database.couchrest_database
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
# Gracefully taken from CouchPotato after it has been removed for good.
|
||||
module SimplyBased
|
||||
module Model
|
||||
module BelongsTo
|
||||
|
||||
def belongs_to(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::BelongsTo::Property)
|
||||
SimplyBased.assert_safe_view_token!(self.to_s, name, soft_delete_attribute)
|
||||
association_property = if name.to_s.index('__')
|
||||
# Already defined properly
|
||||
name
|
||||
elsif options[:class_name].present?
|
||||
# Determine namespace and replace last argument with given name
|
||||
name_hierarchy = options[:class_name].to_s.underscore.split(/\/|::/)
|
||||
name_hierarchy[-1] = name
|
||||
name_hierarchy.join('__')
|
||||
elsif rindex = foreign_property.to_s.rindex('__')
|
||||
# Make name based on current namespace
|
||||
"#{foreign_property[0...rindex]}__#{name}"
|
||||
else
|
||||
# Just return the good old name
|
||||
name
|
||||
end
|
||||
|
||||
map_definition_without_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name}_id'] != null) {
|
||||
if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){
|
||||
// "soft" deleted
|
||||
}else{
|
||||
emit([doc.#{name}_id, doc.created_at], 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
reduce_definition = "_sum"
|
||||
view "association_#{foreign_property}_belongs_to_#{association_property}",
|
||||
:map_function => map_definition_without_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
|
||||
map_definition_with_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name}_id'] != null) {
|
||||
emit([doc.#{name}_id, doc.created_at], 1);
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
view "association_#{foreign_property}_belongs_to_#{association_property}_with_deleted",
|
||||
:map_function => map_definition_with_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
|
||||
properties << SimplyBased::Model::BelongsTo::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
class Property #:nodoc:
|
||||
attr_accessor :name, :options
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
@name = name
|
||||
@options = {
|
||||
:class_name => owner_clazz.find_association_class_name(name)
|
||||
}.update(options)
|
||||
|
||||
@options.assert_valid_keys(:class_name)
|
||||
|
||||
owner_clazz.class_eval do
|
||||
property :"#{name}_id"
|
||||
alias_method :"#{name}_changed?", :"#{name}_id_changed?"
|
||||
|
||||
define_method name do |*args|
|
||||
local_options = args.last.is_a?(Hash) ? args.last : {}
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted)
|
||||
forced_reload = local_options[:force_reload] || false
|
||||
with_deleted = local_options[:with_deleted] || false
|
||||
|
||||
return instance_variable_get("@#{name}") unless instance_variable_get("@#{name}").nil? or forced_reload
|
||||
|
||||
if send("#{name}_id").present?
|
||||
# Try to fetch the object. Does not have to be present. When relation dependency is ignore, the id remains.
|
||||
# This will result in a id to a non existent object. Therefore the rescue for object
|
||||
object = self.class.get_class_from_name(name).find(send("#{name}_id"), :with_deleted => with_deleted) rescue nil
|
||||
instance_variable_set("@#{name}", object)
|
||||
else
|
||||
instance_variable_set("@#{name}", nil)
|
||||
end
|
||||
end
|
||||
|
||||
define_method "#{name}=" do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass)
|
||||
|
||||
if value
|
||||
# Has many object update
|
||||
value_has_many_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasMany::Property) && p.options[:class_name] == self.class.name}.try(:name)
|
||||
value.send(value_has_many_name) << self unless !value_has_many_name || value.send(value_has_many_name).include?(self)
|
||||
|
||||
# Has one object update
|
||||
value_has_one_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasOne::Property) && p.options[:class_name] == self.class.name}.try(:name)
|
||||
value.instance_variable_set("@#{value_has_one_name}", self) unless !value_has_one_name || value.send(value_has_one_name) == self
|
||||
end
|
||||
|
||||
# Mark changed if appropriate
|
||||
# send("#{name}_will_change!") if value != instance_variable_get("@#{name}") is not a persisted property, do not mark as changed
|
||||
|
||||
instance_variable_set("@#{name}", value)
|
||||
if value.nil?
|
||||
send("#{name}_id=", nil)
|
||||
else
|
||||
send("#{name}_id=", value.id)
|
||||
end
|
||||
end
|
||||
|
||||
define_method "#{name}_id=" do |new_foreign_id|
|
||||
super(new_foreign_id)
|
||||
value = instance_variable_get("@#{name}")
|
||||
remove_instance_variable("@#{name}") if instance_variable_defined?("@#{name}") && new_foreign_id != value.try(:id)
|
||||
end
|
||||
end
|
||||
end
|
||||
def build(object, json)
|
||||
object.send "#{name}_id=", json["#{name}_id"]
|
||||
end
|
||||
|
||||
def serialize(json, object)
|
||||
json["#{name}_id"] = object.send("#{name}_id") if object.send("#{name}_id")
|
||||
end
|
||||
alias :value :serialize
|
||||
|
||||
def association?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
# Bulk write helpers backed by CouchDB's `_bulk_docs` endpoint.
|
||||
#
|
||||
# DRAFT / UNVERIFIED: this code has not yet been exercised against a live
|
||||
# CouchDB instance. Review before relying on it.
|
||||
#
|
||||
# Persisting or deleting many documents one at a time is one HTTP round-trip
|
||||
# per document. `_bulk_docs` does it in a single request. These helpers map
|
||||
# onto that endpoint via CouchRest::Database#bulk_save.
|
||||
#
|
||||
# IMPORTANT — like ActiveRecord's `insert_all` / `upsert_all`, the bulk path
|
||||
# intentionally **skips model callbacks** (before/after save/create/update).
|
||||
# Timestamps are still maintained directly. If you need callbacks, save the
|
||||
# records individually. Validations run by default and can be disabled with
|
||||
# `validate: false`.
|
||||
module Bulk
|
||||
# Persist an array of records in a single request.
|
||||
# Returns a Hash: { saved: [records], invalid: [records], failed: [[record, error]] }
|
||||
def save_all(records, validate: true)
|
||||
database.bulk_save(Array(records), validate)
|
||||
end
|
||||
|
||||
# Build records from an array of attribute hashes and persist them in bulk.
|
||||
def create_all(attribute_sets, validate: true)
|
||||
save_all(Array(attribute_sets).map { |attrs| new(attrs) }, validate: validate)
|
||||
end
|
||||
|
||||
# Delete an array of persisted records in a single request.
|
||||
# Returns the raw `_bulk_docs` result rows.
|
||||
def destroy_all(records)
|
||||
database.bulk_destroy(Array(records))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Database
|
||||
def database
|
||||
@_simply_based_database ||= begin
|
||||
if @_database_url
|
||||
SimplyBased.database_for(@_database_url)
|
||||
else
|
||||
SimplyBased.database
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Point this model at a specific Couchbase connection URL.
|
||||
# use_database "couchbase://user:pass@host/bucket?scope=s&collection=c"
|
||||
def use_database(url)
|
||||
self.database_url = url
|
||||
end
|
||||
|
||||
def database_url
|
||||
@_database_url
|
||||
end
|
||||
|
||||
def database_url=(url)
|
||||
@_database_url = url
|
||||
@_simply_based_database = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
# Gracefully taken from CouchPotato after it has been removed for good.
|
||||
module SimplyBased
|
||||
module Model
|
||||
module EmbeddedIn
|
||||
|
||||
def is_embedded_in(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::BelongsTo::Property)
|
||||
parent = options[:class_name] || name.to_s.camelize
|
||||
SimplyBased.assert_safe_view_token!(self.to_s, name, parent, soft_delete_attribute)
|
||||
self.name.property_name.pluralize
|
||||
|
||||
soft_delete_check = if soft_delete_attribute
|
||||
%{if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){ return; }}
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
map_definition_without_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{parent}') {
|
||||
#{soft_delete_check}
|
||||
emit([doc.#{name.to_s}_id, doc.created_at], 1);
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
reduce_definition = "_sum"
|
||||
|
||||
view "association_#{self.name.underscore.gsub('/', '__')}_embedded_in_#{name}",
|
||||
:map_function => map_definition_without_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
|
||||
map_definition_with_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{self.to_s}' && doc['#{name.to_s}_id'] != null) {
|
||||
emit([doc.#{name.to_s}_id, doc.created_at], 1);
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
view "association_#{self.name.underscore.gsub('/', '__')}_embedded_in_#{name}_with_deleted",
|
||||
:map_function => map_definition_with_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
|
||||
properties << SimplyBased::Model::EmbeddedIn::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
class Property #:nodoc:
|
||||
attr_accessor :name, :options
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
@name = name
|
||||
embedded_in_name = name
|
||||
@options = {
|
||||
:class_name => name.to_s.singularize.camelize
|
||||
}.update(options)
|
||||
|
||||
@options.assert_valid_keys(:class_name)
|
||||
|
||||
# For now restrictions on naming
|
||||
parent_property_name = owner_clazz.name.property_name.pluralize
|
||||
|
||||
owner_clazz.class_eval do
|
||||
property :"#{name}_id"
|
||||
attr_accessor :parent_object
|
||||
property :index
|
||||
@@embedded_in_class_name = name.to_s.camelize
|
||||
|
||||
class << self
|
||||
|
||||
define_method :embedded_in_class_name do
|
||||
# embedded_in_name.to_s.singularize.camelize
|
||||
@@embedded_in_class_name
|
||||
end
|
||||
|
||||
define_method :belongs_to do |belongs_to_name, *args|
|
||||
super(*([belongs_to_name] + args))
|
||||
# Now override belongs to view
|
||||
view "association_#{foreign_property}_belongs_to_#{belongs_to_name}",
|
||||
:map_function => %|function(doc){if(doc['ruby_class'] == '#{embedded_in_class_name}' && doc['#{self.name.property_name.pluralize}']){
|
||||
for(var i in doc.#{self.name.property_name.pluralize}){
|
||||
if(doc['#{self.name.property_name.pluralize}'][i]['#{belongs_to_name.to_s.foreign_key}']){
|
||||
emit([doc['#{self.name.property_name.pluralize}'][i]['#{belongs_to_name.to_s.foreign_key}'], doc['created_at']], doc['#{self.name.property_name.pluralize}'][i]);
|
||||
}
|
||||
}
|
||||
}}|,
|
||||
:reduce_function => %|function(key, values){return values.length}|,
|
||||
:type => :raw,
|
||||
:results_filter => lambda{|results| results['rows'].map{|row| d = row['value']; d.parent_object = row['doc']; d.parent_object.send(self.name.property_name.pluralize)[d.index]}},
|
||||
:include_docs => true
|
||||
end
|
||||
|
||||
define_method :count do |options = {}|
|
||||
database.view(all_documents_for_count(options.merge(:reduce => true)))['rows'].try(:first).try('[]', 'value').to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Make parent object send through original for callbacks
|
||||
define_method :parent_object= do |value|
|
||||
return @parent_object if @parent_object && @parent_object == value
|
||||
@parent_object = value # Prevent circular calls
|
||||
send("#{name}=", value)
|
||||
end
|
||||
# Redefine the equality method, since we are different kind of objects
|
||||
define_method "==" do |value|
|
||||
self.class == value.class && (value.respond_to?(:parent_object) && self.parent_object == value.parent_object) && (value.respond_to?(:index) && self.index == value.index)
|
||||
end
|
||||
view :all_documents_for_count, :type => :raw, :include_docs => false, :map_function => %|function(doc){
|
||||
if(doc['ruby_class'] == '#{name.to_s.singularize.camelize}' && typeof(doc['#{parent_property_name}']) == 'object'){
|
||||
for(var i=0; i < doc['#{parent_property_name}'].length; i++){
|
||||
emit(doc['#{parent_property_name}'][i]['created_at'], 1);
|
||||
}
|
||||
}
|
||||
}|, :reduce_function => '_sum'
|
||||
view :all_documents, :type => :raw, :include_docs => true, :map_function => %|function(doc){
|
||||
if(doc['ruby_class'] == '#{name.to_s.singularize.camelize}' && typeof(doc['#{parent_property_name}']) == 'object'){
|
||||
for(var i=0; i < doc['#{parent_property_name}'].length; i++){
|
||||
emit(doc['#{parent_property_name}'][i]['created_at'], doc['#{parent_property_name}'][i]);
|
||||
}
|
||||
}
|
||||
}|, :results_filter => lambda{|results| results['rows'].map{|row| d = row['value']; d.parent_object = row['doc']; d.parent_object.send(parent_property_name)[d.index]}}
|
||||
|
||||
|
||||
|
||||
# For now empty merge. Since value of map function is transformed to object
|
||||
define_method :merge do |*args|
|
||||
end
|
||||
|
||||
define_method :save do |callbacks=true|
|
||||
if !parent_object
|
||||
errors.add(name, 'no_parent')
|
||||
return false
|
||||
end
|
||||
if callbacks
|
||||
_run_save_callbacks do
|
||||
parent_object.save
|
||||
end
|
||||
else
|
||||
parent_object.save
|
||||
end
|
||||
end
|
||||
|
||||
define_method name do |*args|
|
||||
local_options = args.last.is_a?(Hash) ? args.last : {}
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted)
|
||||
forced_reload = local_options[:force_reload] || false
|
||||
with_deleted = local_options[:with_deleted] || false
|
||||
return parent_object
|
||||
end
|
||||
|
||||
define_method "#{name}=" do |value|
|
||||
return value if instance_variable_get("@#{name}") == value
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass)
|
||||
|
||||
if value
|
||||
# Has many object update
|
||||
value_has_many_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasManyEmbedded::Property) && p.options[:class_name] == self.class.name}.try(:name)
|
||||
value.send("add_#{value_has_many_name.to_s.singularize}", self) unless !value_has_many_name || value.send(value_has_many_name).include?(self)
|
||||
|
||||
# Has one object update
|
||||
#value_has_one_name = klass.properties.find{|p| p.is_a?(SimplyBased::Model::HasOneEmbedded::Property) && p.options[:class_name] == self.class.name}.try(:name)
|
||||
#value.instance_variable_set("@#{value_has_one_name}", self) unless !value_has_one_name || value.send(value_has_one_name) == self
|
||||
end
|
||||
|
||||
# Mark changed if appropriate
|
||||
attribute_will_change!("#{name}_id") if value != parent_object
|
||||
|
||||
instance_variable_set('@parent_object', value)
|
||||
if value.nil?
|
||||
send("#{name}_id=", nil)
|
||||
else
|
||||
send("#{name}_id=", value.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
def build(object, json)
|
||||
object.send "#{name}_id=", json["#{name}_id"]
|
||||
end
|
||||
|
||||
def serialize(json, object)
|
||||
json["#{name}_id"] = object.send("#{name}_id") if object.send("#{name}_id")
|
||||
end
|
||||
alias :value :serialize
|
||||
|
||||
def association?
|
||||
true
|
||||
end
|
||||
end # Property
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,211 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module FindBy
|
||||
include PaginationOptions
|
||||
def _define_find_by(name, *args)
|
||||
raise_when_not_found = name.to_s.end_with?('!')
|
||||
name = name.to_s.chop.to_sym if raise_when_not_found
|
||||
keys = name.to_s.sub(/^find_by_/, "").split("_and_").map(&:to_sym)
|
||||
|
||||
# replace asociation assignments with their property values if possible
|
||||
keys.each.with_index do |key, i|
|
||||
if properties.find{|p| p.name.to_sym == key.to_sym}.is_a?(SimplyBased::Model::BelongsTo::Property)
|
||||
keys[i] = "#{keys[i]}_id"
|
||||
end
|
||||
end
|
||||
|
||||
view_name = name.to_s.sub(/^find_/, "").to_sym
|
||||
view_keys = keys.length == 1 ? keys.first : keys
|
||||
without_deleted_view_name = "#{view_name}_withoutdeleted"
|
||||
without_deleted_view_keys = keys + [:deleted_at]
|
||||
|
||||
|
||||
unless respond_to?(view_name)
|
||||
puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(view_name, key: view_keys)
|
||||
end
|
||||
|
||||
if !respond_to?(without_deleted_view_name) && soft_deleting_enabled?
|
||||
puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(without_deleted_view_name, key: without_deleted_view_keys)
|
||||
end
|
||||
if raise_when_not_found
|
||||
(class << self; self end).instance_eval do
|
||||
define_method(:"#{name}!") do |*key_args|
|
||||
options = key_args.last.is_a?(Hash) ? key_args.pop : {}
|
||||
options.assert_valid_keys(:with_deleted)
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
|
||||
raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size
|
||||
|
||||
key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a}
|
||||
|
||||
if soft_deleting_enabled? && !with_deleted
|
||||
key_args = key_args + [nil] # deleted_at
|
||||
result = database.view(send(without_deleted_view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first
|
||||
else
|
||||
result = database.view(send(view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first
|
||||
end
|
||||
raise SimplyBased::RecordNotFound unless result
|
||||
result
|
||||
end
|
||||
end
|
||||
send(:"#{name}!", *args)
|
||||
else
|
||||
(class << self; self end).instance_eval do
|
||||
define_method(name) do |*key_args|
|
||||
options = key_args.last.is_a?(Hash) ? key_args.pop : {}
|
||||
options.assert_valid_keys(:with_deleted)
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
|
||||
raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size
|
||||
|
||||
key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a}
|
||||
|
||||
if soft_deleting_enabled? && !with_deleted
|
||||
key_args = key_args + [nil] # deleted_at
|
||||
database.view(send(without_deleted_view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first
|
||||
else
|
||||
database.view(send(view_name, key: (key_args.size == 1 ? key_args.first : key_args), limit: 1, include_docs: true)).first
|
||||
end
|
||||
end
|
||||
end
|
||||
send(name, *args)
|
||||
end
|
||||
end
|
||||
|
||||
def _define_find_all_by(name, *args)
|
||||
raise_when_not_found = name.to_s.end_with?('!')
|
||||
name = name.to_s.chop.to_sym if raise_when_not_found
|
||||
keys = name.to_s.sub(/^find_all_by_/, "").split("_and_")
|
||||
|
||||
# replace asociation assignments with their property values if possible
|
||||
keys.each.with_index do |key, i|
|
||||
if properties.find{|p| p.name.to_sym == key.to_sym}.is_a?(SimplyBased::Model::BelongsTo::Property)
|
||||
keys[i] = "#{keys[i]}_id"
|
||||
end
|
||||
end
|
||||
|
||||
view_name = name.to_s.sub(/^find_all_/, "").to_sym
|
||||
count_name = name.to_s.sub(/^find_all_/, 'count_').to_sym
|
||||
view_keys = keys.length == 1 ? keys.first : keys
|
||||
without_deleted_view_name = "#{view_name}_withoutdeleted"
|
||||
without_deleted_view_keys = keys + [:deleted_at]
|
||||
|
||||
unless respond_to?(view_name)
|
||||
puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(view_name, key: view_keys)
|
||||
end
|
||||
|
||||
if !respond_to?(without_deleted_view_name) && soft_deleting_enabled?
|
||||
puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(without_deleted_view_name, key: without_deleted_view_keys)
|
||||
end
|
||||
|
||||
# Pre-define the matching count_by_* method so the generated finder's
|
||||
# total_entries lookup (send(count_name, ...)) resolves to a real method
|
||||
# instead of re-entering method_missing on first use.
|
||||
# NOTE: respond_to? returns true via respond_to_missing? even before
|
||||
# the method is defined, so we must check method_defined? instead.
|
||||
_define_count_by(count_name, define_only: true) unless method_defined?(count_name) || private_method_defined?(count_name)
|
||||
|
||||
if raise_when_not_found
|
||||
(class << self; self end).instance_eval do
|
||||
define_method(:"#{name}!") do |*key_args|
|
||||
options = key_args.last.is_a?(Hash) ? key_args.pop : {}
|
||||
with_pagination_options(options.update(total_entries: send(count_name, *key_args))) do |options|
|
||||
options.assert_valid_keys(:with_deleted, :limit, :skip, :keys)
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
|
||||
key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a}
|
||||
options[:key] = key_args.first if key_args.size == 1
|
||||
options[:key] = key_args if key_args.size > 1
|
||||
options[:include_docs] = true
|
||||
|
||||
raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size || options[:keys]
|
||||
|
||||
key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a}
|
||||
|
||||
if soft_deleting_enabled? && !with_deleted
|
||||
options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at
|
||||
result = database.view(send(without_deleted_view_name, options))
|
||||
else
|
||||
result = database.view(send(view_name, options))
|
||||
end
|
||||
raise SimplyBased::RecordNotFound unless result && result.any?
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
send(:"#{name}!", *args)
|
||||
else
|
||||
(class << self; self end).instance_eval do
|
||||
define_method(name) do |*key_args|
|
||||
options = key_args.last.is_a?(Hash) ? key_args.pop : {}
|
||||
with_pagination_options(options.update(total_entries: send(count_name, *key_args))) do |options|
|
||||
options.assert_valid_keys(:with_deleted, :limit, :skip, :keys)
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
|
||||
key_args.map!{|a| a.is_a?(SimplyBased::Model) ? a.id : a}
|
||||
options[:key] = key_args.first if key_args.size == 1
|
||||
options[:key] = key_args if key_args.size > 1
|
||||
options[:include_docs] = true
|
||||
|
||||
raise ArgumentError, "Too many or too few arguments, require #{keys.inspect}" unless keys.size == key_args.size || options[:keys]
|
||||
|
||||
if soft_deleting_enabled? && !with_deleted
|
||||
options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at
|
||||
database.view(send(without_deleted_view_name, options))
|
||||
else
|
||||
database.view(send(view_name, options))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
send(name, *args)
|
||||
end
|
||||
end
|
||||
|
||||
def _define_count_by(name, *args, define_only: false)
|
||||
keys = name.to_s.sub(/^count_by_/, "").split("_and_")
|
||||
view_name = name.to_s.sub(/^count_/, "").to_sym
|
||||
view_keys = keys.length == 1 ? keys.first : keys
|
||||
without_deleted_view_name = "#{view_name}_withoutdeleted"
|
||||
without_deleted_view_keys = keys + [:deleted_at]
|
||||
|
||||
unless respond_to?(view_name)
|
||||
puts "Warning: Defining view #{self.name}##{view_name} with keys #{view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(view_name, key: view_keys)
|
||||
end
|
||||
|
||||
if !respond_to?(without_deleted_view_name) && soft_deleting_enabled?
|
||||
puts "Warning: Defining view #{self.name}##{without_deleted_view_name} with keys #{without_deleted_view_keys.inspect} at call time, please add it to the class body. (Called from #{caller[1]})"
|
||||
view(without_deleted_view_name, key: without_deleted_view_keys)
|
||||
end
|
||||
|
||||
(class << self; self end).instance_eval do
|
||||
define_method("#{name}") do |*key_args|
|
||||
options = key_args.last.is_a?(Hash) ? key_args.pop : {}
|
||||
options.assert_valid_keys(:with_deleted, :keys)
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
options[:key] = key_args.first if key_args.size == 1
|
||||
options[:key] = key_args if key_args.size > 1
|
||||
options[:reduce] = true
|
||||
|
||||
if soft_deleting_enabled? && !with_deleted
|
||||
options[:key] = Array.wrap(options[:key]) + [nil] # deleted_at
|
||||
database.view(send(without_deleted_view_name, options))
|
||||
else
|
||||
database.view(send(view_name, options))
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
# define_only is used when another finder (find_all_by) just needs the
|
||||
# method present; skip the immediate count query in that case.
|
||||
send(name, *args) unless define_only
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Finders
|
||||
include PaginationOptions
|
||||
def find(*args)
|
||||
what = args.shift
|
||||
options = args.last.is_a?(Hash) ? args.last : {}
|
||||
if options && order = options.delete(:order)
|
||||
options[:descending] = true if order == :desc
|
||||
end
|
||||
|
||||
with_deleted = options.delete(:with_deleted)
|
||||
|
||||
result = case what
|
||||
when :all
|
||||
if options.has_key?(:page)
|
||||
options[:total_entries] = count
|
||||
end
|
||||
if with_deleted || !soft_deleting_enabled?
|
||||
with_pagination_options(options) do |options|
|
||||
database.view(all_documents(options))
|
||||
end
|
||||
else
|
||||
with_pagination_options(options) do |options|
|
||||
database.view(all_documents_without_deleted(options.update(include_docs: true)))
|
||||
end
|
||||
end
|
||||
when :first
|
||||
if with_deleted || !soft_deleting_enabled?
|
||||
database.view(all_documents(options.update(limit: 1, include_docs: true))).first
|
||||
else
|
||||
database.view(all_documents_without_deleted(options.update(limit: 1, include_docs: true))).first
|
||||
end
|
||||
else
|
||||
raise SimplyBased::Error, "Can't load record without an id" if what.nil?
|
||||
document = database.load_document(what)
|
||||
if what.is_a?(Array) # Support for multiple find
|
||||
#TODO: extended validation and checking, for array arguments
|
||||
raise SimplyBased::NotImplementedError
|
||||
else
|
||||
# TODO, this part should be better.
|
||||
raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect}") unless document.present?
|
||||
raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect} — got #{document.class.name}") unless document.is_a?(self)
|
||||
if document.deleted? && !with_deleted
|
||||
raise(SimplyBased::RecordNotFound, "#{self.name} could not be found with #{what.inspect}")
|
||||
end
|
||||
end
|
||||
document
|
||||
end
|
||||
end
|
||||
|
||||
def all(*args)
|
||||
find(:all, *args)
|
||||
end
|
||||
|
||||
def first(*args)
|
||||
find(:first, *args)
|
||||
end
|
||||
|
||||
def last(*args)
|
||||
options = args.last.is_a?(Hash) ? args.last : {}
|
||||
find(:first, options.update(order: :desc))
|
||||
end
|
||||
|
||||
def count(options = {})
|
||||
options.assert_valid_keys(:with_deleted)
|
||||
with_deleted = options[:with_deleted]
|
||||
|
||||
if with_deleted || !soft_deleting_enabled?
|
||||
database.view(all_documents(reduce: true))
|
||||
else
|
||||
database.view(all_documents_without_deleted(reduce: true))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,241 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module HasAndBelongsToMany
|
||||
def has_and_belongs_to_many(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::HasAndBelongsToMany::Property)
|
||||
properties << SimplyBased::Model::HasAndBelongsToMany::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_property(foreign_key)
|
||||
property foreign_key
|
||||
|
||||
# Only allow non nil or empty values to be set
|
||||
define_method "#{foreign_key}=" do |value|
|
||||
super(value.is_a?(Array) ? value.select{|v| v.present?} : value)
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_views(name, options)
|
||||
SimplyBased.assert_safe_view_token!(self.name, name, options[:class_storing_keys], options[:foreign_key], soft_delete_attribute)
|
||||
key_order = options[:class_storing_keys] == self.name ? "doc.#{options[:foreign_key]}[index], doc._id" : "doc._id, doc.#{options[:foreign_key]}[index]"
|
||||
value = options[:class_storing_keys] == self.name ? 1 : "{ _id :doc.#{options[:foreign_key]}[index]}"
|
||||
association_property = if name.to_s.index('__')
|
||||
# Already defined properly
|
||||
name
|
||||
elsif options[:class_name].present?
|
||||
# Determine namespace and replace last argument with given name
|
||||
name_hierarchy = options[:class_name].to_s.underscore.split(/\/|::/)
|
||||
name_hierarchy[-1] = name
|
||||
name_hierarchy.join('__')
|
||||
elsif rindex = foreign_property.to_s.rindex('__')
|
||||
# Make name based on current namespace
|
||||
"#{foreign_property[0...rindex]}__#{name}"
|
||||
else
|
||||
# Just return the good old name
|
||||
name
|
||||
end
|
||||
|
||||
soft_delete_check = if soft_delete_attribute
|
||||
%{if (doc['#{soft_delete_attribute}'] && doc['#{soft_delete_attribute}'] != null){ return; }}
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
map_definition_without_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{options[:class_storing_keys]}' && doc['#{options[:foreign_key]}'] != null) {
|
||||
#{soft_delete_check}
|
||||
for (var index in doc.#{options[:foreign_key]}) {
|
||||
emit([#{key_order}], #{value});
|
||||
}
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
reduce_definition = options[:class_storing_keys] == self.name ? "_sum" : <<-eos
|
||||
function(key, values, rereduce) {
|
||||
if (rereduce) {
|
||||
return sum(values);
|
||||
} else {
|
||||
return values.length;
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
view "association_#{foreign_property}_has_and_belongs_to_many_#{association_property}",
|
||||
:map_function => map_definition_without_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
|
||||
map_definition_with_deleted = <<-eos
|
||||
function(doc) {
|
||||
if (doc['ruby_class'] == '#{options[:class_storing_keys]}' && doc['#{options[:foreign_key]}'] != null) {
|
||||
for (var index in doc.#{options[:foreign_key]}) {
|
||||
emit([#{key_order}], #{value});
|
||||
}
|
||||
}
|
||||
}
|
||||
eos
|
||||
|
||||
view "association_#{self.name.underscore.gsub('/', '__')}_has_and_belongs_to_many_#{name}_with_deleted",
|
||||
:map_function => map_definition_with_deleted,
|
||||
:reduce_function => reduce_definition,
|
||||
:type => :custom,
|
||||
:include_docs => true
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_getter(name, options)
|
||||
define_method(name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
forced_reload, with_deleted, limit, descending, skip = extract_association_options(local_options)
|
||||
|
||||
cached_results = send("_get_cached_#{name}")
|
||||
cache_key = _cache_key_for(local_options)
|
||||
return cached_results[cache_key] || [] unless persisted?
|
||||
if forced_reload || cached_results[cache_key].nil?
|
||||
cached_results[cache_key] =
|
||||
if options[:class_storing_keys] == self.class.name
|
||||
# This record holds the id array → resolve by KV multi-get.
|
||||
docs = self.class.database.bulk_load(Array(send(options[:foreign_key])))
|
||||
docs = docs.reject { |d| d.respond_to?(:deleted?) && d.deleted? } unless with_deleted
|
||||
docs = docs.reverse if descending
|
||||
docs = docs.first(limit) if limit
|
||||
docs
|
||||
else
|
||||
# The other class holds the array → N1QL ANY ... SATISFIES.
|
||||
find_associated_via_join_view(options[:class_name], self.class, :with_deleted => with_deleted, :limit => limit, :descending => descending, :foreign_key => options[:foreign_key], :skip => skip)
|
||||
end
|
||||
instance_variable_set("@#{name}", cached_results)
|
||||
end
|
||||
cached_results[cache_key]
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_setter_add(name, options)
|
||||
define_method("add_#{name.to_s.singularize}") do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass)
|
||||
|
||||
if options[:class_storing_keys] == self.class.name
|
||||
self.send("#{options[:foreign_key]}=", ((send(options[:foreign_key]) || []) + [value.id]).uniq )
|
||||
self.save(false)
|
||||
else
|
||||
value.send("#{options[:foreign_key]}=", ((value.send(options[:foreign_key]) || []) + [self.id]).uniq )
|
||||
value.save(false)
|
||||
end
|
||||
|
||||
cached_results = send("_get_cached_#{name}")[:all]
|
||||
send("_set_cached_#{name}", (cached_results || []) << value, :all)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_setter_remove(name, options)
|
||||
define_method "remove_#{name.to_s.singularize}" do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass)
|
||||
|
||||
if options[:class_storing_keys] == self.class.name
|
||||
raise ArgumentError, "cannot remove not mine" unless (send(options[:foreign_key]) || []).include?(value.id)
|
||||
else
|
||||
raise ArgumentError, "cannot remove not mine" unless (value.send(options[:foreign_key]) || []).include?(id)
|
||||
end
|
||||
|
||||
if options[:class_storing_keys] == self.class.name
|
||||
foreign_keys = (send(options[:foreign_key]) || []) - [value.id]
|
||||
send("#{options[:foreign_key]}=", foreign_keys)
|
||||
save(false)
|
||||
else
|
||||
foreign_keys = (value.send(options[:foreign_key]) || []) - [self.id]
|
||||
value.send("#{options[:foreign_key]}=", foreign_keys)
|
||||
value.save(false)
|
||||
end
|
||||
|
||||
cached_results = send("_get_cached_#{name}")[:all]
|
||||
send("_set_cached_#{name}", (cached_results || []).delete_if{|item| item.id == value.id}, :all)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_setter_remove_all(name, options)
|
||||
define_method "remove_all_#{name}" do
|
||||
all = send("#{name}", :force_reload => true)
|
||||
|
||||
all.collect{|i| i}.each do |item|
|
||||
send("remove_#{name.to_s.singularize}", item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_count(name, options, through = nil)
|
||||
method_name = name.to_s.singularize.underscore.gsub('/', '__') + "_count"
|
||||
define_method(method_name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
forced_reload, with_deleted, limit, descending = extract_association_options(local_options)
|
||||
|
||||
if forced_reload || instance_variable_get("@#{method_name}").nil?
|
||||
count =
|
||||
if through.nil? && options[:class_storing_keys] == self.class.name
|
||||
ids = Array(send(options[:foreign_key]))
|
||||
with_deleted ? ids.size : self.class.database.bulk_load(ids).reject { |d| d.respond_to?(:deleted?) && d.deleted? }.size
|
||||
else
|
||||
count_associated_via_join_view(through || options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key])
|
||||
end
|
||||
instance_variable_set("@#{method_name}", count)
|
||||
end
|
||||
instance_variable_get("@#{method_name}")
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_and_belongs_to_many_after_destroy_cleanup(name, options)
|
||||
if options[:class_storing_keys] == self.name
|
||||
define_method "has_and_belongs_to_many_clean_up_after_destroy" do |property|
|
||||
nil # deleting is enough as we store the keys
|
||||
end
|
||||
else
|
||||
define_method "has_and_belongs_to_many_clean_up_after_destroy" do |property|
|
||||
send("remove_all_#{property.name}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Property < SimplyBased::Model::AssociationProperty
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
options = {
|
||||
:storing_keys => false,
|
||||
:class_name => owner_clazz.find_association_class_name(name),
|
||||
:foreign_key => nil,
|
||||
}.update(options)
|
||||
|
||||
# there is only one pair of foreign_keys and it usualy the name of the class not storing the keys
|
||||
if options[:foreign_key].blank?
|
||||
if options[:storing_keys]
|
||||
options[:foreign_key] = options[:class_name].singularize.underscore.sub(/.*\//, '').foreign_key.pluralize.to_sym
|
||||
else
|
||||
options[:foreign_key] = owner_clazz.name.singularize.underscore.sub(/.*\//, '').foreign_key.pluralize.to_sym
|
||||
end
|
||||
end
|
||||
options[:class_storing_keys] = options[:storing_keys] ? owner_clazz.name : options[:class_name]
|
||||
@name, @options = name, options
|
||||
|
||||
options.assert_valid_keys(:class_name, :foreign_key, :storing_keys, :class_storing_keys)
|
||||
|
||||
owner_clazz.class_eval do
|
||||
_define_cache_accessors(name, options)
|
||||
define_has_and_belongs_to_many_property(options[:foreign_key]) if options[:storing_keys]
|
||||
define_has_and_belongs_to_many_views(name, options)
|
||||
define_has_and_belongs_to_many_getter(name, options)
|
||||
define_has_and_belongs_to_many_setter_add(name, options)
|
||||
define_has_and_belongs_to_many_setter_remove(name, options)
|
||||
define_has_and_belongs_to_many_setter_remove_all(name, options)
|
||||
define_has_and_belongs_to_many_count(name, options)
|
||||
define_has_and_belongs_to_many_after_destroy_cleanup(name, options)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module HasMany
|
||||
def has_many(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::HasMany::Property)
|
||||
properties << SimplyBased::Model::HasMany::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
def define_has_many_getter(name, options)
|
||||
define_method(name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
forced_reload, with_deleted, limit, descending = extract_association_options(local_options)
|
||||
|
||||
cached_results = send("_get_cached_#{name}")
|
||||
cache_key = _cache_key_for(local_options)
|
||||
if forced_reload || cached_results[cache_key].nil?
|
||||
cached_results[cache_key] = find_associated(options[:class_name], self.class, :with_deleted => with_deleted, :limit => limit, :descending => descending, :foreign_key => options[:foreign_key])
|
||||
instance_variable_set("@#{name}", cached_results)
|
||||
self.class.set_parent_has_many_association_object(self, cached_results[cache_key])
|
||||
end
|
||||
cached_results[cache_key]
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_through_getter(name, options, through)
|
||||
raise ArgumentError, "no such relation: #{self} - #{through}" unless instance_methods.map(&:to_sym).include?(through.to_sym)
|
||||
|
||||
define_method(name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
if local_options
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted, :limit)
|
||||
forced_reload = local_options[:force_reload]
|
||||
with_deleted = local_options[:with_deleted]
|
||||
limit = local_options[:limit]
|
||||
else
|
||||
forced_reload = false
|
||||
with_deleted = false
|
||||
limit = nil
|
||||
end
|
||||
|
||||
cached_results = send("_get_cached_#{name}")
|
||||
cache_key = _cache_key_for(local_options)
|
||||
|
||||
if forced_reload || cached_results[cache_key].nil?
|
||||
|
||||
# there is probably a faster way to query this
|
||||
through_property = self.class.properties.find{|p| p.name == through}
|
||||
if through_property && through_property.respond_to?(:options) && through_property.options[:class_name].present?
|
||||
through_finder = through_property.options[:class_name].constantize.foreign_property
|
||||
else
|
||||
through_finder = through # try with the association name
|
||||
end
|
||||
intermediate_objects = find_associated(through_finder, self.class, :with_deleted => with_deleted, :limit => limit, :foreign_key => options[:foreign_key])
|
||||
|
||||
through_objects = intermediate_objects.map do |intermediate_object|
|
||||
intermediate_object.send(name.to_s.singularize.underscore.gsub('/', '__'), :with_deleted => with_deleted)
|
||||
end.flatten.uniq
|
||||
cached_results[cache_key] = through_objects
|
||||
instance_variable_set("@#{name}", cached_results)
|
||||
end
|
||||
cached_results[cache_key]
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_setter_add(name, options)
|
||||
define_method("add_#{name.to_s.singularize}") do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass)
|
||||
foreign_key = "#{self.class.foreign_key}="
|
||||
# If foreign key is namespace: admin__user, try jus user if admin_user_id is not present
|
||||
foreign_key.gsub!(/.*__/, '') if !value.respond_to?(foreign_key) && value.respond_to?(foreign_key.sub(/.*__/, ''))
|
||||
value.send(foreign_key, id)
|
||||
value.save(false)
|
||||
|
||||
cached_results = send("_get_cached_#{name}")[:all]
|
||||
send("_set_cached_#{name}", (cached_results || []) << value, :all)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_setter_remove(name, options)
|
||||
define_method "remove_#{name.to_s.singularize}" do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass)
|
||||
raise ArgumentError, "cannot remove not mine" unless value.send(self.class.foreign_key.to_sym) == id
|
||||
|
||||
if options[:dependent] == :destroy
|
||||
value.destroy
|
||||
elsif options[:dependent] == :ignore
|
||||
# skip
|
||||
else # nullify
|
||||
foreign_key = "#{self.class.foreign_key}="
|
||||
# If foreign key is namespace: admin__user, try jus user if admin_user_id is not present
|
||||
foreign_key.gsub!(/.*__/, '') if !value.respond_to?(foreign_key) && value.respond_to?(foreign_key.sub(/.*__/, ''))
|
||||
value.send(foreign_key, nil)
|
||||
value.save(false)
|
||||
end
|
||||
|
||||
cached_results = send("_get_cached_#{name}")[:all]
|
||||
send("_set_cached_#{name}", (cached_results || []).delete_if{|item| item.id == value.id}, :all)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_setter_remove_all(name, options)
|
||||
define_method "remove_all_#{name}" do
|
||||
all = send("#{name}", :force_reload => true)
|
||||
|
||||
all.collect{|i| i}.each do |item|
|
||||
send("remove_#{name.to_s.singularize}", item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_count(name, options, through = nil)
|
||||
method_name = name.to_s.singularize.underscore.gsub('/', '__') + "_count"
|
||||
define_method(method_name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
if local_options
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted)
|
||||
forced_reload = local_options[:force_reload]
|
||||
with_deleted = local_options[:with_deleted]
|
||||
else
|
||||
forced_reload = false
|
||||
with_deleted = false
|
||||
end
|
||||
|
||||
if forced_reload || instance_variable_get("@#{method_name}").nil?
|
||||
instance_variable_set("@#{method_name}", count_associated(through || options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key]))
|
||||
end
|
||||
instance_variable_get("@#{method_name}")
|
||||
end
|
||||
end
|
||||
|
||||
def set_parent_has_many_association_object(parent, child_collection)
|
||||
child_collection.each do |child|
|
||||
if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=")
|
||||
child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Property < SimplyBased::Model::AssociationProperty
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
options = {
|
||||
:dependent => :nullify,
|
||||
:through => nil,
|
||||
:class_name => owner_clazz.find_association_class_name(name),
|
||||
:foreign_key => nil
|
||||
}.update(options)
|
||||
@name, @options = name, options
|
||||
|
||||
options.assert_valid_keys(:dependent, :through, :class_name, :foreign_key)
|
||||
|
||||
if options[:through]
|
||||
owner_clazz.class_eval do
|
||||
_define_cache_accessors(name, options)
|
||||
define_has_many_through_getter(name, options, options[:through])
|
||||
define_has_many_count(name, options, options[:through])
|
||||
end
|
||||
else
|
||||
owner_clazz.class_eval do
|
||||
_define_cache_accessors(name, options)
|
||||
define_has_many_getter(name, options)
|
||||
define_has_many_setter_add(name, options)
|
||||
define_has_many_setter_remove(name, options)
|
||||
define_has_many_setter_remove_all(name, options)
|
||||
define_has_many_count(name, options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module HasManyEmbedded
|
||||
def has_many_embedded(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::HasMany::Property)
|
||||
properties << SimplyBased::Model::HasManyEmbedded::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
def define_has_many_embedded_getter(name, options)
|
||||
# Make an alias to the property getter.
|
||||
property_getter_method = "#{name}_property_getter"
|
||||
alias_method property_getter_method, name
|
||||
define_method(name) do |*args|
|
||||
current = instance_variable_get("@#{name}")
|
||||
if current && current.respond_to?(:parent_object_set?)
|
||||
current.each { |o| o.parent_object = self }
|
||||
return current
|
||||
end
|
||||
# Rebuild current, ensure parent object
|
||||
current = Array.wrap(self.send(property_getter_method)).map do |h|
|
||||
if h.is_a?(options[:class_name].constantize)
|
||||
o = h
|
||||
o.parent_object = self
|
||||
else
|
||||
o = options[:class_name].constantize.new
|
||||
o._document = h
|
||||
o.parent_object = self
|
||||
end
|
||||
o
|
||||
end
|
||||
def current.parent_object_set?
|
||||
true
|
||||
end
|
||||
instance_variable_set("@#{name}", current)
|
||||
return current
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_embedded_setter(name, options)
|
||||
define_method("#{name}=") do |values|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected Array got #{values.class}" unless values.is_a?(Array)
|
||||
instance_variable_set("@#{name}", []) unless instance_variable_get("@#{name}")
|
||||
iid = 0
|
||||
for value in values
|
||||
if value.is_a?(Hash)
|
||||
newval = klass.new
|
||||
newval._document = value
|
||||
newval.updated_at ||= Time.now
|
||||
newval.created_at ||= Time.now
|
||||
else
|
||||
newval = value
|
||||
end
|
||||
newval.index = iid
|
||||
instance_variable_get("@#{name}") << newval
|
||||
iid += 1
|
||||
end
|
||||
current = instance_variable_get("@#{name}")
|
||||
current.define_singleton_method(:parent_object_set?) { true }
|
||||
save
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_embedded_setter_add(name, options)
|
||||
define_method("add_#{name.to_s.singularize}") do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.is_a?(klass)
|
||||
if value.is_a?(Hash)
|
||||
newval = klass.new
|
||||
newval._document = value
|
||||
newval.updated_at ||= Time.now
|
||||
newval.created_at ||= Time.now
|
||||
else
|
||||
newval = value
|
||||
end
|
||||
newval.index = (instance_variable_get("@#{name}") || []).size
|
||||
instance_variable_get("@#{name}") << newval
|
||||
save
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_embedded_setter_remove(name, options)
|
||||
define_method "remove_#{name.to_s.singularize}" do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
if value.is_a?(klass)
|
||||
found = instance_variable_get("@#{name}").delete(value)
|
||||
if found
|
||||
self.send("reset_#{name}_index_values")
|
||||
end
|
||||
else
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}"
|
||||
end
|
||||
return save
|
||||
end
|
||||
end
|
||||
|
||||
def define_reset_index_values(name, options)
|
||||
define_method "reset_#{name}_index_values" do
|
||||
i = 0
|
||||
for embedded_document in (instance_variable_get("@#{name}") || [])
|
||||
embedded_document.index = i
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_embedded_setter_remove_all(name, options)
|
||||
define_method "remove_all_#{name}" do
|
||||
all = send("#{name}", force_reload: true)
|
||||
all.collect { |i| i }.each do |item|
|
||||
send("remove_#{name.to_s.singularize}", item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_many_embedded_count(name, options, through = nil)
|
||||
method_name = name.to_s.singularize.underscore.gsub('/', '__') + '_count'
|
||||
define_method(method_name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
if local_options
|
||||
local_options.assert_valid_keys(:force_reload, :with_deleted)
|
||||
forced_reload = local_options[:force_reload]
|
||||
with_deleted = local_options[:with_deleted]
|
||||
else
|
||||
forced_reload = false
|
||||
with_deleted = false
|
||||
end
|
||||
|
||||
if forced_reload || instance_variable_get("@#{method_name}").nil?
|
||||
instance_variable_set("@#{method_name}", count_associated(through || options[:class_name], self.class, with_deleted: with_deleted, foreign_key: options[:foreign_key]))
|
||||
end
|
||||
instance_variable_get("@#{method_name}")
|
||||
end
|
||||
end
|
||||
|
||||
def set_parent_has_many_embedded_association_object(parent, child_collection)
|
||||
child_collection.each do |child|
|
||||
if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=")
|
||||
child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Property < SimplyBased::Model::AssociationProperty
|
||||
|
||||
def build(object, json)
|
||||
values = Array.wrap(json[name]).map do |v|
|
||||
obj = if v.is_a?(Hash)
|
||||
options[:class_name].constantize.new.tap { |o| o._document = v }
|
||||
else
|
||||
v
|
||||
end
|
||||
obj.parent_object = object
|
||||
obj
|
||||
end
|
||||
values.define_singleton_method(:parent_object_set?) { true }
|
||||
values.each_with_index { |v, i| v.index = i }
|
||||
object.instance_variable_set("@#{name}", values)
|
||||
end
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
options = {
|
||||
dependent: :nullify,
|
||||
through: nil,
|
||||
class_name: name.to_s.singularize.camelize,
|
||||
foreign_key: nil
|
||||
}.update(options)
|
||||
@name, @options = name, options
|
||||
|
||||
options.assert_valid_keys(:dependent, :through, :class_name, :foreign_key)
|
||||
|
||||
owner_clazz.class_eval do
|
||||
property name, type: Array
|
||||
_define_cache_accessors(name, options)
|
||||
define_has_many_embedded_getter(name, options)
|
||||
define_has_many_embedded_setter(name, options)
|
||||
define_has_many_embedded_setter_add(name, options)
|
||||
define_has_many_embedded_setter_remove(name, options)
|
||||
define_has_many_embedded_setter_remove_all(name, options)
|
||||
define_reset_index_values(name, options)
|
||||
define_has_many_embedded_count(name, options)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module HasOne
|
||||
def has_one(name, options = {})
|
||||
check_existing_properties(name, SimplyBased::Model::HasOne::Property)
|
||||
properties << SimplyBased::Model::HasOne::Property.new(self, name, options)
|
||||
end
|
||||
|
||||
def define_has_one_setter(name, options)
|
||||
define_method("#{name}=") do |value|
|
||||
klass = self.class.get_class_from_name(name)
|
||||
raise ArgumentError, "expected #{klass} got #{value.class}" unless value.nil? || value.is_a?(klass)
|
||||
old_value = send("#{name}", :force_reload => true)
|
||||
if value.nil?
|
||||
instance_variable_set("@#{name}", nil)
|
||||
else
|
||||
instance_variable_set("@#{name}", value)
|
||||
value.send("#{self.class.foreign_key}=", id)
|
||||
value.save
|
||||
end
|
||||
|
||||
if old_value
|
||||
if options[:dependent] == :destroy
|
||||
old_value.destroy
|
||||
else
|
||||
old_value.send("#{self.class.foreign_property}=", nil)
|
||||
old_value.save
|
||||
end
|
||||
end
|
||||
old_value
|
||||
end
|
||||
end
|
||||
|
||||
def define_has_one_getter(name, options)
|
||||
define_method(name) do |*args|
|
||||
local_options = args.first && args.first.is_a?(Hash) && args.first
|
||||
forced_reload, with_deleted, limit, descending = extract_association_options(local_options)
|
||||
|
||||
if forced_reload || instance_variable_get("@#{name}").nil?
|
||||
found_object = find_one_associated(options[:class_name], self.class, :with_deleted => with_deleted, :foreign_key => options[:foreign_key])
|
||||
instance_variable_set("@#{name}", found_object)
|
||||
self.class.set_parent_has_one_association_object(self, found_object)
|
||||
end
|
||||
instance_variable_get("@#{name}")
|
||||
end
|
||||
end
|
||||
|
||||
def set_parent_has_one_association_object(parent, child)
|
||||
if child.respond_to?("#{parent.class.name.to_s.singularize.downcase}=")
|
||||
child.send("#{parent.class.name.to_s.singularize.camelize.downcase}=", parent)
|
||||
end
|
||||
end
|
||||
|
||||
class Property < SimplyBased::Model::AssociationProperty
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
options = {
|
||||
:dependent => :nullify,
|
||||
:class_name => name.to_s.singularize.camelize,
|
||||
:foreign_key => nil
|
||||
}.update(options)
|
||||
@name, @options = name, options
|
||||
|
||||
options.assert_valid_keys(:dependent, :class_name, :foreign_key)
|
||||
|
||||
owner_clazz.class_eval do
|
||||
define_has_one_getter(name, options)
|
||||
define_has_one_setter(name, options)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Pagination
|
||||
def total_pages_method(setter = nil)
|
||||
@_total_pages_method = setter if setter
|
||||
@_total_pages_method || 'total_pages'
|
||||
end
|
||||
|
||||
def current_page_method(setter = nil)
|
||||
@_current_page_method = setter if setter
|
||||
@_current_page_method || 'current_page'
|
||||
end
|
||||
|
||||
def num_pages_method(setter = nil)
|
||||
@_num_pages_method = setter if setter
|
||||
@_num_pages_method || 'num_pages'
|
||||
end
|
||||
|
||||
def per_page_method(setter = nil)
|
||||
@_per_page_method = setter if setter
|
||||
@_per_page_method || 'per_page'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module PaginationOptions
|
||||
def with_pagination_options(options, &block)
|
||||
# Allow for manual setting of total_entries
|
||||
# If none given, default fallback will be total_rows
|
||||
# This is not true for a paginated find_all_by construction
|
||||
total_entries = options.delete(:total_entries)
|
||||
# Add limit and skip to options if requested
|
||||
if ([:page, :per_page] & options.keys).any? # Pagination active
|
||||
page = [options.delete(:page).to_i, 1].max
|
||||
per_page = options.delete(:per_page).to_i # Can be string
|
||||
per_page = 22 unless per_page > 0 # Nill will be 0
|
||||
options[:limit] = per_page
|
||||
options[:skip] = (page - 1) * options[:limit]
|
||||
elsif options[:offset] && options[:limit] # Sql syntax support
|
||||
options[:skip] = options.delete(:offset)
|
||||
options.delete(:order)
|
||||
page = (options[:skip].to_i / options[:limit].to_i).floor.succ
|
||||
per_page = options[:limit]
|
||||
else
|
||||
page = 1
|
||||
per_page = 22
|
||||
end
|
||||
result = block.call(options)
|
||||
if result
|
||||
result.instance_eval <<-PAGINATION, __FILE__, __LINE__
|
||||
unless respond_to?(:total_rows)
|
||||
def total_rows
|
||||
1
|
||||
end
|
||||
end
|
||||
def total_entries
|
||||
#{total_entries || 'total_rows'}
|
||||
end
|
||||
alias total_count total_entries
|
||||
|
||||
def #{current_page_method}
|
||||
#{page}
|
||||
end
|
||||
def #{num_pages_method}
|
||||
return 1 if total_entries.zero?
|
||||
(total_entries.to_f / #{per_page}).ceil
|
||||
end
|
||||
alias :#{total_pages_method} :#{num_pages_method}
|
||||
def #{per_page_method}
|
||||
#{per_page}
|
||||
end
|
||||
PAGINATION
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,438 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Persistence — replaces CouchPotato::Persistence.
|
||||
# Provides property macro, callbacks, JSON, dirty tracking, timestamps, validations.
|
||||
#
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Persistence
|
||||
require 'active_support/time'
|
||||
|
||||
def self.included(base)
|
||||
base.instance_variable_set(:@properties, nil) if base.instance_variable_defined?(:@properties)
|
||||
base.send :include, Properties
|
||||
base.send :include, Callbacks
|
||||
base.send :include, Json
|
||||
base.send :include, DirtyAttributes
|
||||
base.send :include, MagicTimestamps
|
||||
base.send :include, ActiveModelCompliance
|
||||
base.send :include, ForbiddenAttributesProtection
|
||||
base.send :include, Revisions
|
||||
base.send :include, Validation
|
||||
base.send :include, View::CustomViews
|
||||
base.send :include, View::Lists
|
||||
|
||||
base.class_eval do
|
||||
attr_accessor :_id, :_rev, :_deleted, :_attachments, :database, :_document
|
||||
alias_method :id, :_id
|
||||
alias_method :id=, :_id=
|
||||
|
||||
def _document=(val)
|
||||
@_document = val.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(val) : val
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── initialize / attributes ────────────────────────────────────────
|
||||
|
||||
def initialize(attributes = {})
|
||||
if attributes
|
||||
@skip_dirty_tracking = true
|
||||
begin
|
||||
self.attributes = attributes
|
||||
ensure
|
||||
# Always restore tracking, even if a setter raises, so the instance
|
||||
# isn't left permanently ignoring dirty state.
|
||||
@skip_dirty_tracking = false
|
||||
end
|
||||
end
|
||||
yield self if block_given?
|
||||
end
|
||||
|
||||
def attributes=(hash)
|
||||
hash.each { |attribute, value| self.public_send "#{attribute}=", value }
|
||||
end
|
||||
|
||||
def attributes
|
||||
self.class.properties.inject(ActiveSupport::HashWithIndifferentAccess.new) do |res, property|
|
||||
property.value(res, self)
|
||||
res
|
||||
end
|
||||
end
|
||||
|
||||
def []=(attribute, value); public_send("#{attribute}=", value); end
|
||||
def [](attribute); public_send(attribute); end
|
||||
def has_key?(key); attributes.has_key?(key); end
|
||||
def new?; _rev.nil?; end
|
||||
alias_method :new_record?, :new?
|
||||
def to_param; _id; end
|
||||
|
||||
def ==(other)
|
||||
super || (self.class == other.class && self._id.present? && self._id == other._id)
|
||||
end
|
||||
def eql?(other); self == other; end
|
||||
|
||||
def hash
|
||||
# Unsaved records hash by object identity to stay consistent with #==
|
||||
# (two distinct new records are not equal, so must not be forced equal).
|
||||
return super if _id.nil?
|
||||
[self.class, _id, _rev].hash
|
||||
end
|
||||
|
||||
def inspect
|
||||
attrs = attributes.map {|k,v| "#{k}: #{v.inspect}"}.join(", ")
|
||||
%Q{#<#{self.class} _id: "#{_id}", _rev: "#{_rev}", #{attrs}>}
|
||||
end
|
||||
|
||||
# ── Properties ─────────────────────────────────────────────────────
|
||||
|
||||
module Properties
|
||||
class PropertyList
|
||||
include Enumerable
|
||||
attr_accessor :list
|
||||
|
||||
def initialize(clazz)
|
||||
@clazz = clazz
|
||||
@list = []
|
||||
@hash = {}
|
||||
end
|
||||
|
||||
def each(&block); (list + inherited_properties).each(&block); end
|
||||
def <<(property); @hash[property.name] = property; @list << property; end
|
||||
def find_property(name); @hash[name] || @clazz.superclass.properties.find_property(name); end
|
||||
def inspect; list.map(&:name).inspect; end
|
||||
|
||||
def inherited_properties
|
||||
superclazz = @clazz.superclass
|
||||
properties = []
|
||||
while superclazz && superclazz.respond_to?(:properties)
|
||||
properties << superclazz.properties.list
|
||||
superclazz = superclazz.superclass
|
||||
end
|
||||
properties.flatten
|
||||
end
|
||||
end
|
||||
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
base.class_eval do
|
||||
def self.properties
|
||||
@properties ||= {}
|
||||
@properties[name] ||= PropertyList.new(self)
|
||||
@properties[name]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def type_caster; @type_caster ||= TypeCaster.new; end
|
||||
|
||||
module ClassMethods
|
||||
def property_names; properties.map(&:name); end
|
||||
|
||||
def property(name, options = {})
|
||||
am = send(:generated_attribute_methods)
|
||||
am.module_eval { undef_method(name) if instance_methods.include?(name) }
|
||||
define_attribute_method name
|
||||
properties << SimpleProperty.new(self, name, options)
|
||||
am.send(:remove_method, name) if am.instance_methods.include?(name)
|
||||
end
|
||||
|
||||
def check_existing_properties(name, type)
|
||||
existing = properties.find{|p| name.to_sym == p.name.to_sym}
|
||||
return if existing.nil? || existing.class == type
|
||||
raise "Property #{name} already defined as #{existing.class}, cannot redefine as #{type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── SimpleProperty ──────────────────────────────────────────────────
|
||||
|
||||
module PropertyMethods
|
||||
private
|
||||
def load_attribute_from_document(name)
|
||||
if _document&.has_key?(name)
|
||||
property = self.class.properties.find_property(name)
|
||||
@skip_dirty_tracking = true
|
||||
value = property.build(self, _document)
|
||||
@skip_dirty_tracking = false
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class SimpleProperty
|
||||
attr_accessor :name, :type
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
self.name = name
|
||||
@setter_name = "#{name}="
|
||||
self.type = options[:type]
|
||||
@type_caster = TypeCaster.new
|
||||
owner_clazz.send(:include, PropertyMethods) unless owner_clazz.ancestors.include?(PropertyMethods)
|
||||
define_accessors(accessors_module_for(owner_clazz), name, options)
|
||||
end
|
||||
|
||||
def build(object, json); object.public_send @setter_name, json[name]; end
|
||||
def changed?(object); object.public_send("#{name}_changed?"); end
|
||||
def serialize(json, object); json[name] = @type_caster.cast_back object.public_send(name); end
|
||||
alias :value :serialize
|
||||
|
||||
private
|
||||
|
||||
def module_for(clazz, name)
|
||||
suffix = "#{clazz.name.to_s.gsub('::', '__')}#{name}"
|
||||
unless clazz.const_defined?(suffix)
|
||||
clazz.const_set(suffix, Module.new).tap {|m| clazz.send(:include, m) }
|
||||
end
|
||||
clazz.const_get(suffix)
|
||||
end
|
||||
|
||||
def accessors_module_for(clazz); module_for(clazz, "AccessorMethods"); end
|
||||
|
||||
def define_accessors(base, name, options)
|
||||
ivar = "@#{name}".freeze
|
||||
base.class_eval do
|
||||
define_method(name) do
|
||||
load_attribute_from_document(name) unless instance_variable_defined?(ivar)
|
||||
value = instance_variable_get(ivar)
|
||||
if value.nil? && !options[:default].nil?
|
||||
default = if options[:default].respond_to?(:call)
|
||||
options[:default].arity == 1 ? options[:default].call(self) : options[:default].call
|
||||
else
|
||||
clone_attribute(options[:default])
|
||||
end
|
||||
instance_variable_set(ivar, default)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
define_method("#{name}=") do |value|
|
||||
typecasted = type_caster.cast(value, options[:type])
|
||||
public_send("#{name}_will_change!") unless @skip_dirty_tracking || typecasted == public_send(name)
|
||||
instance_variable_set(ivar, typecasted)
|
||||
end
|
||||
|
||||
define_method("#{name}?") { !send(name).nil? && !send(name).try(:blank?) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
module Callbacks
|
||||
extend ActiveSupport::Concern
|
||||
include ActiveSupport::Callbacks
|
||||
|
||||
included do
|
||||
define_callbacks :validate, :validation,
|
||||
:validation_on_save, :validation_on_create, :validation_on_update,
|
||||
:save, :create, :update, :destroy
|
||||
%w(validate validation save create update destroy).each do |cb|
|
||||
class_eval <<-RUBY, __FILE__, __LINE__
|
||||
def self.before_#{cb}(*args, &block); set_callback :#{cb}, :before, *args, █ end
|
||||
def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, █ end
|
||||
def self.around_#{cb}(*args, &block); set_callback :#{cb}, :around, *args, █ end
|
||||
RUBY
|
||||
end
|
||||
%w(validation_on_create validation_on_update).each do |cb|
|
||||
class_eval <<-RUBY, __FILE__, __LINE__
|
||||
def self.before_#{cb}(*args, &block); set_callback :#{cb}, :before, *args, █ end
|
||||
def self.after_#{cb}(*args, &block); set_callback :#{cb}, :after, *args, █ end
|
||||
RUBY
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Dirty Attributes ─────────────────────────────────────────────────
|
||||
|
||||
module DirtyAttributes
|
||||
def self.included(base)
|
||||
base.send :include, ActiveModel::Dirty
|
||||
base.send :alias_method, :dirty?, :changed?
|
||||
|
||||
base.class_eval { after_save :clear_changes_information }
|
||||
end
|
||||
private
|
||||
# Deep-copy a (default) attribute value so instances never share mutable
|
||||
# state. Recurses through Hash/Array instead of Marshal.dump/load, which
|
||||
# is both lighter and avoids the Marshal round-trip (Marshal can't dump
|
||||
# some objects and is flagged by security scanners). Preserves the hash
|
||||
# subclass (e.g. HashWithIndifferentAccess).
|
||||
def clone_attribute(value)
|
||||
case value
|
||||
when Integer, Symbol, TrueClass, FalseClass, NilClass, Float
|
||||
value
|
||||
when Array
|
||||
value.map { |element| clone_attribute(element) }
|
||||
when Hash
|
||||
value.each_with_object(value.class.new) do |(k, v), copy|
|
||||
copy[clone_attribute(k)] = clone_attribute(v)
|
||||
end
|
||||
else
|
||||
value.clone
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── JSON ─────────────────────────────────────────────────────────────
|
||||
|
||||
module Json
|
||||
def to_json(*args); to_hash(*args).to_json(*args); end
|
||||
def to_hash(options = nil)
|
||||
doc = { 'ruby_class' => self.class.name, '_id' => _id, '_rev' => _rev }.reject { |_, v| v.nil? }.merge(@_document || {})
|
||||
(self.class.properties || []).inject(doc) {|d, p| p.serialize(d, self); d }
|
||||
end
|
||||
alias :as_json :to_hash
|
||||
def _document; @_document ||= {}; end
|
||||
def _document=(val)
|
||||
@_document = val.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(val) : val
|
||||
end
|
||||
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Called by JSON.parse to hydrate documents into model instances.
|
||||
# Looks for 'ruby_class' key (mozo convention) or 'json_class' (standard).
|
||||
def json_create(json)
|
||||
return if json.nil?
|
||||
doc = ActiveSupport::HashWithIndifferentAccess.new(json)
|
||||
instance = new
|
||||
instance.instance_variable_set(:@_document, doc)
|
||||
instance._id = doc[:_id] || doc['_id']
|
||||
instance._rev = doc[:_rev] || doc['_rev']
|
||||
instance._attachments = doc[:_attachments] || doc['_attachments']
|
||||
instance
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Magic Timestamps ─────────────────────────────────────────────────
|
||||
|
||||
module MagicTimestamps
|
||||
def self.included(base)
|
||||
base.instance_eval do
|
||||
property :created_at, type: Time
|
||||
property :updated_at, type: Time
|
||||
before_create :set_created_at
|
||||
before_save :set_updated_at
|
||||
end
|
||||
end
|
||||
private
|
||||
def set_created_at; self.created_at ||= Time.now; end
|
||||
def set_updated_at; self.updated_at = Time.now; end
|
||||
end
|
||||
|
||||
# ── ActiveModel Compliance ───────────────────────────────────────────
|
||||
|
||||
module ActiveModelCompliance
|
||||
extend ActiveSupport::Concern
|
||||
def persisted?; !new? && !destroyed?; end
|
||||
def destroyed?; @destroyed || false; end
|
||||
def to_key; persisted? ? [id] : nil; end
|
||||
def to_model; self; end
|
||||
end
|
||||
|
||||
# ── Forbidden Attributes Protection ──────────────────────────────────
|
||||
|
||||
module ForbiddenAttributesProtection
|
||||
# Rails 5+ uses strong parameters at controller level — stub only
|
||||
end
|
||||
|
||||
# ── Revisions ────────────────────────────────────────────────────────
|
||||
|
||||
module Revisions
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
def self.revisions(ids)
|
||||
return [] if ids.empty?
|
||||
database.bulk_load(ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
module Validation
|
||||
extend ActiveSupport::Concern
|
||||
include ActiveModel::Validations
|
||||
end
|
||||
|
||||
# ── Type Caster ──────────────────────────────────────────────────────
|
||||
|
||||
class TypeCaster
|
||||
# Cast a value to the given type.
|
||||
# Supports:
|
||||
# type: SomeClass — direct class reference
|
||||
# type: :boolean, :integer — symbol (lazy, Rails autoloading safe)
|
||||
# type: 'ClassName' — string (constantize in Rails)
|
||||
def cast(value, type = nil)
|
||||
return value unless type
|
||||
resolved = resolve_type(type)
|
||||
# If resolved is a Module (class), try to coerce
|
||||
if resolved.is_a?(Module)
|
||||
return value if value.is_a?(resolved)
|
||||
return cast_to_builtin(value, resolved)
|
||||
end
|
||||
value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cast_to_builtin(value, klass)
|
||||
case klass.name
|
||||
when 'Integer' then value.to_i
|
||||
when 'Float' then value.to_f
|
||||
when 'String' then value.to_s
|
||||
when 'Symbol' then value.to_sym
|
||||
when 'TrueClass', 'FalseClass' then !!value
|
||||
when 'Array' then value.is_a?(Array) ? value : [value]
|
||||
when 'Hash' then value.is_a?(Hash) ? value : { value: value }
|
||||
when 'Time', 'DateTime', 'Date'
|
||||
value.is_a?(String) ? Time.parse(value) : value
|
||||
else value
|
||||
end
|
||||
end
|
||||
|
||||
public
|
||||
def cast_back(value)
|
||||
value.respond_to?(:iso8601) ? value.iso8601 : value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Map symbols and strings to Ruby classes.
|
||||
# Symbols are preferred — they work without Rails autoloading.
|
||||
BUILTIN_TYPES = {
|
||||
boolean: [TrueClass, FalseClass],
|
||||
integer: Integer,
|
||||
float: Float,
|
||||
string: String,
|
||||
symbol: Symbol,
|
||||
time: Time,
|
||||
datetime: DateTime,
|
||||
date: Date,
|
||||
array: Array,
|
||||
hash: Hash,
|
||||
}.freeze
|
||||
|
||||
def resolve_type(type)
|
||||
case type
|
||||
when Symbol
|
||||
mapped = BUILTIN_TYPES[type]
|
||||
mapped || (Object.const_get(type.to_s.classify) rescue type)
|
||||
when String
|
||||
Object.const_get(type) rescue type
|
||||
when Module
|
||||
type
|
||||
else
|
||||
type
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Properties
|
||||
def check_existing_properties(name, type)
|
||||
if properties.find{|property| name.to_sym == property.name.to_sym && property.class != type}
|
||||
raise "Property with the name (#{name}) already defined"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
#require 'active_support/core_ext/hash/except'
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Validations
|
||||
class UniquenessValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
other_instance = record.class.send("find_by_#{attribute}", value)
|
||||
if other_instance && other_instance != record && other_instance.send(attribute) == value
|
||||
record.errors.add(attribute, :taken, **options.except(:case_sensitive, :scope).merge(:value => value))
|
||||
end
|
||||
end
|
||||
end
|
||||
def validates_uniqueness_of(*attr_names)
|
||||
validates_with UniquenessValidator, _merge_attributes(attr_names)
|
||||
end
|
||||
class ContainmentValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
unless Array.wrap(value) - options[:in] == []
|
||||
record.errors.add(attribute, :inclusion, **options.except(:in, :within).merge!(:value => value))
|
||||
end
|
||||
end
|
||||
end
|
||||
def validates_containment_of(*attr_names)
|
||||
validates_with ContainmentValidator, _merge_attributes(attr_names)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require 'simply_based/model/view/base_view_spec'
|
||||
require 'simply_based/model/view/model_view_spec'
|
||||
require 'simply_based/model/view/properties_view_spec'
|
||||
require 'simply_based/model/view/custom_view_spec'
|
||||
require 'simply_based/model/view/raw_view_spec'
|
||||
require 'simply_based/model/view/custom_views'
|
||||
require 'simply_based/model/view/lists'
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'digest'
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
class BaseViewSpec
|
||||
attr_reader :reduce_function, :lib, :list_name, :list_function, :design_document, :view_name, :klass, :options, :language
|
||||
attr_accessor :view_parameters
|
||||
|
||||
# `klass` (the model) and `options` (the view declaration: :key, :conditions,
|
||||
# :emit_value, :properties, …) are the structured, backend-neutral query
|
||||
# description. The couchrest backend renders them to JS map/reduce; other
|
||||
# backends (e.g. couchbase → N1QL) translate these fields instead of
|
||||
# parsing the generated JavaScript. Public so adapters can read them.
|
||||
|
||||
DEFAULT_LANGUAGE = :javascript
|
||||
DEFAULT_DIGEST_VIEW_NAMES = false
|
||||
|
||||
def initialize(klass, view_name, options, view_parameters)
|
||||
normalized_view_parameters = normalize_view_parameters view_parameters
|
||||
|
||||
@list_name = normalized_view_parameters.delete(:list) || options[:list]
|
||||
@language = options[:language] || DEFAULT_LANGUAGE
|
||||
|
||||
assert_valid_view_parameters normalized_view_parameters
|
||||
assert_sorted_false_not_with_startkey_endkey normalized_view_parameters
|
||||
@klass = klass
|
||||
@options = options
|
||||
@view_name = compute_view_name(view_name,
|
||||
options.key?(:digest_view_name) ? options[:digest_view_name] : DEFAULT_DIGEST_VIEW_NAMES)
|
||||
@design_document = translate_to_design_doc_name(klass.to_s, @view_name, @list_name)
|
||||
@list_params = normalized_view_parameters.delete :list_params
|
||||
|
||||
@list_function = klass.lists(@list_name) if @list_name
|
||||
@view_parameters = {}
|
||||
[:group, :include_docs, :descending, :group_level, :limit].each do |key|
|
||||
@view_parameters[key] = options[key] if options.include?(key)
|
||||
end
|
||||
@view_parameters.merge!(normalized_view_parameters)
|
||||
@view_parameters.merge!(@list_params) if @list_params
|
||||
end
|
||||
|
||||
def process_results(results)
|
||||
if (filter = options[:results_filter])
|
||||
filter.call results
|
||||
else
|
||||
results
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_view_name(view_name, digest)
|
||||
if digest
|
||||
"#{view_name}-#{Digest::MD5.hexdigest(map_function + reduce_function.to_s)}"
|
||||
else
|
||||
view_name
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_view_parameters(params)
|
||||
hash = wrap_in_hash params
|
||||
remove_nil_stale(replace_range_key(hash))
|
||||
end
|
||||
|
||||
def remove_nil_stale(params)
|
||||
params.reject{|name, value| name.to_s == 'stale' && value.nil?}
|
||||
end
|
||||
|
||||
def wrap_in_hash(params)
|
||||
if params.is_a?(Hash)
|
||||
params
|
||||
else
|
||||
{:key => params}
|
||||
end
|
||||
end
|
||||
|
||||
def replace_range_key(params)
|
||||
if((key = params[:key]).is_a?(Range))
|
||||
params.delete :key
|
||||
params[:startkey] = key.first
|
||||
params[:endkey] = key.last
|
||||
end
|
||||
params
|
||||
end
|
||||
|
||||
def assert_valid_view_parameters(params)
|
||||
params.keys.each do |key|
|
||||
fail ArgumentError, "invalid view parameter: #{key}" unless valid_view_parameters.include?(key.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_sorted_false_not_with_startkey_endkey(params)
|
||||
used_key_params = params.keys & [:startkey, :endkey, :startkey_docid, :endkey_docid]
|
||||
if params[:sorted] == false && used_key_params.any?
|
||||
fail ArgumentError, "view parameter: `sorted: false` can not be combined with #{used_key_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
def valid_view_parameters
|
||||
%w(list_params key keys startkey startkey_docid endkey endkey_docid limit stale descending skip group group_level reduce include_docs inclusive_end sorted)
|
||||
end
|
||||
|
||||
def translate_to_design_doc_name(klass_name, view_name, list_name)
|
||||
klass_name = klass_name.dup
|
||||
klass_name.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
||||
klass_name.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
||||
klass_name.tr!('-', '_')
|
||||
doc_name = klass_name.downcase
|
||||
|
||||
if options && options[:split_design_doc]
|
||||
doc_name += "_view_#{view_name}" if view_name.present?
|
||||
doc_name += "_list_#{list_name}" if list_name.present?
|
||||
end
|
||||
doc_name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
# a view for custom map/reduce functions that still returns model instances
|
||||
#
|
||||
# example:
|
||||
# view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :include_docs => true, :type => :custom, :reduce => nil
|
||||
class CustomViewSpec < BaseViewSpec
|
||||
def map_function
|
||||
options[:map]
|
||||
end
|
||||
|
||||
def reduce_function
|
||||
options[:reduce]
|
||||
end
|
||||
|
||||
def lib
|
||||
options[:lib]
|
||||
end
|
||||
|
||||
def view_parameters
|
||||
{:include_docs => options[:include_docs] || false}.merge(super)
|
||||
end
|
||||
|
||||
def process_results(results)
|
||||
processed = if count?
|
||||
results['rows'].first.try(:[], 'value') || 0
|
||||
else
|
||||
results['rows'].map do |row|
|
||||
if row['doc'].kind_of?(klass)
|
||||
row['doc']
|
||||
else
|
||||
result = row['doc'] || (row['value'].merge(:_id => row['id'] || row['key']) unless view_parameters[:include_docs])
|
||||
klass.json_create result if result
|
||||
end
|
||||
end.compact
|
||||
end
|
||||
super processed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count?
|
||||
view_parameters[:reduce]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
module CustomViews
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def views(view_name = nil)
|
||||
if view_name
|
||||
_find_view(view_name)
|
||||
else
|
||||
@views ||= {}
|
||||
end
|
||||
end
|
||||
|
||||
def execute_view(view_name, view_parameters)
|
||||
options = views(view_name).dup
|
||||
options[:split_design_doc] = split_design_documents? if respond_to?(:split_design_documents?)
|
||||
view_spec_class(options[:type]).new(self, view_name, options, view_parameters)
|
||||
end
|
||||
|
||||
# Declare a CouchDB view, for examples on how to use see the *ViewSpec classes in SimplyBased::Model::View
|
||||
def view(view_name, options)
|
||||
view_name = view_name.to_s
|
||||
views[view_name] = options
|
||||
method_str = "def #{view_name}(view_parameters = {}); execute_view(\"#{view_name}\", view_parameters); end"
|
||||
self.instance_eval(method_str)
|
||||
end
|
||||
|
||||
def view_spec_class(type)
|
||||
if type && type.is_a?(Class)
|
||||
type
|
||||
else
|
||||
name = type.nil? ? 'Model' : type.to_s.camelize
|
||||
SimplyBased::Model::View.const_get("#{name}ViewSpec")
|
||||
end
|
||||
end
|
||||
|
||||
def _find_view(view)
|
||||
(@views && @views[view]) || (superclass._find_view(view) if superclass.respond_to?(:_find_view))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
module Lists
|
||||
def self.included(base)
|
||||
base.send :extend, ClassMethods
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def list(name, list_function)
|
||||
lists[name] = list_function
|
||||
end
|
||||
|
||||
def lists(name = nil)
|
||||
if name.nil?
|
||||
@lists ||= {}
|
||||
else
|
||||
(@lists && @lists[name]) || (superclass.lists(name) if superclass.respond_to?(:lists))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
# A view to return model instances by searching its properties.
|
||||
# If you pass reduce => true will count instead
|
||||
#
|
||||
# example:
|
||||
# view :my_view, :key => :name
|
||||
#
|
||||
# in addition you can pass in conditions as a javascript string
|
||||
# view :my_view_only_completed, :key => :name, :conditions => 'doc.completed = true'
|
||||
# and also a results filter (the results will be run through the given proc):
|
||||
# view :my_view, :key => :name, :results_filter => lambda{|results| results.size}
|
||||
class ModelViewSpec < BaseViewSpec
|
||||
# The key simply_couch uses for class identification in CouchDB documents
|
||||
RUBY_CLASS_KEY = 'ruby_class'
|
||||
|
||||
class JavascriptGenerator
|
||||
def initialize(options, klass)
|
||||
@options = options
|
||||
@klass = klass
|
||||
end
|
||||
|
||||
def map_body(&block)
|
||||
<<-JS
|
||||
function(doc) {
|
||||
if(doc.#{RUBY_CLASS_KEY} && doc.#{RUBY_CLASS_KEY} == '#{@klass.name}'#{conditions}) {
|
||||
#{yield}
|
||||
}
|
||||
}
|
||||
JS
|
||||
end
|
||||
|
||||
def map_function
|
||||
map_body do
|
||||
"emit(#{formatted_key}, #{emit_value});"
|
||||
end
|
||||
end
|
||||
|
||||
def formatted_key(_key = nil)
|
||||
_key ||= @options[:key]
|
||||
if _key.is_a? Array
|
||||
'[' + _key.map{|key_part| formatted_key(key_part)}.join(', ') + ']'
|
||||
else
|
||||
"doc['#{_key}']"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Allow custom emit values. Raise when the specified argument is not recognized
|
||||
def emit_value
|
||||
case @options[:emit_value]
|
||||
when Symbol then "doc['#{@options[:emit_value]}']"
|
||||
when String then @options[:emit_value]
|
||||
when Numeric then @options[:emit_value]
|
||||
when NilClass then 1
|
||||
else
|
||||
raise "The emit value specified is not recognized"
|
||||
end
|
||||
end
|
||||
|
||||
def conditions
|
||||
" && (#{@options[:conditions]})" if @options[:conditions]
|
||||
end
|
||||
end
|
||||
|
||||
delegate :map_function, :map_body, :formatted_key, :to => :generator
|
||||
|
||||
def view_parameters
|
||||
_super = super
|
||||
if _super[:reduce]
|
||||
_super
|
||||
else
|
||||
{:include_docs => true, :reduce => false}.merge(_super)
|
||||
end
|
||||
end
|
||||
|
||||
def reduce_function
|
||||
"_sum"
|
||||
end
|
||||
|
||||
def process_results(results)
|
||||
processed = if count?
|
||||
results['rows'].first.try(:[], 'value') || 0
|
||||
else
|
||||
results['rows'].map {|row|
|
||||
row['doc'] || (row['id'] unless view_parameters[:include_docs])
|
||||
}.compact
|
||||
end
|
||||
super processed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generator
|
||||
@generator ||= JavascriptGenerator.new(@options, @klass)
|
||||
end
|
||||
|
||||
def count?
|
||||
view_parameters[:reduce]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
# A view to return model instances with only some properties populated by searching its properties, e.g. for very large documents where you are only interested in some of their data
|
||||
#
|
||||
# example:
|
||||
# view :my_view, :key => :name, :properties => [:name, :author], :type => :properties
|
||||
class PropertiesViewSpec < ModelViewSpec
|
||||
def map_function
|
||||
map_body do
|
||||
"emit(#{formatted_key}, #{properties_for_map(properties)});"
|
||||
end
|
||||
end
|
||||
|
||||
def reduce_function
|
||||
<<-JS
|
||||
function(key, values, rereduce) {
|
||||
if(rereduce) {
|
||||
return sum(values);
|
||||
} else {
|
||||
return values.length;
|
||||
}
|
||||
}
|
||||
JS
|
||||
end
|
||||
|
||||
def process_results(results)
|
||||
results['rows'].map do |row|
|
||||
klass.json_create row['value'].merge(:_id => row['id'])
|
||||
end
|
||||
end
|
||||
|
||||
def view_parameters
|
||||
{:include_docs => false}.merge(super)
|
||||
end
|
||||
|
||||
def language
|
||||
:javascript
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def properties
|
||||
options[:properties]
|
||||
end
|
||||
|
||||
def properties_for_map(properties)
|
||||
'{' + properties.map{|p| "#{p}: doc.#{p}"}.join(', ') + '}'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module View
|
||||
# A view for custom map/reduce functions that returns the raw data from couchdb
|
||||
#
|
||||
# example:
|
||||
# view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :reduce => nil
|
||||
# optionally you can pass in a results filter which you can use to process the raw couchdb results before returning them
|
||||
#
|
||||
# example:
|
||||
# view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :results_filter => lambda{|results| results['rows'].map{|row| row['value']}}
|
||||
#
|
||||
# example:
|
||||
# view :my_custom_view, :map => "function(doc) { emit(doc._id, null); }", :type => :raw, :lib => {:module => "exports.name = 'module';"}
|
||||
class RawViewSpec < BaseViewSpec
|
||||
def map_function
|
||||
options[:map]
|
||||
end
|
||||
|
||||
def reduce_function
|
||||
options[:reduce]
|
||||
end
|
||||
|
||||
def lib
|
||||
options[:lib]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
require 'simply_based/model/views/array_property_view_spec'
|
||||
require 'simply_based/model/views/deleted_model_view_spec'
|
||||
@@ -0,0 +1,26 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Views
|
||||
class ArrayPropertyViewSpec < SimplyBased::Model::View::ModelViewSpec
|
||||
def map_function
|
||||
"function(doc) {
|
||||
if(doc.ruby_class && doc.ruby_class == '#{@klass.name}') {
|
||||
if (#{formatted_key(key)}.constructor.toString().match(/function Array()/)) {
|
||||
for (var i in #{formatted_key(key)}) {
|
||||
emit(#{formatted_key(key)}[i], 1);
|
||||
}
|
||||
} else {
|
||||
emit(#{formatted_key(key)}, 1);
|
||||
}
|
||||
}
|
||||
}"
|
||||
end
|
||||
|
||||
def key
|
||||
@options[:key]
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
module SimplyBased
|
||||
module Model
|
||||
module Views
|
||||
class DeletedModelViewSpec < SimplyBased::Model::View::ModelViewSpec
|
||||
def map_function
|
||||
<<-eos
|
||||
function(doc) {
|
||||
if (doc.ruby_class && doc.ruby_class == '#{@klass.name}') {
|
||||
if (doc['#{@klass.soft_delete_attribute}'] && doc['#{@klass.soft_delete_attribute}'] != null){
|
||||
// "soft" deleted
|
||||
}else{
|
||||
emit(doc.created_at, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
eos
|
||||
end
|
||||
|
||||
def reduce_function
|
||||
'_sum'
|
||||
end
|
||||
|
||||
def view_parameters
|
||||
_super = super
|
||||
if _super[:reduce]
|
||||
_super
|
||||
else
|
||||
{:include_docs => true, :reduce => false}.merge(_super)
|
||||
end
|
||||
end
|
||||
|
||||
def process_results(results)
|
||||
if count?
|
||||
results['rows'].first.try(:[], 'value') || 0
|
||||
else
|
||||
results['rows'].map { |row| row['doc'] || row['id'] }
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace :simply_couch do
|
||||
desc "delete all design documents"
|
||||
task :delete_design_documents do
|
||||
require File.dirname(__FILE__) + "/couch"
|
||||
if database = ENV['DATABASE']
|
||||
deleted = SimplyBased::Model.delete_all_design_documents(database)
|
||||
puts "deleted #{deleted} design documents in #{database}"
|
||||
else
|
||||
puts "please specify which database to clear: DATABASE=http://localhost:5984/simply_couch rake simply_couch:delete_design_documents"
|
||||
end
|
||||
end
|
||||
|
||||
desc "compact all design documents"
|
||||
task :compact_design_documents do
|
||||
require File.dirname(__FILE__) + "/couch"
|
||||
if database = ENV['DATABASE']
|
||||
compacted = SimplyBased::Model.compact_all_design_documents(database)
|
||||
puts "triggered compaction of #{compacted} design documents in #{database}"
|
||||
else
|
||||
puts "please specify which database to clear: DATABASE=http://localhost:5984/simply_couch rake simply_couch:delete_design_documents"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'base64'
|
||||
|
||||
module SimplyBased
|
||||
# Couchbase-backed replacements for the things apps usually reach to Redis for.
|
||||
# All three use the same cluster/bucket as your models — one datastore.
|
||||
|
||||
# Server-side atomic counters (Couchbase binary increment/decrement). A drop-in
|
||||
# for Redis INCR/DECR.
|
||||
#
|
||||
# counter = SimplyBased::Counter.new
|
||||
# counter.increment("jobs:processed") # => 1 (created at 0, +1)
|
||||
# counter.increment("jobs:processed", 5) # => 6
|
||||
# counter.get("jobs:processed") # => 6
|
||||
class Counter
|
||||
def initialize(store: nil)
|
||||
@store = store
|
||||
end
|
||||
|
||||
def store
|
||||
@store ||= SimplyBased.database
|
||||
end
|
||||
|
||||
def increment(key, by = 1, initial: 0, ttl: nil)
|
||||
store.counter_incr(key, by, initial: initial, ttl: ttl)
|
||||
end
|
||||
|
||||
def decrement(key, by = 1, initial: 0, ttl: nil)
|
||||
store.counter_decr(key, by, initial: initial, ttl: ttl)
|
||||
end
|
||||
|
||||
def get(key)
|
||||
v = store.kv_get(key)
|
||||
v.nil? ? nil : v.to_i
|
||||
end
|
||||
|
||||
def reset(key, to = 0, ttl: nil)
|
||||
store.kv_set(key, to, ttl: ttl)
|
||||
to
|
||||
end
|
||||
end
|
||||
|
||||
# An ActiveSupport::Cache::Store backed by Couchbase KV + native TTL — usable
|
||||
# as `Rails.cache` and therefore as the ActiveJob / fragment / low-level cache.
|
||||
#
|
||||
# Rails.application.config.cache_store = SimplyBased::Cache.new(namespace: "app")
|
||||
class Cache < ActiveSupport::Cache::Store
|
||||
def initialize(options = {})
|
||||
@store = options.delete(:store)
|
||||
@namespace = options.delete(:namespace)
|
||||
super(options)
|
||||
end
|
||||
|
||||
def store
|
||||
@store ||= SimplyBased.database
|
||||
end
|
||||
|
||||
def clear(**)
|
||||
# Namespaced clear would need a key scan; left to the app (or drop the
|
||||
# bucket in tests). KV TTLs expire entries automatically.
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_key(name, options = nil)
|
||||
k = super
|
||||
@namespace ? "#{@namespace}::#{k}" : k
|
||||
end
|
||||
|
||||
def read_entry(key, **)
|
||||
raw = store.kv_get(key)
|
||||
raw && Marshal.load(Base64.strict_decode64(raw))
|
||||
end
|
||||
|
||||
def write_entry(key, entry, **options)
|
||||
ttl = options[:expires_in] && options[:expires_in].to_i
|
||||
store.kv_set(key, Base64.strict_encode64(Marshal.dump(entry)), ttl: ttl)
|
||||
true
|
||||
end
|
||||
|
||||
def delete_entry(key, **)
|
||||
store.kv_delete(key)
|
||||
end
|
||||
end
|
||||
|
||||
# A distributed lock via insert-if-absent + TTL (auto-expiry) and CAS-checked
|
||||
# release, so only the holder can unlock. Replaces Redis SETNX-style locks.
|
||||
#
|
||||
# SimplyBased::Lock.new.with_lock("rebuild:report", ttl: 30) do
|
||||
# # critical section — only one process at a time
|
||||
# end
|
||||
class Lock
|
||||
def initialize(store: nil)
|
||||
@store = store
|
||||
end
|
||||
|
||||
def store
|
||||
@store ||= SimplyBased.database
|
||||
end
|
||||
|
||||
# Returns a CAS token (the lock handle) or nil if already held.
|
||||
def acquire(key, ttl: 30)
|
||||
store.kv_add(lock_key(key), { 'locked_at' => Time.now.to_i }, ttl: ttl)
|
||||
end
|
||||
|
||||
# Release only if we still hold the same CAS (no stomping a re-acquired lock).
|
||||
def release(key, cas = nil)
|
||||
store.kv_delete(lock_key(key), cas: cas)
|
||||
end
|
||||
|
||||
def with_lock(key, ttl: 30, retries: 0, wait: 0.1)
|
||||
cas = acquire(key, ttl: ttl)
|
||||
tries = 0
|
||||
while cas.nil? && tries < retries
|
||||
sleep wait
|
||||
tries += 1
|
||||
cas = acquire(key, ttl: ttl)
|
||||
end
|
||||
raise SimplyBased::Error, "could not acquire lock: #{key}" if cas.nil?
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
release(key, cas)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_key(key)
|
||||
"lock::#{key}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,460 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
require 'uri'
|
||||
require 'couchbase'
|
||||
|
||||
module SimplyBased
|
||||
# The Couchbase-native data store. Plays the role SimplyCouch's
|
||||
# Model::DatabaseInstance played, but speaks the SDK directly: key/value for
|
||||
# documents, N1QL/SQL++ for queries, CAS for optimistic concurrency. There is
|
||||
# no CouchDB design document or JavaScript map/reduce anywhere.
|
||||
#
|
||||
# v0 data model: one collection, every document tagged `ruby_class`; queries
|
||||
# filter on it and partial GSIs are scoped `WHERE ruby_class = '...'`. A future
|
||||
# `collection :name` per-model mode maps each class to its own collection.
|
||||
class Store
|
||||
attr_reader :cluster, :bucket_name, :scope_name, :collection_name
|
||||
|
||||
def initialize(connection = nil)
|
||||
cfg = parse_connection(connection)
|
||||
@bucket_name = cfg[:bucket]
|
||||
@scope_name = cfg[:scope] || '_default'
|
||||
@collection_name = cfg[:collection] || '_default'
|
||||
@cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password])
|
||||
@ensured_indexes = {}
|
||||
end
|
||||
|
||||
def keyspace
|
||||
"`#{bucket_name}`.`#{scope_name}`.`#{collection_name}`"
|
||||
end
|
||||
|
||||
# ── single document (KV) ────────────────────────────────────────────────
|
||||
|
||||
def save_document(document, validate = true)
|
||||
if document.new?
|
||||
create_document(document, validate)
|
||||
else
|
||||
update_document(document, validate)
|
||||
end
|
||||
rescue ::Couchbase::Error::CasMismatch, ::Couchbase::Error::DocumentExists
|
||||
raise SimplyBased::Conflict.new
|
||||
end
|
||||
|
||||
def save_document!(document)
|
||||
save_document(document) || raise("Validations failed: #{document.errors.full_messages}")
|
||||
end
|
||||
|
||||
def load_document(id)
|
||||
raise "Can't load a document without an id (got nil)" if id.nil?
|
||||
_track("GET #{id}")
|
||||
res = collection.get(id)
|
||||
hydrate(res.content, id, res.cas)
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def destroy_document(document, run_callbacks = true)
|
||||
remove = lambda do
|
||||
document._deleted = true
|
||||
collection.remove(document._id) if document._id
|
||||
end
|
||||
run_callbacks ? document.run_callbacks(:destroy) { remove.call } : remove.call
|
||||
document._id = nil
|
||||
document._rev = nil
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
document._id = nil; document._rev = nil
|
||||
end
|
||||
|
||||
def delete_document(document)
|
||||
collection.remove(document._id) if document._id
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
# ── bulk (batched KV) ───────────────────────────────────────────────────
|
||||
|
||||
def bulk_load(ids)
|
||||
ids = Array(ids).compact
|
||||
return [] if ids.empty?
|
||||
ids.filter_map { |id| load_document(id) }
|
||||
end
|
||||
|
||||
def bulk_save(documents, validate = true)
|
||||
documents = Array(documents)
|
||||
return { saved: [], invalid: [], failed: [] } if documents.empty?
|
||||
invalid = []; saved = []; failed = []; now = Time.now
|
||||
documents.each do |document|
|
||||
document.database = self
|
||||
document.created_at ||= now if document.respond_to?(:created_at) && document.new?
|
||||
document.updated_at = now if document.respond_to?(:updated_at=)
|
||||
if validate
|
||||
document.errors.clear
|
||||
(invalid << document; next) unless valid_document?(document)
|
||||
end
|
||||
begin
|
||||
persist(document)
|
||||
document.send(:clear_changes_information) if document.respond_to?(:clear_changes_information, true)
|
||||
saved << document
|
||||
rescue ::Couchbase::Error::CouchbaseError => e
|
||||
failed << [document, e.message]
|
||||
end
|
||||
end
|
||||
{ saved: saved, invalid: invalid, failed: failed }
|
||||
end
|
||||
|
||||
def bulk_destroy(documents)
|
||||
documents = Array(documents)
|
||||
return [] if documents.empty?
|
||||
rows = documents.map do |d|
|
||||
begin
|
||||
collection.remove(d._id) if d._id
|
||||
{ 'id' => d._id, 'ok' => true }
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
{ 'id' => d._id, 'error' => 'not_found' }
|
||||
end
|
||||
end
|
||||
documents.each { |d| d._id = nil; d._rev = nil }
|
||||
rows
|
||||
end
|
||||
|
||||
# ── view specs (finders / count) → N1QL ─────────────────────────────────
|
||||
# Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count).
|
||||
|
||||
def view(spec)
|
||||
kls = spec.klass
|
||||
vp = spec.view_parameters
|
||||
where = ["c.`ruby_class` = $rc"]
|
||||
params = { rc: kls.name }
|
||||
where << not_deleted(kls) if deleted_spec?(spec)
|
||||
keyexp = key_expr(spec.options[:key])
|
||||
where.concat(key_filters(keyexp, vp, params))
|
||||
|
||||
results =
|
||||
if vp[:reduce]
|
||||
[{ 'value' => run_count(where, params) }]
|
||||
else
|
||||
run_docs(where, params, keyexp, vp).map { |doc| { 'doc' => doc, 'id' => doc._id } }
|
||||
end
|
||||
processed = spec.process_results({ 'rows' => results })
|
||||
assign_db(processed)
|
||||
processed
|
||||
end
|
||||
|
||||
def first(spec)
|
||||
spec.view_parameters = spec.view_parameters.merge(limit: 1)
|
||||
Array(view(spec)).first
|
||||
end
|
||||
|
||||
# ── associations → N1QL (replaces JS join views) ────────────────────────
|
||||
# has_many / has_one / belongs_to: docs of `from_class` whose scalar foreign
|
||||
# key column equals `value`.
|
||||
def find_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
end
|
||||
|
||||
def count_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
end
|
||||
|
||||
# has_and_belongs_to_many: docs of `from_class` whose array column contains value.
|
||||
def find_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
end
|
||||
|
||||
def count_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
end
|
||||
|
||||
# ── admin ───────────────────────────────────────────────────────────────
|
||||
|
||||
def create_database!
|
||||
query("CREATE PRIMARY INDEX IF NOT EXISTS ON #{keyspace}")
|
||||
self
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
self
|
||||
end
|
||||
|
||||
# Clear all documents. Indexes survive (DELETE removes rows, not GSIs), so
|
||||
# the @ensured_indexes cache is intentionally kept — between test examples we
|
||||
# want to wipe data without rebuilding every association index.
|
||||
def drop_database!
|
||||
query("DELETE FROM #{keyspace}")
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
nil
|
||||
end
|
||||
|
||||
# ── raw KV primitives (used by Counter / Cache / Lock) ──────────────────
|
||||
|
||||
# Server-side atomic counter (Couchbase binary increment/decrement). Returns
|
||||
# the new value. Creates the key at `initial` if absent.
|
||||
def counter_incr(key, delta = 1, initial: 0, ttl: nil)
|
||||
o = ::Couchbase::Options::Increment.new(delta: delta, initial: initial)
|
||||
o.expiry = ttl if ttl
|
||||
collection.binary.increment(key, o).content
|
||||
end
|
||||
|
||||
def counter_decr(key, delta = 1, initial: 0, ttl: nil)
|
||||
o = ::Couchbase::Options::Decrement.new(delta: delta, initial: initial)
|
||||
o.expiry = ttl if ttl
|
||||
collection.binary.decrement(key, o).content
|
||||
end
|
||||
|
||||
def kv_set(key, value, ttl: nil)
|
||||
o = ::Couchbase::Options::Upsert.new
|
||||
o.expiry = ttl if ttl
|
||||
collection.upsert(key, value, o).cas
|
||||
end
|
||||
|
||||
# Insert only if absent — returns CAS, or nil if the key already exists.
|
||||
def kv_add(key, value, ttl: nil)
|
||||
o = ::Couchbase::Options::Insert.new
|
||||
o.expiry = ttl if ttl
|
||||
collection.insert(key, value, o).cas
|
||||
rescue ::Couchbase::Error::DocumentExists
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_get(key)
|
||||
collection.get(key).content
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_get_cas(key)
|
||||
r = collection.get(key)
|
||||
[r.content, r.cas]
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_delete(key, cas: nil)
|
||||
o = ::Couchbase::Options::Remove.new
|
||||
o.cas = cas if cas
|
||||
collection.remove(key, o)
|
||||
true
|
||||
rescue ::Couchbase::Error::DocumentNotFound, ::Couchbase::Error::CasMismatch
|
||||
false
|
||||
end
|
||||
|
||||
def query(statement, params = {})
|
||||
_track(statement)
|
||||
opts = ::Couchbase::Options::Query.new(named_parameters: params)
|
||||
opts.scan_consistency = :request_plus # read-your-writes; the suite expects it
|
||||
cluster.query(statement, opts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Record a database round-trip for the spec query-count matchers (no-op in
|
||||
# production, where $performed_queries is undefined).
|
||||
def _track(op)
|
||||
$performed_queries << { url: op } if defined?($performed_queries) && $performed_queries
|
||||
end
|
||||
|
||||
def collection
|
||||
@collection ||= cluster.bucket(bucket_name).scope(scope_name).collection(collection_name)
|
||||
end
|
||||
|
||||
# ── N1QL builders ────────────────────────────────────────────────────────
|
||||
|
||||
def run_docs(where, params, order_col, vp)
|
||||
sql = +"SELECT c.*, META(c).id AS `__id`, META(c).cas AS `__cas` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
|
||||
if order_col
|
||||
dir = vp[:descending] ? 'DESC' : 'ASC'
|
||||
# META().id tiebreaker keeps ordering deterministic (created_at is only
|
||||
# second-precision) so ascending and descending are exact reverses.
|
||||
sql << " ORDER BY #{order_col} #{dir}, META(c).id #{dir}"
|
||||
end
|
||||
sql << " LIMIT #{Integer(vp[:limit])}" if vp[:limit]
|
||||
sql << " OFFSET #{Integer(vp[:skip])}" if vp[:skip]
|
||||
query(sql, params).rows.to_a.map { |row| row_to_doc(row) }
|
||||
end
|
||||
|
||||
def run_count(where, params)
|
||||
sql = "SELECT COUNT(*) AS `value` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
|
||||
r = query(sql, params).rows.first
|
||||
(r && r['value']) || 0
|
||||
end
|
||||
|
||||
def key_expr(key)
|
||||
case key
|
||||
when nil then nil
|
||||
when Array then '[' + key.map { |k| "c.`#{k}`" }.join(', ') + ']'
|
||||
else "c.`#{key}`"
|
||||
end
|
||||
end
|
||||
|
||||
def key_filters(keyexp, vp, params)
|
||||
return [] unless keyexp
|
||||
if vp.key?(:key)
|
||||
params[:key] = vp[:key]; ["#{keyexp} = $key"]
|
||||
elsif vp.key?(:keys)
|
||||
params[:keys] = vp[:keys]; ["#{keyexp} IN $keys"]
|
||||
else
|
||||
low, high = vp[:descending] ? %i[endkey startkey] : %i[startkey endkey]
|
||||
f = []
|
||||
(params[:startkey] = vp[low]; f << "#{keyexp} >= $startkey") if vp.key?(low)
|
||||
(params[:endkey] = vp[high]; f << "#{keyexp} <= $endkey") if vp.key?(high)
|
||||
f
|
||||
end
|
||||
end
|
||||
|
||||
def order_opts(opts)
|
||||
{ descending: opts[:descending], limit: opts[:limit], skip: opts[:skip] }
|
||||
end
|
||||
|
||||
def not_deleted(kls)
|
||||
a = kls.soft_delete_attribute
|
||||
"(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)"
|
||||
end
|
||||
|
||||
def soft_deletes?(kls)
|
||||
kls.respond_to?(:soft_deleting_enabled?) && kls.soft_deleting_enabled?
|
||||
end
|
||||
|
||||
def deleted_spec?(spec)
|
||||
spec.class.name.end_with?('DeletedModelViewSpec')
|
||||
end
|
||||
|
||||
# Lazily create the GSIs an association needs — the N1QL analogue of
|
||||
# SimplyCouch creating a design document on first view query.
|
||||
def ensure_index(kls, columns)
|
||||
key = [kls.name, columns].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60]
|
||||
cols = columns.map { |col| "`#{col}`" }.join(', ')
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(#{cols}) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
end
|
||||
|
||||
def ensure_array_index(kls, column)
|
||||
key = [kls.name, :array, column].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60]
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(DISTINCT ARRAY x FOR x IN `#{column}` END) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
end
|
||||
|
||||
# ── persistence helpers ──────────────────────────────────────────────────
|
||||
|
||||
def create_document(document, validate)
|
||||
document.database = self
|
||||
if validate
|
||||
document.errors.clear
|
||||
return false if false == document.run_callbacks(:validation) do
|
||||
return false if false == document.run_callbacks(:validation_on_create) do
|
||||
return false unless valid_document?(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
return false if false == document.run_callbacks(:save) do
|
||||
return false if false == document.run_callbacks(:create) do
|
||||
document._id ||= SecureRandom.uuid
|
||||
res = collection.insert(document._id, document.to_hash)
|
||||
document._rev = res.cas.to_s
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def update_document(document, validate)
|
||||
if validate
|
||||
document.errors.clear
|
||||
return false if false == document.run_callbacks(:validation) do
|
||||
return false if false == document.run_callbacks(:validation_on_update) do
|
||||
return false unless valid_document?(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
return false if false == document.run_callbacks(:save) do
|
||||
return false if false == document.run_callbacks(:update) do
|
||||
persist(document) if document.changed?
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def persist(document)
|
||||
if document.new?
|
||||
document._id ||= SecureRandom.uuid
|
||||
_track("INSERT #{document._id}")
|
||||
res = collection.insert(document._id, document.to_hash)
|
||||
else
|
||||
opts = ::Couchbase::Options::Replace.new
|
||||
opts.cas = document._rev.to_i if document._rev
|
||||
_track("REPLACE #{document._id}")
|
||||
res = collection.replace(document._id, document.to_hash, opts)
|
||||
end
|
||||
document._rev = res.cas.to_s
|
||||
document._id
|
||||
end
|
||||
|
||||
def valid_document?(document)
|
||||
original = document.errors.to_hash
|
||||
document.valid?
|
||||
original.each { |k, v| Array(v).each { |m| document.errors.add(k, m) } }
|
||||
document.errors.empty?
|
||||
end
|
||||
|
||||
# ── hydration ────────────────────────────────────────────────────────────
|
||||
|
||||
def row_to_doc(row)
|
||||
id = row['__id'] || row['_id']
|
||||
cas = row['__cas'] # the document's CAS is its _rev surrogate
|
||||
doc = row.reject { |k, _| k == '__id' || k == '__cas' }
|
||||
doc['_id'] ||= id
|
||||
hydrate(doc, id, cas || doc['_rev'])
|
||||
end
|
||||
|
||||
def hydrate(content, id, rev)
|
||||
return content unless content.is_a?(Hash)
|
||||
hash = content.dup
|
||||
hash['_id'] ||= id
|
||||
hash['_rev'] = rev.to_s unless rev.nil?
|
||||
model = SimplyBased::DocumentCodec.from_hash(hash)
|
||||
model.database = self if model.respond_to?(:database=)
|
||||
model
|
||||
end
|
||||
|
||||
def assign_db(results)
|
||||
return unless results.respond_to?(:each)
|
||||
results.each { |d| d.database = self if d.respond_to?(:database=) }
|
||||
end
|
||||
|
||||
def parse_connection(connection)
|
||||
return connection if connection.is_a?(Hash)
|
||||
uri = URI.parse(connection.to_s)
|
||||
q = URI.decode_www_form(uri.query.to_s).to_h
|
||||
host = [uri.host, (":#{uri.port}" if uri.port)].compact.join
|
||||
{
|
||||
conn_str: "#{uri.scheme || 'couchbase'}://#{host}",
|
||||
user: uri.user && URI.decode_www_form_component(uri.user),
|
||||
password: uri.password && URI.decode_www_form_component(uri.password),
|
||||
bucket: uri.path.sub(%r{\A/}, ''),
|
||||
scope: q['scope'],
|
||||
collection: q['collection']
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user