diff --git a/.travis.yml b/.travis.yml index 79e5cb28..6fc9f40e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: ruby rvm: - ruby-2.4.2 - - ruby-2.3.5 + - ruby-2.5.7 - ruby-head - - jruby-9.1.13.0 + - jruby-9.2.9.0 - jruby-head - rubinius - rubinius-3.86 @@ -24,6 +24,7 @@ before_install: - /opt/couchbase/bin/couchbase-cli bucket-create -c 127.0.0.1:8091 -u admin -p password --bucket=default --bucket-type=couchbase --bucket-ramsize=160 --bucket-replica=0 --wait - sleep 1 - /opt/couchbase/bin/couchbase-cli user-manage -c 127.0.0.1:8091 -u admin -p password --set --rbac-username tester --rbac-password password123 --rbac-name "Auto Tester" --roles admin --auth-domain local + - curl http://admin:password@localhost:8093/query/service -d 'statement=CREATE INDEX `default_type` ON `default`(`type`)' - export TRAVIS_TEST=true matrix: allow_failures: diff --git a/Gemfile b/Gemfile index c35cc324..1f15ea88 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,4 @@ source "https://rubygems.org" gemspec gem "rubysl", :platform => :rbx +gem 'libcouchbase', github: 'Mapotempo/libcouchbase', tag: '1.3.2-mapo' \ No newline at end of file diff --git a/couchbase-orm.gemspec b/couchbase-orm.gemspec index 58281f19..c2f69a8a 100644 --- a/couchbase-orm.gemspec +++ b/couchbase-orm.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |gem| gem.required_ruby_version = '>= 2.1.0' gem.require_paths = ["lib"] - gem.add_runtime_dependency 'libcouchbase', '~> 1.2' + # gem.add_runtime_dependency 'libcouchbase', '~> 1.2' gem.add_runtime_dependency 'activemodel', '>= 4.0', '< 6.0' gem.add_runtime_dependency 'radix', '~> 2.2' # converting numbers to and from any base diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index eeb28645..88822a29 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -5,6 +5,7 @@ require 'active_support/hash_with_indifferent_access' require 'couchbase-orm/error' require 'couchbase-orm/views' +require 'couchbase-orm/n1ql' require 'couchbase-orm/persistence' require 'couchbase-orm/associations' require 'couchbase-orm/utilities/join' @@ -28,6 +29,7 @@ class Base include Persistence include Associations include Views + include N1ql extend Join extend Enum @@ -225,7 +227,7 @@ def attribute(name) @__attributes__[name] end alias_method :read_attribute_for_serialization, :attribute - + def attribute=(name, value) __send__(:"#{name}=", value) end diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb new file mode 100644 index 00000000..241d4416 --- /dev/null +++ b/lib/couchbase-orm/n1ql.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'active_model' +require 'active_support/core_ext/array/wrap' +require 'active_support/core_ext/object/try' + +module CouchbaseOrm + module N1ql + extend ActiveSupport::Concern + # Defines a query N1QL for the model + # # + # # @param [Symbol, String, Array] names names of the views + # # @param [Hash] options options passed to the {Couchbase::N1QL} + # # + # # @example Define some views for a model + # # class Post < CouchbaseOrm::Base + # # n1ql :all + # # n1ql :by_rating, emit_key: :rating + # # end + # # + # # Post.by_rating.stream do |response| + # # # ... + # # end + # TODO: add range keys [:startkey, :endkey] + module ClassMethods + def n1ql(name, query: nil, emit_key: [], **options) + emit_key = Array.wrap(emit_key) + emit_key.each do |key| + raise "unknown emit_key attribute for n1ql :#{name}, emit_key: :#{key}" if key && @attributes[key].nil? + end + options = VIEW_DEFAULTS.merge(options) + method_opts = {} + method_opts[:emit_key] = emit_key + + @indexes ||= {} + @indexes[name] = method_opts + + singleton_class.__send__(:define_method, name) do |**opts, &result_modifier| + opts = options.merge(opts) + + values = convert_values(opts[:key]) + current_query = build_query(method_opts[:emit_key], values, query, opts) + + if result_modifier + opts[:include_docs] = true + current_query.results &result_modifier + elsif opts[:include_docs] + current_query.results { |res| + find(res) + } + else + current_query.results + end + end + end + + # add a view and lookup method to the model for finding all records + # using a value in the supplied attr. + def index_n1ql(attr, validate: true, find_method: nil, n1ql_method: nil) + n1ql_method ||= "by_#{attr}" + find_method ||= "find_#{n1ql_method}" + + validates(attr, presence: true) if validate + n1ql n1ql_method, emit_key: attr + + instance_eval " + def self.#{find_method}(#{attr}) + #{n1ql_method}(key: #{attr}) + end + " + end + + VIEW_DEFAULTS = { include_docs: true } + + private + + # TODO: secure it for injection query + def convert_values(values) + Array.wrap(values).compact.map do |v| + if v.class == String + "\"#{v}\"" + elsif v.class == Date || v.class == Time + "\"#{v.iso8601(3)}\"" + else + v.to_s + end + end + end + + def build_where(keys, values) + where = keys.each_with_index + .reject { |key, i| values.try(:[], i).nil? } + .map { |key, i| "#{key} = #{values[i] }" } + .join(" and ") + "type=\"#{design_document}\" #{"and " + where unless where.blank?}" + end + + # + # order-by-clause ::= ORDER BY ordering-term [ ',' ordering-term ]* + # ordering-term ::= expr [ ASC | DESC ] [ NULLS ( FIRST | LAST ) ] + # see https://docs.couchbase.com/server/5.0/n1ql/n1ql-language-reference/orderby.html + def build_order(keys, descending) + "#{keys.dup.push("meta().id").map { |k| "#{k} #{descending ? "desc" : "asc" }" }.join(",")}" + end + + def build_query(keys, values, query, descending: false, limit: nil, **options) + if query + query.call(bucket, values) + else + bucket_name = bucket.bucket + where = build_where(keys, values) + order = build_order(keys, descending) + query = bucket.n1ql + .select("raw meta().id") + .from("`#{bucket_name}`") + .where(where) + if order + query = query.order_by(order) + end + if limit + query = query.limit(limit) + end + query + end + end + end + end +end diff --git a/lib/couchbase-orm/utilities/has_many.rb b/lib/couchbase-orm/utilities/has_many.rb index d68a28dc..220b2227 100644 --- a/lib/couchbase-orm/utilities/has_many.rb +++ b/lib/couchbase-orm/utilities/has_many.rb @@ -1,73 +1,106 @@ module CouchbaseOrm module HasMany - private - # :foreign_key, :class_name, :through - def has_many(model, class_name: nil, foreign_key: nil, through: nil, through_class: nil, through_key: nil, **options) - class_name = (class_name || model.to_s.singularize.camelcase).to_s + def has_many(model, class_name: nil, foreign_key: nil, through: nil, through_class: nil, through_key: nil, type: :view, **options) + class_name = (class_name || model.to_s.singularize.camelcase).to_s foreign_key = (foreign_key || ActiveSupport::Inflector.foreign_key(self.name)).to_sym + if through || through_class + remote_class = class_name + class_name = (through_class || through.to_s.camelcase).to_s + through_key = (through_key || "#{remote_class.underscore}_id").to_sym + remote_method = :"by_#{foreign_key}_with_#{through_key}" + else + remote_method = :"find_by_#{foreign_key}" + end - if through || through_class - remote_class = class_name - class_name = (through_class || through.to_s.camelcase).to_s - through_key = (through_key || "#{remote_class.underscore}_id").to_sym - remote_method = :"by_#{foreign_key}_with_#{through_key}" - else - remote_method = :"find_by_#{foreign_key}" - end + relset_varname = "@#{model}_rel_set" - relset_varname = "@#{model}_rel_set" + klass = begin + class_name.constantize + rescue NameError => e + puts "WARNING: #{class_name} referenced in #{self.name} before it was aded" - klass = begin - class_name.constantize - rescue NameError => e - puts "WARNING: #{class_name} referenced in #{self.name} before it was loaded" + # Open the class early - load order will have to be changed to prevent this. + # Warning notice required as a misspelling will not raise an error + Object.class_eval <<-EKLASS + class #{class_name} < CouchbaseOrm::Base + attribute :#{foreign_key} + end + EKLASS + class_name.constantize + end + + build_index(type, klass, remote_class, remote_method, through_key, foreign_key) - # Open the class early - load order will have to be changed to prevent this. - # Warning notice required as a misspelling will not raise an error - Object.class_eval <<-EKLASS - class #{class_name} < CouchbaseOrm::Base - attribute :#{foreign_key} + if remote_class + define_method(model) do + return self.instance_variable_get(relset_varname) if instance_variable_defined?(relset_varname) + + remote_klass = remote_class.constantize + enum = klass.__send__(remote_method, key: self.id) { |row| + case type + when :n1ql + remote_klass.find(row) + else + remote_klass.find(row.value[through_key]) end - EKLASS - class_name.constantize + } + + self.instance_variable_set(relset_varname, enum) + end + else + define_method(model) do + return self.instance_variable_get(relset_varname) if instance_variable_defined?(relset_varname) + self.instance_variable_set(relset_varname, klass.__send__(remote_method, self.id)) end + end + @associations ||= [] + @associations << [model, options[:dependent]] + end + def build_index(type, klass, remote_class, remote_method, through_key, foreign_key) + case type + when :n1ql + build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_key) + else + build_index_view(klass, remote_class, remote_method, through_key, foreign_key) + end + end + + def build_index_view(klass, remote_class, remote_method, through_key, foreign_key) if remote_class klass.class_eval do - view remote_method, map: <<-EMAP - function(doc) { - if (doc.type === "{{design_document}}" && doc.#{through_key}) { - emit(doc.#{foreign_key}, null); - } - } -EMAP - end - - define_method(model) do - return self.instance_variable_get(relset_varname) if instance_variable_defined?(relset_varname) - - remote_klass = remote_class.constantize - enum = klass.__send__(remote_method, key: self.id) { |row| - remote_klass.find(row.value[through_key]) + view remote_method, map: <<-EMAP + function(doc) { + if (doc.type === "{{design_document}}" && doc.#{through_key}) { + emit(doc.#{foreign_key}, null); + } } - - self.instance_variable_set(relset_varname, enum) + EMAP end else klass.class_eval do - index_view foreign_key, validate: false + index_view foreign_key, validate: false end + end + end - define_method(model) do - return self.instance_variable_get(relset_varname) if instance_variable_defined?(relset_varname) - self.instance_variable_set(relset_varname, klass.__send__(remote_method, self.id)) + def build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_key) + if remote_class + klass.class_eval do + n1ql remote_method, query: proc { |bucket, values| + bucket_name = bucket.bucket + bucket.n1ql.select("raw #{through_key}") + .from("`#{bucket_name}`") + .where("type=\"#{design_document}\" and #{foreign_key} = #{values[0]}") + } + end + else + klass.class_eval do + index_n1ql foreign_key, validate: false end end - - @associations ||= [] - @associations << [model, options[:dependent]] end - end + end end diff --git a/spec/has_many_spec.rb b/spec/has_many_spec.rb index fad9ad09..847cbe91 100644 --- a/spec/has_many_spec.rb +++ b/spec/has_many_spec.rb @@ -80,3 +80,81 @@ class ObjectTest < CouchbaseOrm::Base expect(docs).to eq(['bob', 'jane']) end end + + +class ObjectRatingN1qlTest < CouchbaseOrm::Base + join :object_n1ql_test, :rating_n1ql_test + view :all +end + +class RatingN1qlTest < CouchbaseOrm::Base + enum rating: [:awesome, :good, :okay, :bad], default: :okay + belongs_to :object_n1ql_test + + has_many :object_n1ql_tests, through: :object_rating_n1ql_test, type: :n1ql + view :all +end + +class ObjectN1qlTest < CouchbaseOrm::Base + attribute :name, type: String + has_many :rating_n1ql_tests, dependent: :destroy, type: :n1ql + view :all +end + + +describe CouchbaseOrm::HasMany do + before :all do + RatingN1qlTest.ensure_design_document! + ObjectN1qlTest.ensure_design_document! + ObjectRatingN1qlTest.ensure_design_document! + end + + after :each do + ObjectN1qlTest.all.stream { |ob| ob.delete } + RatingN1qlTest.all.stream { |ob| ob.delete } + ObjectRatingN1qlTest.all.stream { |ob| ob.delete } + end + + it "should return matching results" do + first = ObjectN1qlTest.create! name: :bob + second = ObjectN1qlTest.create! name: :jane + + rate = RatingN1qlTest.create! rating: :awesome, object_n1ql_test: first + RatingN1qlTest.create! rating: :bad, object_n1ql_test: second + RatingN1qlTest.create! rating: :good, object_n1ql_test: first + + expect(rate.object_n1ql_test_id).to eq(first.id) + expect(RatingN1qlTest.respond_to?(:find_by_object_n1ql_test_id)).to be(true) + expect(first.respond_to?(:rating_n1ql_tests)).to be(true) + + docs = first.rating_n1ql_tests.collect { |ob| + ob.rating + } + + expect(docs).to eq([1, 2]) + + first.destroy + expect { RatingN1qlTest.find rate.id }.to raise_error(::Libcouchbase::Error::KeyNotFound) + expect(RatingN1qlTest.all.count).to be(1) + end + + it "should work through a join model" do + first = ObjectN1qlTest.create! name: :bob + second = ObjectN1qlTest.create! name: :jane + + rate1 = RatingN1qlTest.create! rating: :awesome, object_n1ql_test: first + rate2 = RatingN1qlTest.create! rating: :bad, object_n1ql_test: second + rate3 = RatingN1qlTest.create! rating: :good, object_n1ql_test: first + + ort = ObjectRatingN1qlTest.create! object_n1ql_test: first, rating_n1ql_test: rate1 + ObjectRatingN1qlTest.create! object_n1ql_test: second, rating_n1ql_test: rate1 + + expect(ort.rating_n1ql_test_id).to eq(rate1.id) + expect(rate1.respond_to?(:object_n1ql_tests)).to be(true) + docs = rate1.object_n1ql_tests.collect { |ob| + ob.name + } + + expect(docs).to eq(['bob', 'jane']) + end +end