SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM

A from-scratch fork of simply_couch's model layer that runs on Couchbase
natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`.

- SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++
  finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or
  JavaScript map/reduce — the hand-written association views become N1QL
  (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array
  index, or KV multi-get on the storing side).
- Redis replacements on the same cluster: Counter (atomic increment/decrement),
  Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob),
  Lock (insert-if-absent + TTL + CAS-checked release).
- Runs the simply_couch test suite against live Couchbase: 378/432 applicable
  examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded).
- Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded
  N1QL) and a few pagination/lifecycle edges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
GregandClaude Opus 4.8 committed 2026-06-20 13:58:47 -05:00
commit 86299b8827
84 files changed
+9904

No files matched your search

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