Live-run finding: the full suite against Couchbase was 423 examples with 17 failures — all in embedding_spec. The session report claimed embedded specs were 'excluded by design', but the exclusion filter targeted a :embedding tag no example carries, so the file ran (and failed) all along. Three fixes: - HasManyEmbedded::Property#build wired parent_object before installing the collection ivar on the parent; parent_object= reaches back into the parent's collection getter, which re-entered build -> SystemStackError on any access to embedded documents loaded from the database. Install the ivar first. - EmbeddedClass.all / .count referenced raw JS views (all_documents / all_documents_for_count) that the N1QL port silently drops, so .all returned [] and .count raised NoMethodError. Rebuilt them by enumerating the parent documents (same work as the old view scan, client-side; an UNNEST query can replace it later). - Only reverse lookups (N1QL on a foreign key inside an embedded array) remain unsupported: tagged :embedded_in_reverse_lookup and excluded in spec_helper with an explanatory comment, replacing the broken :embedding filter line. embedding_spec.rb: 16 examples, 0 failures against live Couchbase 8.0.1 CE. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
9.3 KiB
Ruby
213 lines
9.3 KiB
Ruby
# 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
|
|
|
|
# The CouchDB implementation used raw JS views (all_documents /
|
|
# all_documents_for_count) that emitted one row per embedded
|
|
# element. Those views are no-ops on Couchbase (see
|
|
# ClassMethods::Base#view), so enumerate through the parent
|
|
# documents instead: embedded records only exist inside their
|
|
# parent, and the parent getter wires parent_object/index.
|
|
# NOTE: this loads all parent documents — same order of work as
|
|
# the CouchDB view scan, but client-side. Fine for small/medium
|
|
# collections; revisit with an UNNEST query if it ever matters.
|
|
define_method :all do |*args|
|
|
parent_klass = Object.const_get(embedded_in_class_name)
|
|
embedded_property = self.name.property_name.pluralize
|
|
parent_klass.all.flat_map { |p| Array.wrap(p.send(embedded_property)) }
|
|
end
|
|
|
|
define_method :count do |options = {}|
|
|
all.size
|
|
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
|