Files
simply_based/lib/simply_based/model/embedded_in.rb
T
bterkuileandClaude Sonnet 5 1beb8ead11 Fix embedded documents: recursion crash, .all/.count, honest spec exclusion
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>
2026-07-06 18:35:51 -05:00

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