diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..f616f6a1 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @doctolib/couchbase-guild diff --git a/couchbase-orm.gemspec b/couchbase-orm.gemspec index aad5fefc..752861c3 100644 --- a/couchbase-orm.gemspec +++ b/couchbase-orm.gemspec @@ -13,10 +13,10 @@ Gem::Specification.new do |gem| gem.required_ruby_version = '>= 2.1.0' gem.require_paths = ["lib"] - gem.add_runtime_dependency 'activemodel', ENV["ACTIVE_MODEL_VERSION"] || '>= 5.2' - gem.add_runtime_dependency 'activerecord', ENV["ACTIVE_MODEL_VERSION"] || '>= 5.2' + gem.add_runtime_dependency 'activemodel', ENV["ACTIVE_MODEL_VERSION"] || '>= 5.2', '< 7.1' + gem.add_runtime_dependency 'activerecord', ENV["ACTIVE_MODEL_VERSION"] || '>= 5.2', '< 7.1' - gem.add_runtime_dependency 'couchbase', '~> 3.3.0' + gem.add_runtime_dependency 'couchbase', '~> 3.4.2' gem.add_runtime_dependency 'radix', '~> 2.2' # converting numbers to and from any base gem.add_development_dependency 'rake', '~> 12.2' diff --git a/lib/couchbase-orm/associations.rb b/lib/couchbase-orm/associations.rb index 71514d5b..ce08b74d 100644 --- a/lib/couchbase-orm/associations.rb +++ b/lib/couchbase-orm/associations.rb @@ -32,6 +32,8 @@ def belongs_to(name, **options) val = if options[:polymorphic] ::CouchbaseOrm.try_load(self.send(ref)) else + raise ActiveRecord::StrictLoadingViolationError, "#{self.class} is marked as strict_loading and #{assoc} cannot be lazily loaded." if strict_loading? + assoc.constantize.find(self.send(ref), quiet: true) end instance_variable_set(instance_var, val) @@ -81,6 +83,8 @@ def has_and_belongs_to_many(name, **options) val = if options[:polymorphic] ::CouchbaseOrm.try_load(ref_value) if ref_value else + raise ActiveRecord::StrictLoadingViolationError, "#{self.class} is marked as strict_loading and #{assoc} cannot be lazily loaded." if strict_loading? + assoc.constantize.find(ref_value) if ref_value end val = Array.wrap(val || []) diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index 0f4d98cd..ddb2e943 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -123,7 +123,6 @@ class Document extend Enum define_model_callbacks :initialize, :only => :after - define_model_callbacks :create, :destroy, :save, :update Metadata = Struct.new(:cas) @@ -212,6 +211,8 @@ class Base < Document extend Index extend IgnoredProperties + define_model_callbacks :create, :destroy, :save, :update + class << self def connect(**options) @bucket = BucketProxy.new(::MTLibcouchbase::Bucket.new(**options)) @@ -241,7 +242,7 @@ def uuid_generator=(generator) @uuid_generator = generator end - def find(*ids, quiet: false) + def find(*ids, quiet: false, with_strict_loading: false) CouchbaseOrm.logger.debug { "Base.find(l##{ids.length}) #{ids}" } ids = ids.flatten.select { |id| id.present? } @@ -253,9 +254,14 @@ def find(*ids, quiet: false) records = quiet ? collection.get_multi(ids, transcoder: transcoder) : collection.get_multi!(ids, transcoder: transcoder) CouchbaseOrm.logger.debug { "Base.find found(#{records})" } records = records.zip(ids).map { |record, id| - self.new(record, id: id) if record - } - records.compact! + next unless record + next if record.error + new(record, id: id).tap do |instance| + if with_strict_loading + instance.strict_loading! + end + end + }.compact ids.length > 1 ? records : records[0] end diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb index 352ed30d..4dd5fe2d 100644 --- a/lib/couchbase-orm/n1ql.rb +++ b/lib/couchbase-orm/n1ql.rb @@ -8,6 +8,7 @@ module CouchbaseOrm module N1ql extend ActiveSupport::Concern NO_VALUE = :no_value_specified + DEFAULT_SCAN_CONSISTENCY = :request_plus # sanitize for injection query def self.sanitize(value) if value.is_a?(String) @@ -19,6 +20,13 @@ def self.sanitize(value) end end + def self.config(new_config = nil) + Thread.current['__couchbaseorm_n1ql_config__'] = new_config if new_config + Thread.current['__couchbaseorm_n1ql_config__'] || { + scan_consistency: DEFAULT_SCAN_CONSISTENCY + } + end + module ClassMethods # Defines a query N1QL for the model # @@ -49,7 +57,7 @@ def n1ql(name, query_fn: nil, emit_key: [], custom_order: nil, **options) @indexes[name] = method_opts singleton_class.__send__(:define_method, name) do |key: NO_VALUE, **opts, &result_modifier| - opts = options.merge(opts).reverse_merge(scan_consistency: :request_plus) + opts = options.merge(opts).reverse_merge(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]) values = key == NO_VALUE ? NO_VALUE : convert_values(method_opts[:emit_key], key) current_query = run_query(method_opts[:emit_key], values, query_fn, custom_order: custom_order, **opts.except(:include_docs, :key)) if result_modifier @@ -116,7 +124,7 @@ def run_query(keys, values, query_fn, custom_order: nil, descending: false, limi limit = build_limit(limit) n1ql_query = "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**options)) - CouchbaseOrm.logger.debug { "N1QL query: #{n1ql_query} return #{result.rows.to_a.length} rows" } + CouchbaseOrm.logger.debug "N1QL query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{options[:scan_consistency]}" N1qlProxy.new(result) end end diff --git a/lib/couchbase-orm/persistence.rb b/lib/couchbase-orm/persistence.rb index efa6f8ec..a3704cc0 100644 --- a/lib/couchbase-orm/persistence.rb +++ b/lib/couchbase-orm/persistence.rb @@ -209,7 +209,7 @@ def reload CouchbaseOrm.logger.debug "Data - Get #{id}" resp = self.class.collection.get!(id) - assign_attributes(decode_encrypted_attributes(resp.content.except("id"))) # API return a nil id + assign_attributes(decode_encrypted_attributes(resp.content.except("id", *self.class.ignored_properties ))) # API return a nil id @__metadata__.cas = resp.cas reset_associations diff --git a/lib/couchbase-orm/proxies/collection_proxy.rb b/lib/couchbase-orm/proxies/collection_proxy.rb index 9ddf54c6..5492994c 100644 --- a/lib/couchbase-orm/proxies/collection_proxy.rb +++ b/lib/couchbase-orm/proxies/collection_proxy.rb @@ -21,8 +21,7 @@ def get_multi!(*ids, **options) end def get_multi(*ids, **options) - result = @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) - result.reject(&:error) + @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) end def remove!(id, **options) diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb index 8a92ece7..006b8314 100644 --- a/lib/couchbase-orm/relation.rb +++ b/lib/couchbase-orm/relation.rb @@ -3,19 +3,20 @@ module Relation extend ActiveSupport::Concern class CouchbaseOrm_Relation - def initialize(model:, where: where = nil, order: order = nil, limit: limit = nil, _not: _not = false) - CouchbaseOrm::logger.debug "CouchbaseOrm_Relation init: #{model} where:#{where.inspect} not:#{_not.inspect} order:#{order.inspect} limit: #{limit}" + def initialize(model:, where: where = nil, order: order = nil, limit: limit = nil, _not: _not = false, strict_loading: strict_loading = false) + CouchbaseOrm::logger.debug "CouchbaseOrm_Relation init: #{model} where:#{where.inspect} not:#{_not.inspect} order:#{order.inspect} limit: #{limit} strict_loading: #{strict_loading}" @model = model @limit = limit @where = [] @order = {} @order = merge_order(**order) if order @where = merge_where(where, _not) if where + @strict_loading = strict_loading CouchbaseOrm::logger.debug "- #{to_s}" end def to_s - "CouchbaseOrm_Relation: #{@model} where:#{@where.inspect} order:#{@order.inspect} limit: #{@limit}" + "CouchbaseOrm_Relation: #{@model} where:#{@where.inspect} order:#{@order.inspect} limit: #{@limit} strict_loading: #{@strict_loading}" end def to_n1ql @@ -27,8 +28,8 @@ def to_n1ql end def execute(n1ql_query) - result = @model.cluster.query(n1ql_query, Couchbase::Options::Query.new(scan_consistency: :request_plus)) - CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} return #{result.rows.to_a.length} rows" } + result = @model.cluster.query(n1ql_query, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) + CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{CouchbaseOrm::N1ql.config[:scan_consistency]}" } N1qlProxy.new(result) end @@ -51,16 +52,25 @@ def ids query.to_a end + def strict_loading + CouchbaseOrm_Relation.new(**initializer_arguments.merge(strict_loading: true)) + end + + def strict_loading? + !!@strict_loading + end + def first - result = @model.cluster.query(self.limit(1).to_n1ql, Couchbase::Options::Query.new(scan_consistency: :request_plus)) - first_id = result.rows.to_a.first - @model.find(first_id) if first_id + result = @model.cluster.query(self.limit(1).to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) + return unless (first_id = result.rows.to_a.first) + + @model.find(first_id, with_strict_loading: @strict_loading) end def last - result = @model.cluster.query(to_n1ql, Couchbase::Options::Query.new(scan_consistency: :request_plus)) + result = @model.cluster.query(to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) last_id = result.rows.to_a.last - @model.find(last_id) if last_id + @model.find(last_id, with_strict_loading: @strict_loading) if last_id end def count @@ -89,7 +99,7 @@ def pluck(*fields) def to_ary ids = query.results return [] if ids.empty? - Array(ids && @model.find(ids)) + Array(ids && @model.find(ids, with_strict_loading: @strict_loading)) end alias :to_a :to_ary @@ -146,7 +156,7 @@ def build_limit end def initializer_arguments - { model: @model, order: @order, where: @where, limit: @limit } + { model: @model, order: @order, where: @where, limit: @limit, strict_loading: @strict_loading } end def merge_order(*lorder, **horder) @@ -233,7 +243,7 @@ def relation delegate :ids, :update_all, :delete_all, :count, :empty?, :filter, :reduce, :find_by, to: :all - delegate :where, :not, :order, :limit, :all, to: :relation + delegate :where, :not, :order, :limit, :all, :strict_loading, :strict_loading?, to: :relation end end end diff --git a/lib/couchbase-orm/utilities/ignored_properties.rb b/lib/couchbase-orm/utilities/ignored_properties.rb index 7ef5cfd9..86ac8386 100644 --- a/lib/couchbase-orm/utilities/ignored_properties.rb +++ b/lib/couchbase-orm/utilities/ignored_properties.rb @@ -1,9 +1,15 @@ module CouchbaseOrm module IgnoredProperties + def ignored_properties=(properties) + @@ignored_properties = properties.map(&:to_s) + end + def ignored_properties(*args) + if args.any? + CouchbaseOrm.logger.warn('Passing aruments to `.ignored_properties` is deprecated. PLease use `.ignored_properties=` intead.') + return send :ignored_properties=, args + end @@ignored_properties ||= [] - return @@ignored_properties if args.empty? - @@ignored_properties += args.map(&:to_s) end end end diff --git a/lib/couchbase-orm/views.rb b/lib/couchbase-orm/views.rb index b2e55493..20135086 100644 --- a/lib/couchbase-orm/views.rb +++ b/lib/couchbase-orm/views.rb @@ -66,7 +66,7 @@ def view(name, map: nil, emit_key: nil, reduce: nil, **options) @views[name] = method_opts singleton_class.__send__(:define_method, name) do |**opts, &result_modifier| - opts = options.merge(opts).reverse_merge(scan_consistency: :request_plus) + opts = options.merge(opts).reverse_merge(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]) CouchbaseOrm.logger.debug("View [#{@design_document}, #{name.inspect}] options: #{opts.inspect}") if result_modifier include_docs(bucket.view_query(@design_document, name.to_s, Couchbase::Options::View.new(**opts.except(:include_docs)))).map(&result_modifier) diff --git a/spec/associations_spec.rb b/spec/associations_spec.rb index ff45ed1d..9cebe410 100644 --- a/spec/associations_spec.rb +++ b/spec/associations_spec.rb @@ -5,6 +5,13 @@ class Parent < CouchbaseOrm::Base attribute :name + has_and_belongs_to_many :children +end + +class StrictLoadingParent < CouchbaseOrm::Base + attribute :name + has_and_belongs_to_many :children + self.strict_loading_by_default = true end class RandomOtherType < CouchbaseOrm::Base @@ -209,4 +216,47 @@ class Part < CouchbaseOrm::Base it_behaves_like "ActiveModel" end end + + describe 'strict_loading' do + let(:parent) {Parent.create!(name: 'joe')} + let(:child) {Child.create!(name: 'bob', parent_id: parent.id)} + context 'instance strict loading' do + it 'raises StrictLoadingViolationError on lazy loading child relation' do + expect {child.parent.id}.not_to raise_error + expect_strict_loading_error_on_calling_parent(Child.find(child.id).tap{|child| child.strict_loading!}) + end + end + context 'scope strict loading' do + it 'raises StrictLoadingViolationError on lazy loading child relation' do + expect_strict_loading_error_on_calling_parent(Child.where(id: child.id).strict_loading.first) + expect_strict_loading_error_on_calling_parent(Child.strict_loading.where(id: child.id).first) + expect_strict_loading_error_on_calling_parent(Child.strict_loading.where(id: child.id).last) + expect_strict_loading_error_on_calling_parent(Child.strict_loading.where(id: child.id).to_a.first) + expect_strict_loading_error_on_calling_parent(Child.strict_loading.all.to_a.first) + end + + it 'does not raise StrictLoadingViolationError on lazy loading child relation without declaring it' do + expect_strict_loading_error_on_calling_parent(Child.strict_loading.where(id: child.id).first) + expect { Child.where(id: child.id).last.parent}.not_to raise_error + end + + it 'raises StrictLoadingViolationError on lazy loading habtm relation' do + expect {Parent.strict_loading.where(id: parent.id).first.children}.to raise_error(ActiveRecord::StrictLoadingViolationError) + # NB any action called on model class breaks find return type (find return an enumerator instead of a record) + expect {Parent.strict_loading.find(parent.id).first.children}.to raise_error(ActiveRecord::StrictLoadingViolationError) + end + + it 'raises StrictLoadingViolationError on lazy loading relation when model is by default strict_loading' do + strict_loading_parent = StrictLoadingParent.create!(name: 'joe') + expect {StrictLoadingParent.where(id: strict_loading_parent.id).first.children}.to raise_error(ActiveRecord::StrictLoadingViolationError) + expect {Parent.find(parent.id).children}.not_to raise_error + # NB any action called on model class breaks find return type (find return an enumerator instead of a record) + expect {Parent.strict_loading.find(strict_loading_parent.id).first.children}.to raise_error(ActiveRecord::StrictLoadingViolationError) + end + end + end + + def expect_strict_loading_error_on_calling_parent(child_instance) + expect {child_instance.parent}.to raise_error(ActiveRecord::StrictLoadingViolationError) + end end diff --git a/spec/base_spec.rb b/spec/base_spec.rb index f2e33d2c..2391c441 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -17,7 +17,7 @@ class TimestampTest < CouchbaseOrm::Base end class BaseTestWithIgnoredProperties < CouchbaseOrm::Base - ignored_properties :deprecated_property + self.ignored_properties += [:deprecated_property] attribute :name, :string attribute :job, :string end @@ -212,6 +212,27 @@ class BaseTestWithIgnoredProperties < CouchbaseOrm::Base expect(base.created_at).to be_a(Time) end + it "should find multiple ids at same time" do + base1 = BaseTest.create!(name: 'joe1') + base2 = BaseTest.create!(name: 'joe2') + base3 = BaseTest.create!(name: 'joe3') + expect(BaseTest.find([base1.id, base2.id, base3.id])).to eq([base1, base2, base3]) + end + + it "should find multiple ids at same time with a not found id with exception" do + base1 = BaseTest.create!(name: 'joe1') + base2 = BaseTest.create!(name: 'joe2') + base3 = BaseTest.create!(name: 'joe3') + expect { BaseTest.find([base1.id, 't', base3.id]) }.to raise_error(Couchbase::Error::DocumentNotFound) + end + + it "should find multiple ids at same time with a not found id without exception" do + base1 = BaseTest.create!(name: 'joe1') + base2 = BaseTest.create!(name: 'joe2') + base3 = BaseTest.create!(name: 'joe3') + expect(BaseTest.find([base1.id, 't', 't', base2.id, base3.id], quiet: true)).to eq([base1, base2, base3]) + end + describe BaseTest do it_behaves_like "ActiveModel" end @@ -220,6 +241,15 @@ class BaseTestWithIgnoredProperties < CouchbaseOrm::Base it_behaves_like "ActiveModel" end + it 'does not expose callbacks for nested that wont never be called' do + expect{ + class InvalidNested < CouchbaseOrm::NestedDocument + before_save {p "this should raise on loading class"} + end + + }.to raise_error NoMethodError + end + describe '.ignored_properties' do @@ -254,6 +284,10 @@ class BaseTestWithIgnoredProperties < CouchbaseOrm::Base from(%w[deprecated_property job name type]). to(%w[job name type]) end + + it 'does not raise for reload' do + expect{ loaded_model.reload }.not_to raise_error + end end end end diff --git a/spec/n1ql_spec.rb b/spec/n1ql_spec.rb index 1418be31..c4266559 100644 --- a/spec/n1ql_spec.rb +++ b/spec/n1ql_spec.rb @@ -170,6 +170,26 @@ class N1QLTest < CouchbaseOrm::Base expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) end + it "should log the default scan_consistency when n1ql query is executed" do + allow(CouchbaseOrm.logger).to receive(:debug) + N1QLTest.by_rating_reverse() + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : #{described_class::DEFAULT_SCAN_CONSISTENCY}") + end + + it "should log the set scan_consistency when n1ql query is executed with a specific scan_consistency" do + allow(CouchbaseOrm.logger).to receive(:debug) + default_n1ql_config = CouchbaseOrm::N1ql.config + CouchbaseOrm::N1ql.config({ scan_consistency: :not_bounded }) + puts "CouchbaseOrm::N1ql.config" + puts CouchbaseOrm::N1ql.config + N1QLTest.by_rating_reverse() + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : not_bounded") + + CouchbaseOrm::N1ql.config(default_n1ql_config) + N1QLTest.by_rating_reverse() + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : #{described_class::DEFAULT_SCAN_CONSISTENCY}") + end + after(:all) do N1QLTest.delete_all end