diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 00000000..85c5486a --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,21 @@ +name: Linters + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + rubocop: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + - name: Install dependencies + run: bundle install + - name: Run rubocop + run: bundle exec rubocop diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..3927994e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,39 @@ +inherit_from: .rubocop_todo.yml +require: + - rubocop-rspec + - rubocop-performance + - rubocop-rake + - rubocop-thread_safety + +AllCops: + TargetRubyVersion: 2.6 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'vendor/**/*' + +Layout/LineLength: + Max: 170 + +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: no_space + EnforcedStyleForEmptyBraces: no_space + +Style/Lambda: + EnforcedStyle: lambda + +Style/SymbolArray: + EnforcedStyle: brackets + +Style/RegexpLiteral: + EnforcedStyle: mixed + AllowInnerSlashes: true + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 00000000..d91d6ff4 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,92 @@ +# TODO: gradually revisit these settings. +# Ideally the code should be refactored so that this file will be empty + +Metrics/ParameterLists: + Max: 30 + +Metrics/BlockNesting: + Max: 4 + +Metrics/ModuleLength: + Max: 150 + +Metrics/ClassLength: + Max: 150 + +Metrics/MethodLength: + Max: 80 + +RSpec/ExampleLength: + Max: 40 + +RSpec/MultipleExpectations: + Max: 20 + +RSpec/InstanceVariable: + Enabled: false + +RSpec/LeakyConstantDeclaration: + Enabled: false + +RSpec/MultipleDescribes: + Enabled: false + +RSpec/BeforeAfterAll: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +Style/Documentation: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +ThreadSafety/InstanceVariableInClassMethod: + Enabled: false + +ThreadSafety/NewThread: + Enabled: false + +Lint/UnderscorePrefixedVariableName: + Enabled: false + +Style/OptionalBooleanParameter: + Enabled: false + +Naming/ClassAndModuleCamelCase: + Enabled: false + +Naming/MemoizedInstanceVariableName: + Enabled: false + +Naming/MethodName: + Enabled: false + +Naming/MethodParameterName: + Enabled: false + +Naming/PredicateName: + Enabled: false + +Style/ClassVars: + Enabled: false + +Style/MissingRespondToMissing: + Enabled: false + +ThreadSafety/ClassAndModuleAttributes: + Enabled: false + +Naming/FileName: + Enabled: false + +Lint/ConstantDefinitionInBlock: + Enabled: false diff --git a/Gemfile b/Gemfile index 5e793866..e0bc046f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,14 @@ +# frozen_string_literal: true + git_source(:github) do |repo_name| repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') "https://github.com/#{repo_name}.git" end -source "https://rubygems.org" -gemspec \ No newline at end of file +source 'https://rubygems.org' +gemspec + +gem 'rubocop' +gem 'rubocop-performance' +gem 'rubocop-rake' +gem 'rubocop-rspec' +gem 'rubocop-thread_safety' diff --git a/Rakefile b/Rakefile index aae699b0..52f0c170 100644 --- a/Rakefile +++ b/Rakefile @@ -1,15 +1,17 @@ +# frozen_string_literal: true + require 'rubygems' require 'rspec/core/rake_task' # testing framework require 'yard' # yard documentation # By default we don't run network tests -task :default => :test +task default: :test RSpec::Core::RakeTask.new(:spec) desc 'Run all tests' -task :test => [:spec] +task test: [:spec] YARD::Rake::YardocTask.new do |t| - t.files = ['lib/**/*.rb', '-', 'README.md'] + t.files = ['lib/**/*.rb', '-', 'README.md'] end diff --git a/couchbase-orm.gemspec b/couchbase-orm.gemspec index 7e85f2f5..922149a4 100644 --- a/couchbase-orm.gemspec +++ b/couchbase-orm.gemspec @@ -1,31 +1,33 @@ -require File.expand_path("../lib/couchbase-orm/version", __FILE__) +# frozen_string_literal: true + +require File.expand_path('lib/couchbase_orm/version', __dir__) Gem::Specification.new do |gem| - gem.name = "couchbase-orm" - gem.version = CouchbaseOrm::VERSION - gem.license = 'MIT' - gem.authors = ["Stephen von Takach"] - gem.email = ["steve@cotag.me"] - gem.homepage = "https://github.com/cotag/couchbase-orm" - gem.summary = "Couchbase ORM for Rails" - gem.description = "A Couchbase ORM for Rails" + gem.name = 'couchbase-orm' + gem.version = CouchbaseOrm::VERSION + gem.license = 'MIT' + gem.authors = ['Stephen von Takach'] + gem.email = ['steve@cotag.me'] + gem.homepage = 'https://github.com/cotag/couchbase-orm' + gem.summary = 'Couchbase ORM for Rails' + gem.description = 'A Couchbase ORM for Rails' - gem.required_ruby_version = '>= 2.1.0' - gem.require_paths = ["lib"] + gem.required_ruby_version = '>= 2.6.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' + gem.add_runtime_dependency 'activerecord', ENV['ACTIVE_MODEL_VERSION'] || '>= 5.2' - gem.add_runtime_dependency 'couchbase' - gem.add_runtime_dependency 'radix', '~> 2.2' # converting numbers to and from any base + gem.add_runtime_dependency 'couchbase' + gem.add_runtime_dependency 'radix', '~> 2.2' # converting numbers to and from any base - gem.add_development_dependency 'rake', '~> 12.2' - gem.add_development_dependency 'rspec', '~> 3.7' - gem.add_development_dependency 'yard', '~> 0.9' - gem.add_development_dependency 'pry' - gem.add_development_dependency 'pry-stack_explorer' - gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'pry' + gem.add_development_dependency 'pry-stack_explorer' + gem.add_development_dependency 'rake', '~> 12.2' + gem.add_development_dependency 'rspec', '~> 3.7' + gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'yard', '~> 0.9' - gem.files = `git ls-files`.split("\n") - gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + gem.files = `git ls-files`.split("\n") + gem.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/couchbase-orm.rb b/lib/couchbase-orm.rb index c91f87ec..15bed0f3 100644 --- a/lib/couchbase-orm.rb +++ b/lib/couchbase-orm.rb @@ -1,89 +1,3 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT -require "logger" -require "active_support/lazy_load_hooks" - -ActiveSupport.on_load(:i18n) do - I18n.load_path << File.expand_path("couchbase-orm/locale/en.yml", __dir__) -end - -module CouchbaseOrm - autoload :Error, 'couchbase-orm/error' - autoload :Connection, 'couchbase-orm/connection' - autoload :IdGenerator, 'couchbase-orm/id_generator' - autoload :Base, 'couchbase-orm/base' - autoload :HasMany, 'couchbase-orm/utilities/has_many' - - def self.logger - @@logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT).tap { |l| l.level = Logger::INFO unless ENV["COUCHBASE_ORM_DEBUG"] } - end - - def self.logger=(logger) - @@logger = logger - end - - def self.try_load(id) - result = nil - was_array = id.is_a?(Array) - if was_array && id.length == 1 - query_id = id.first - else - query_id = id - end - - result = query_id.is_a?(Array) ? CouchbaseOrm::Base.bucket.default_collection.get_multi(query_id) : CouchbaseOrm::Base.bucket.default_collection.get(query_id) - - result = Array.wrap(result) if was_array - - if result&.is_a?(Array) - return result.zip(id).map { |r, id| try_load_create_model(r, id) }.compact - end - - return try_load_create_model(result, id) - end - - private - - def self.try_load_create_model(result, id) - ddoc = result&.content["type"] - return nil unless ddoc - ::CouchbaseOrm::Base.descendants.each do |model| - if model.design_document == ddoc - return model.new(result, id: id) - end - end - nil - end -end - -# Provide Boolean conversion function -# See: http://www.virtuouscode.com/2012/05/07/a-ruby-conversion-idiom/ -module Kernel - private - - def Boolean(value) - case value - when String, Symbol - case value.to_s.strip.downcase - when 'true' - return true - when 'false' - return false - end - when Integer - return value != 0 - when false, nil - return false - when true - return true - end - - raise ArgumentError, "invalid value for Boolean(): \"#{value.inspect}\"" - end -end -class Boolean < TrueClass; end - -# If we are using Rails then we will include the Couchbase railtie. -if defined?(Rails) - require 'couchbase-orm/railtie' -end +# frozen_string_literal: true +require 'couchbase_orm' diff --git a/lib/couchbase-orm/associations.rb b/lib/couchbase-orm/associations.rb deleted file mode 100644 index 71514d5b..00000000 --- a/lib/couchbase-orm/associations.rb +++ /dev/null @@ -1,210 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'active_model' - -module CouchbaseOrm - module Associations - extend ActiveSupport::Concern - - - module ClassMethods - # Defines a belongs_to association for the model - def belongs_to(name, **options) - @associations ||= [] - @associations << [name.to_sym, options[:dependent]] - - ref = options[:foreign_key] || :"#{name}_id" - ref_ass = :"#{ref}=" - instance_var = :"@__assoc_#{name}" - - # Class reference - assoc = (options[:class_name] || name.to_s.camelize).to_s - - # Create the local setter / getter - attribute(ref) { |value| - remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) - value - } - - # Define reader - define_method(name) do - return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) - val = if options[:polymorphic] - ::CouchbaseOrm.try_load(self.send(ref)) - else - assoc.constantize.find(self.send(ref), quiet: true) - end - instance_variable_set(instance_var, val) - val - end - - # Define writer - attr_writer name - define_method(:"#{name}=") do |value| - if value - if !options[:polymorphic] - klass = assoc.constantize - raise ArgumentError, "type mismatch on association: #{klass.design_document} != #{value.class.design_document}" if klass.design_document != value.class.design_document - end - self.send(ref_ass, value.id) - else - self.send(ref_ass, nil) - end - - instance_variable_set(instance_var, value) - end - end - - def has_and_belongs_to_many(name, **options) - @associations ||= [] - @associations << [name.to_sym, options[:dependent]] - - ref = options[:foreign_key] || :"#{name.to_s.singularize}_ids" - ref_ass = :"#{ref}=" - instance_var = :"@__assoc_#{name}" - - # Class reference - assoc = (options[:class_name] || name.to_s.singularize.camelize).to_s - - # Create the local setter / getter - attribute(ref) { |value| - remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) - value - } - - # Define reader - define_method(name) do - return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) - ref_value = self.send(ref) - ref_value = nil if ref_value.respond_to?(:empty?) && ref_value.empty? - - val = if options[:polymorphic] - ::CouchbaseOrm.try_load(ref_value) if ref_value - else - assoc.constantize.find(ref_value) if ref_value - end - val = Array.wrap(val || []) - instance_variable_set(instance_var, val) - val - end - - # Define writer - attr_writer name - define_method(:"#{name}=") do |value| - if value - if !options[:polymorphic] - klass = assoc.constantize - value.each do |v| - raise ArgumentError, "type mismatch on association: #{klass.design_document} != #{v.class.design_document}" if klass.design_document != v.class.design_document - end - end - self.send(ref_ass, value.map(&:id)) - else - self.send(ref_ass, nil) - end - - instance_variable_set(instance_var, value) - end - - return unless options[:autosave] - - save_method = :"autosave_associated_records_for_#{name}" - - define_non_cyclic_method(save_method) do - old, new = previous_changes[ref] - adds = (new || []) - (old || []) - subs = (old || []) - (new || []) - update_has_and_belongs_to_many_reverse_association(assoc, adds, true, **options) if adds.any? - update_has_and_belongs_to_many_reverse_association(assoc, subs, false, **options) if subs.any? - end - - after_create save_method - after_update save_method - end - - def associations - @associations || [] - end - - def define_non_cyclic_method(name, &block) - return if method_defined?(name) - - define_method(name) do |*args| - result = true; @_already_called ||= {} - # Loop prevention for validation of associations - unless @_already_called[name] - begin - @_already_called[name] = true - result = instance_eval(&block) - ensure - @_already_called[name] = false - end - end - result - end - end - end - - def update_has_and_belongs_to_many_reverse_association(assoc, keys, is_add, **options) - remote_method = options[:inverse_of] || self.class.to_s.pluralize.underscore.to_sym - return if keys.empty? - - models = if options[:polymorphic] - ::CouchbaseOrm.try_load(keys) - else - assoc.constantize.find(keys, quiet: true) - end - models = Array.wrap(models) - models.each do |v| - next unless v.respond_to?(remote_method) - - tab = v.__send__(remote_method) || [] - index = tab.find_index(self) - if is_add && !index - tab = tab.dup - tab.push(self) - elsif !is_add && index - tab = tab.dup - tab.delete_at(index) - else - next - end - v[remote_method] = tab - v.save! - end - end - - def destroy_associations! - assoc = self.class.associations - assoc.each do |name, dependent| - next unless dependent - - model = self.__send__(name) - if model.present? - case dependent - when :destroy, :delete - if model.respond_to?(:stream) - model.stream { |mod| mod.__send__(dependent) } - elsif model.is_a?(Array) || model.is_a?(CouchbaseOrm::ResultsProxy) - model.each { |m| m.__send__(dependent) } - else - model.__send__(dependent) - end - when :restrict_with_exception - raise RecordExists.new("#{self.class.name} instance maintains a restricted reference to #{name}", self) - when :restrict_with_error - # TODO:: - end - end - end - end - - def reset_associations - assoc = self.class.associations - assoc.each do |name, _| - instance_var = :"@__assoc_#{name}" - remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) - end - end - end -end diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb deleted file mode 100644 index 91e6ed29..00000000 --- a/lib/couchbase-orm/base.rb +++ /dev/null @@ -1,288 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - - -require 'active_model' -require 'active_record' -if ActiveModel::VERSION::MAJOR >= 6 - require 'active_record/database_configurations' -else - require 'active_model/type' -end -require 'active_support/hash_with_indifferent_access' -require 'couchbase' -require 'couchbase-orm/error' -require 'couchbase-orm/views' -require 'couchbase-orm/n1ql' -require 'couchbase-orm/persistence' -require 'couchbase-orm/associations' -require 'couchbase-orm/types' -require 'couchbase-orm/relation' -require 'couchbase-orm/proxies/bucket_proxy' -require 'couchbase-orm/proxies/collection_proxy' -require 'couchbase-orm/utilities/join' -require 'couchbase-orm/utilities/enum' -require 'couchbase-orm/utilities/index' -require 'couchbase-orm/utilities/has_many' -require 'couchbase-orm/utilities/ensure_unique' -require 'couchbase-orm/utilities/query_helper' - - -module CouchbaseOrm - - module ActiveRecordCompat - # try to avoid dependencies on too many active record classes - # by exemple we don't want to go down to the concept of tables - - extend ActiveSupport::Concern - - module ClassMethods - def primary_key - "id" - end - - def base_class? - true - end - - def column_names # can't be an alias for now - attribute_names - end - - def abstract_class? - false - end - - def connected? - true - end - - def table_exists? - true - end - - if ActiveModel::VERSION::MAJOR < 6 - def attribute_names - attribute_types.keys - end - end - end - - def _has_attribute?(attr_name) - attribute_names.include?(attr_name.to_s) - end - - def attribute_for_inspect(attr_name) - value = send(attr_name) - value.inspect - end - - if ActiveModel::VERSION::MAJOR < 6 - def attribute_names - self.class.attribute_names - end - - def has_attribute?(attr_name) - @attributes.key?(attr_name.to_s) - end - - def attribute_present?(attribute) - value = send(attribute) - !value.nil? && !(value.respond_to?(:empty?) && value.empty?) - end - - def _write_attribute(attr_name, value) - @attributes.write_from_user(attr_name.to_s, value) - value - end - end - end - - class Base - include ::ActiveModel::Model - include ::ActiveModel::Dirty - include ::ActiveModel::Attributes - include ::ActiveModel::Serializers::JSON - - include ::ActiveModel::Validations - include ::ActiveModel::Validations::Callbacks - - include ::ActiveRecord::Core - include ActiveRecordCompat - - define_model_callbacks :initialize, :only => :after - define_model_callbacks :create, :destroy, :save, :update - - include Persistence - include ::ActiveRecord::AttributeMethods::Dirty - include ::ActiveRecord::Timestamp # must be included after Persistence - include Associations - include Views - include QueryHelper - include N1ql - include Relation - - extend Join - extend Enum - extend EnsureUnique - extend HasMany - extend Index - - - Metadata = Struct.new(:key, :cas) - - - class << self - def connect(**options) - @bucket = BucketProxy.new(::MTLibcouchbase::Bucket.new(**options)) - end - - def bucket=(bucket) - @bucket = bucket.is_a?(BucketProxy) ? bucket : BucketProxy.new(bucket) - end - - def bucket - @bucket ||= BucketProxy.new(Connection.bucket) - end - - def cluster - Connection.cluster - end - - def collection - CollectionProxy.new(bucket.default_collection) - end - - def uuid_generator - @uuid_generator ||= IdGenerator - end - - def uuid_generator=(generator) - @uuid_generator = generator - end - - def find(*ids, quiet: false) - CouchbaseOrm.logger.debug { "Base.find(l##{ids.length}) #{ids}" } - - ids = ids.flatten.select { |id| id.present? } - if ids.empty? - raise CouchbaseOrm::Error::EmptyNotAllowed, 'no id(s) provided' - end - - records = quiet ? collection.get_multi(ids) : collection.get_multi!(ids) - CouchbaseOrm.logger.debug { "Base.find found(#{records})" } - records = records.zip(ids).map { |record, id| - self.new(record, id: id) if record - } - records.compact! - ids.length > 1 ? records : records[0] - end - - def find_by_id(*ids, **options) - options[:quiet] = true - find(*ids, **options) - end - alias_method :[], :find_by_id - - def exists?(id) - CouchbaseOrm.logger.debug { "Data - Exists? #{id}" } - collection.exists(id).exists - end - alias_method :has_key?, :exists? - end - - class MismatchTypeError < RuntimeError; end - - # Add support for libcouchbase response objects - def initialize(model = nil, ignore_doc_type: false, **attributes) - CouchbaseOrm.logger.debug { "Initialize model #{model} with #{attributes.to_s.truncate(200)}" } - @__metadata__ = Metadata.new - - super() - - if model - case model - when Couchbase::Collection::GetResult - doc = HashWithIndifferentAccess.new(model.content) || raise('empty response provided') - type = doc.delete(:type) - doc.delete(:id) - - if type && !ignore_doc_type && type.to_s != self.class.design_document - raise CouchbaseOrm::Error::TypeMismatchError.new("document type mismatch, #{type} != #{self.class.design_document}", self) - end - - self.id = attributes[:id] if attributes[:id].present? - @__metadata__.cas = model.cas - - assign_attributes(doc) - when CouchbaseOrm::Base - clear_changes_information - super(model.attributes.except(:id, 'type')) - else - clear_changes_information - assign_attributes(**attributes.merge(Hash(model)).symbolize_keys) - end - else - clear_changes_information - super(attributes) - end - yield self if block_given? - - run_callbacks :initialize - end - - - # Document ID is a special case as it is not stored in the document - def id - @id - end - - def id=(value) - raise 'ID cannot be changed' if @__metadata__.cas && value - attribute_will_change!(:id) - @id = value.to_s.presence - end - - def [](key) - send(key) - end - - def []=(key, value) - send(:"#{key}=", value) - end - - # Public: Allows for access to ActiveModel functionality. - # - # Returns self. - def to_model - self - end - - # Public: Hashes identifying properties of the instance - # - # Ruby normally hashes an object to be used in comparisons. In our case - # we may have two techincally different objects referencing the same entity id. - # - # Returns a string representing the unique key. - def hash - "#{self.class.name}-#{self.id}-#{@__metadata__.cas}-#{@__attributes__.hash}".hash - end - - # Public: Overrides eql? to use == in the comparison. - # - # other - Another object to compare to - # - # Returns a boolean. - def eql?(other) - self == other - end - - # Public: Overrides == to compare via class and entity id. - # - # other - Another object to compare to - # - # Returns a boolean. - def ==(other) - super || other.instance_of?(self.class) && !id.nil? && other.id == id - end - end -end diff --git a/lib/couchbase-orm/connection.rb b/lib/couchbase-orm/connection.rb deleted file mode 100644 index cda9dc76..00000000 --- a/lib/couchbase-orm/connection.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'couchbase' - -module CouchbaseOrm - class Connection - @@config = nil - def self.config - @@config || { - :connection_string => "couchbase://#{ENV['COUCHBASE_HOST'] || '127.0.0.1'}", - :username => ENV['COUCHBASE_USER'], - :password => ENV['COUCHBASE_PASSWORD'], - :bucket => ENV['COUCHBASE_BUCKET'] - } - end - - def self.config=(config) - @@config = config - end - - def self.cluster - @cluster ||= begin - cb_config = Couchbase::Configuration.new - cb_config.connection_string = config[:connection_string] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm connection string') - cb_config.username = config[:username] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm username') - cb_config.password = config[:password] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm password') - Couchbase::Cluster.connect(cb_config) - end - end - - def self.bucket - @bucket ||= begin - bucket_name = config[:bucket] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm bucket name') - cluster.bucket(bucket_name) - end - end - end -end \ No newline at end of file diff --git a/lib/couchbase-orm/error.rb b/lib/couchbase-orm/error.rb deleted file mode 100644 index 6aa7ebcc..00000000 --- a/lib/couchbase-orm/error.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -module CouchbaseOrm - class Error < ::StandardError - attr_reader :record - - def initialize(message = nil, record = nil) - @record = record - super(message) - end - - class RecordInvalid < Error - def initialize(message = nil, record = nil) - if record - errors = record.errors.full_messages.join(", ") - message = I18n.t( - :"couchbase.#{record.class.design_document}.errors.messages.record_invalid", - errors: errors, - default: :"couchbase.errors.messages.record_invalid" - ) - end - super(message, record) - end - end - class TypeMismatchError < Error; end - class RecordExists < Error; end - class CouchbaseOrm::Error::EmptyNotAllowed < Error; end - end -end diff --git a/lib/couchbase-orm/id_generator.rb b/lib/couchbase-orm/id_generator.rb deleted file mode 100644 index ff0166d9..00000000 --- a/lib/couchbase-orm/id_generator.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'radix/base' - -module CouchbaseOrm - class IdGenerator - # Using base 65 as a form of compression (reduced length of ID string) - # No escape characters are required to display these in a URL - B65 = ::Radix::Base.new(::Radix::BASE::B62 + ['-', '_', '~']) - B10 = ::Radix::Base.new(10) - - # We don't really care about dates before this library was created - # This reduces the length of the ID significantly - Skip46Years = 1451649600 # 46.years.to_i - - # Generate a unique, orderable, ID using minimal bytes - def self.next(model) - # We are unlikely to see a clash here - now = Time.now - time = (now.to_i - Skip46Years) * 1_000_000 + now.usec - - # This makes it very very improbable that there will ever be an ID clash - # Distributed system safe! - prefix = time.to_s - tail = (rand(9999) + 1).to_s.rjust(4, '0') - - "#{model.class.design_document}-#{Radix.convert("#{prefix}#{tail}", B10, B65)}" - end - end -end diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb deleted file mode 100644 index 29a5640b..00000000 --- a/lib/couchbase-orm/n1ql.rb +++ /dev/null @@ -1,125 +0,0 @@ -# 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 - NO_VALUE = :no_value_specified - # sanitize for injection query - def self.sanitize(value) - if value.is_a?(String) - value.gsub("'", "''").gsub("\\"){"\\\\"}.gsub('"', '\"') - elsif value.is_a?(Array) - value.map{ |v| sanitize(v) } - else - value - end - end - - module ClassMethods - # 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 N1QL queries for a model - # class Post < CouchbaseOrm::Base - # n1ql :by_rating, emit_key: :rating - # end - # - # Post.by_rating do |response| - # # ... - # end - # TODO: add range keys [:startkey, :endkey] - def n1ql(name, query_fn: nil, emit_key: [], custom_order: nil, **options) - raise ArgumentError, "#{self} already respond_to? #{name}" if self.respond_to?(name) - - emit_key = Array.wrap(emit_key) - emit_key.each do |key| - raise "unknown emit_key attribute for n1ql :#{name}, emit_key: :#{key}" if key && !attribute_names.include?(key.to_s) - end - options = N1QL_DEFAULTS.merge(options) - method_opts = {} - method_opts[:emit_key] = emit_key - - @indexes ||= {} - @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) - 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 - 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 - N1QL_DEFAULTS = { include_docs: true } - - # add a n1ql query 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 - - define_singleton_method find_method do |value| - send n1ql_method, key: [value] - end - end - - private - - def convert_values(keys, values) - raise ArgumentError, "Empty keys but values are present, can't type cast" if keys.empty? && Array.wrap(values).any? - keys.zip(Array.wrap(values)).map do |key, value_before_type_cast| - serialize_value(key, value_before_type_cast) - end - end - - def build_where(keys, values) - where = values == NO_VALUE ? '' : keys.zip(Array.wrap(values)) - .reject { |key, value| key.nil? && value.nil? } - .map { |key, value| build_match(key, value) } - .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_limit(limit) - limit ? "limit #{limit}" : "" - end - - def run_query(keys, values, query_fn, custom_order: nil, descending: false, limit: nil, **options) - if query_fn - N1qlProxy.new(query_fn.call(bucket, values, Couchbase::Options::Query.new(**options))) - else - bucket_name = bucket.name - where = build_where(keys, values) - order = custom_order || build_order(keys, descending) - 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" } - N1qlProxy.new(result) - end - end - end - end -end diff --git a/lib/couchbase-orm/persistence.rb b/lib/couchbase-orm/persistence.rb deleted file mode 100644 index 29f72552..00000000 --- a/lib/couchbase-orm/persistence.rb +++ /dev/null @@ -1,274 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'active_model' -require 'active_support/hash_with_indifferent_access' - -module CouchbaseOrm - module Persistence - extend ActiveSupport::Concern - - included do - attribute :id, :string - end - - module ClassMethods - def create(attributes = nil, &block) - if attributes.is_a?(Array) - attributes.collect { |attr| create(attr, &block) } - else - instance = new(attributes, &block) - instance.save - instance - end - end - - def create!(attributes = nil, &block) - if attributes.is_a?(Array) - attributes.collect { |attr| create!(attr, &block) } - else - instance = new(attributes, &block) - instance.save! - instance - end - end - - # Raise an error if validation failed. - def fail_validate!(document) - raise Error::RecordInvalid.new("Failed to save the record", document) - end - - # Allow classes to overwrite the default document name - # extend ActiveModel::Naming (included by ActiveModel::Model) - def design_document(name = nil) - return @design_document unless name - @design_document = name.to_s - end - - # Set a default design document - def inherited(child) - super - child.instance_eval do - @design_document = child.name.underscore - end - end - end - - - # Returns true if this object hasn't been saved yet -- that is, a record - # for the object doesn't exist in the database yet; otherwise, returns false. - def new_record? - @__metadata__.cas.nil? && id.nil? - end - alias_method :new?, :new_record? - - # Returns true if this object has been destroyed, otherwise returns false. - def destroyed? - !!(@__metadata__.cas && id.blank?) - end - - # Returns true if the record is persisted, i.e. it's not a new record and it was - # not destroyed, otherwise returns false. - def persisted? - id.present? - end - alias_method :exists?, :persisted? - - # Saves the model. - # - # If the model is new, a record gets created in the database, otherwise - # the existing record gets updated. - def save(**options) - raise "Cannot save a destroyed document!" if destroyed? - self.new_record? ? _create_record(**options) : _update_record(**options) - end - - # Saves the model. - # - # If the model is new, a record gets created in the database, otherwise - # the existing record gets updated. - # - # By default, #save! always runs validations. If any of them fail - # CouchbaseOrm::Error::RecordInvalid gets raised, and the record won't be saved. - def save!(**options) - self.class.fail_validate!(self) unless self.save(**options) - self - end - - # Deletes the record in the database and freezes this instance to - # reflect that no changes should be made (since they can't be - # persisted). Returns the frozen instance. - # - # The record is simply removed, no callbacks are executed. - def delete(with_cas: false, **options) - options[:cas] = @__metadata__.cas if with_cas - CouchbaseOrm.logger.debug "Data - Delete #{self.id}" - self.class.collection.remove(self.id, **options) - - self.id = nil - clear_changes_information - self.freeze - self - end - - alias :remove :delete - - # Deletes the record in the database and freezes this instance to reflect - # that no changes should be made (since they can't be persisted). - # - # There's a series of callbacks associated with #destroy. - def destroy(with_cas: false, **options) - return self if destroyed? - raise 'model not persisted' unless persisted? - - run_callbacks :destroy do - destroy_associations! - - options[:cas] = @__metadata__.cas if with_cas - CouchbaseOrm.logger.debug "Data - Destroy #{id}" - self.class.collection.remove(id, **options) - - self.id = nil - - clear_changes_information - freeze - end - end - alias_method :destroy!, :destroy - - # Updates a single attribute and saves the record. - # This is especially useful for boolean flags on existing records. Also note that - # - # * Validation is skipped. - # * \Callbacks are invoked. - def update_attribute(name, value) - public_send(:"#{name}=", value) - changed? ? save(validate: false) : true - end - - def assign_attributes(hash) - super(hash.with_indifferent_access.except("type")) - end - - # Updates the attributes of the model from the passed-in hash and saves the - # record. If the object is invalid, the saving will fail and false will be returned. - def update(hash) - assign_attributes(hash) - save - end - alias_method :update_attributes, :update - - # Updates its receiver just like #update but calls #save! instead - # of +save+, so an exception is raised if the record is invalid and saving will fail. - def update!(hash) - assign_attributes(hash) # Assign attributes is provided by ActiveModel::AttributeAssignment - save! - end - alias_method :update_attributes!, :update! - - # Updates the record without validating or running callbacks. - # Updates only the attributes that are passed in as parameters - # except if there is more than 16 attributes, in which case - # the whole record is saved. - def update_columns(with_cas: false, **hash) - raise "unable to update columns, model not persisted" unless id - - assign_attributes(hash) - - options = {extended: true} - options[:cas] = @__metadata__.cas if with_cas - - # There is a limit of 16 subdoc operations per request - resp = if hash.length <= 16 - self.class.collection.mutate_in( - id, - hash.map { |k, v| Couchbase::MutateInSpec.replace(k.to_s, v) } - ) - else - # Fallback to writing the whole document - CouchbaseOrm.logger.debug { "Data - Replace #{id} #{attributes.to_s.truncate(200)}" } - self.class.collection.replace(id, attributes.except(:id).merge(type: self.class.design_document), **options) - end - - # Ensure the model is up to date - @__metadata__.cas = resp.cas - - changes_applied - self - end - - # Reloads the record from the database. - # - # This method finds record by its key and modifies the receiver in-place: - def reload - raise "unable to reload, model not persisted" unless id - - CouchbaseOrm.logger.debug "Data - Get #{id}" - resp = self.class.collection.get!(id) - assign_attributes(resp.content.except("id")) # API return a nil id - @__metadata__.cas = resp.cas - - reset_associations - clear_changes_information - self - end - - # Updates the TTL of the document - def touch(**options) - CouchbaseOrm.logger.debug "Data - Touch #{id}" - _res = self.class.collection.touch(id, async: false, **options) - @__metadata__.cas = resp.cas - self - end - - - protected - - def serialized_attributes - attributes.map { |k, v| - [k, self.class.attribute_types[k].serialize(v)] - }.to_h - end - - def _update_record(*_args, with_cas: false, **options) - return false unless perform_validations(:update, options) - return true unless changed? - - run_callbacks :update do - run_callbacks :save do - options[:cas] = @__metadata__.cas if with_cas - CouchbaseOrm.logger.debug { "_update_record - replace #{id} #{serialized_attributes.to_s.truncate(200)}" } - resp = self.class.collection.replace(id, serialized_attributes.except(:id).merge(type: self.class.design_document), Couchbase::Options::Replace.new(**options)) - - # Ensure the model is up to date - @__metadata__.cas = resp.cas - - changes_applied - true - end - end - end - def _create_record(*_args, **options) - return false unless perform_validations(:create, options) - - run_callbacks :create do - run_callbacks :save do - assign_attributes(id: self.class.uuid_generator.next(self)) unless self.id - CouchbaseOrm.logger.debug { "_create_record - Upsert #{id} #{serialized_attributes.to_s.truncate(200)}" } - - resp = self.class.collection.upsert(self.id, serialized_attributes.except(:id).merge(type: self.class.design_document), Couchbase::Options::Upsert.new(**options)) - - # Ensure the model is up to date - @__metadata__.cas = resp.cas - - changes_applied - true - end - end - end - - def perform_validations(context, options = {}) - return valid?(context) if options[:validate] != false - true - end - end -end diff --git a/lib/couchbase-orm/proxies/bucket_proxy.rb b/lib/couchbase-orm/proxies/bucket_proxy.rb deleted file mode 100644 index 259ecd1e..00000000 --- a/lib/couchbase-orm/proxies/bucket_proxy.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'couchbase-orm/proxies/n1ql_proxy' - -module CouchbaseOrm - class BucketProxy - def initialize(proxyfied) - raise ArgumentError, "Must proxy a non nil object" if proxyfied.nil? - - @proxyfied = proxyfied - - self.class.define_method(:n1ql) do - N1qlProxy.new(@proxyfied.n1ql) - end - - self.class.define_method(:view) do |design, view, **opts, &block| - @results = nil if @current_query != "#{design}_#{view}" - @current_query = "#{design}_#{view}" - return @results if @results - - CouchbaseOrm.logger.debug "View - #{design} #{view}" - @results = ResultsProxy.new(@proxyfied.send(:view, design, view, **opts, &block)) - end - end - - if RUBY_VERSION.to_i >= 3 - def method_missing(name, *args, **options, &block) - @proxyfied.public_send(name, *args, **options, &block) - end - else - def method_missing(name, *args, &block) - @proxyfied.public_send(name, *args, &block) - end - end - end -end diff --git a/lib/couchbase-orm/proxies/collection_proxy.rb b/lib/couchbase-orm/proxies/collection_proxy.rb deleted file mode 100644 index 9ddf54c6..00000000 --- a/lib/couchbase-orm/proxies/collection_proxy.rb +++ /dev/null @@ -1,53 +0,0 @@ -require "couchbase" - -module CouchbaseOrm - class CollectionProxy - - def get!(id, **options) - @proxyfied.get(id, Couchbase::Options::Get.new(**options)) - end - - def get(id, **options) - @proxyfied.get(id, Couchbase::Options::Get.new(**options)) - rescue Couchbase::Error::DocumentNotFound - nil - end - - def get_multi!(*ids, **options) - result = @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) - first_result_with_error = result.find(&:error) - raise first_result_with_error.error if first_result_with_error - result - end - - def get_multi(*ids, **options) - result = @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) - result.reject(&:error) - end - - def remove!(id, **options) - @proxyfied.remove(id, Couchbase::Options::Remove.new(**options)) - end - - def remove(id, **options) - @proxyfied.remove(id, Couchbase::Options::Remove.new(**options)) - rescue Couchbase::Error::DocumentNotFound - nil - end - - def initialize(proxyfied) - raise "Must proxy a non nil object" if proxyfied.nil? - @proxyfied = proxyfied - end - - if RUBY_VERSION.to_i >= 3 - def method_missing(name, *args, **options, &block) - @proxyfied.public_send(name, *args, **options, &block) - end - else # :nocov: - def method_missing(name, *args, &block) - @proxyfied.public_send(name, *args, &block) - end - end - end -end diff --git a/lib/couchbase-orm/proxies/n1ql_proxy.rb b/lib/couchbase-orm/proxies/n1ql_proxy.rb deleted file mode 100644 index db4e9363..00000000 --- a/lib/couchbase-orm/proxies/n1ql_proxy.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'couchbase-orm/proxies/results_proxy' - -module CouchbaseOrm - class N1qlProxy - def initialize(proxyfied) - @proxyfied = proxyfied - - self.class.define_method(:results) do |*params, &block| - @results = nil if @current_query != self.to_s - @current_query = self.to_s - return @results if @results - - CouchbaseOrm.logger.debug { 'Query - ' + self.to_s } - - results = @proxyfied.rows - results = results.map { |r| block.call(r) } if block - @results = ResultsProxy.new(results.to_a) - end - - self.class.define_method(:to_s) do - @proxyfied.to_s.tr("\n", ' ') - end - - proxyfied.public_methods.each do |method| - next if self.public_methods.include?(method) - - self.class.define_method(method) do |*params, &block| - ret = @proxyfied.send(method, *params, &block) - ret.is_a?(@proxyfied.class) ? self : ret - end - end - end - - def method_missing(m, *args, &block) - self.results.send(m, *args, &block) - end - end -end diff --git a/lib/couchbase-orm/proxies/results_proxy.rb b/lib/couchbase-orm/proxies/results_proxy.rb deleted file mode 100644 index 27d2897c..00000000 --- a/lib/couchbase-orm/proxies/results_proxy.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -module CouchbaseOrm - class ResultsProxy - def initialize(proxyfied) - @proxyfied = proxyfied - - raise ArgumentError, "Proxyfied object must respond to :to_a" unless @proxyfied.respond_to?(:to_a) - - proxyfied.public_methods.each do |method| - next if self.public_methods.include?(method) - - self.class.define_method(method) do |*params, &block| - @proxyfied.send(method, *params, &block) - end - end - end - - def method_missing(m, *args, &block) - @proxyfied.to_a.send(m, *args, &block) - end - end -end diff --git a/lib/couchbase-orm/railtie.rb b/lib/couchbase-orm/railtie.rb deleted file mode 100644 index 4d6d5706..00000000 --- a/lib/couchbase-orm/railtie.rb +++ /dev/null @@ -1,87 +0,0 @@ -# encoding: utf-8 -# -# Author:: Couchbase -# Copyright:: 2012 Couchbase, Inc. -# License:: Apache License, Version 2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -require 'yaml' -require 'couchbase-orm/base' - -module Rails #:nodoc: - module Couchbase #:nodoc: - class Railtie < Rails::Railtie #:nodoc: - config.couchbase_orm = ActiveSupport::OrderedOptions.new - config.couchbase_orm.ensure_design_documents = true - - # Maping of rescued exceptions to HTTP responses - # - # @example - # railtie.rescue_responses - # - # @return [Hash] rescued responses - def self.rescue_responses - { - } - end - - config.send(:app_generators).orm :couchbase_orm, :migration => false - - if config.action_dispatch.rescue_responses - config.action_dispatch.rescue_responses.merge!(rescue_responses) - end - - initializer 'couchbase_orm.setup_connection_config' do - CouchbaseOrm::Connection.config = Rails.application.config_for(:couchbase) - end - - # After initialization we will warn the user if we can't find a couchbase.yml and - # alert to create one. - initializer 'couchbase.warn_configuration_missing' do - unless ARGV.include?('couchbase:config') - config.after_initialize do - unless Rails.root.join('config', 'couchbase.yml').file? - puts "\nCouchbase config not found. Create a config file at: config/couchbase.yml" - puts "to generate one run: rails generate couchbase:config\n\n" - end - end - end - end - - # Set the proper error types for Rails. NotFound errors should be - # 404s and not 500s, validation errors are 422s. - initializer 'couchbase.load_http_errors' do |app| - config.after_initialize do - unless config.action_dispatch.rescue_responses - ActionDispatch::ShowExceptions.rescue_responses.update(Railtie.rescue_responses) - end - end - end - - # Check (and upgrade if needed) all design documents - config.after_initialize do |app| - if config.couchbase_orm.ensure_design_documents - begin - ::CouchbaseOrm::Base.descendants.each do |model| - model.ensure_design_document! - end - rescue ::MTLibcouchbase::Error::Timedout, ::MTLibcouchbase::Error::ConnectError, ::MTLibcouchbase::Error::NetworkError - # skip connection errors for now - end - end - end - end - end -end diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb deleted file mode 100644 index c595c6ca..00000000 --- a/lib/couchbase-orm/relation.rb +++ /dev/null @@ -1,142 +0,0 @@ -module CouchbaseOrm - 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}" - @model = model - @limit = limit - @where = [] - @order = {} - @order = merge_order(**order) if order - @where = merge_where(where, _not) if where - CouchbaseOrm::logger.debug "- #{to_s}" - end - - def to_s - "CouchbaseOrm_Relation: #{@model} where:#{@where.inspect} order:#{@order.inspect} limit: #{@limit}" - end - - def to_n1ql - bucket_name = @model.bucket.name - where = build_where - order = build_order - limit = build_limit - "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" - end - - def query - CouchbaseOrm::logger.debug("Query: #{self}") - n1ql_query = to_n1ql - 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" } - N1qlProxy.new(result) - end - - def ids - query.to_a - end - - def count - query.count - end - - def to_ary - query.results { |res| @model.find(res) }.to_ary - end - - alias :to_a :to_ary - - delegate :each, :map, :collect, :to => :to_ary - - def delete_all - CouchbaseOrm::logger.debug{ "Delete all: #{self}" } - ids = query.to_a - CouchbaseOrm::Connection.bucket.default_collection.remove_multi(ids) unless ids.empty? - end - - def where(**conds) - CouchbaseOrm_Relation.new(**initializer_arguments.merge(where: merge_where(conds))) - end - - def not(**conds) - CouchbaseOrm_Relation.new(**initializer_arguments.merge(where: merge_where(conds, _not: true))) - end - - def order(*lorder, **horder) - CouchbaseOrm_Relation.new(**initializer_arguments.merge(order: merge_order(*lorder, **horder))) - end - - def limit(limit) - CouchbaseOrm_Relation.new(**initializer_arguments.merge(limit: limit)) - end - - def all - CouchbaseOrm_Relation.new(**initializer_arguments) - end - - private - - def build_limit - @limit ? "limit #{@limit}" : "" - end - - def initializer_arguments - { model: @model, order: @order, where: @where, limit: @limit } - end - - def merge_order(*lorder, **horder) - raise ArgumentError, "invalid order passed by list: #{lorder.inspect}, must be symbols" unless lorder.all? { |o| o.is_a? Symbol } - raise ArgumentError, "Invalid order passed by hash: #{horder.inspect}, must be symbol -> :asc|:desc" unless horder.all? { |k, v| k.is_a?(Symbol) && [:asc, :desc].include?(v) } - @order - .merge(Array.wrap(lorder).map{ |o| [o, :asc] }.to_h) - .merge(horder) - end - - def merge_where(conds, _not = false) - @where + (_not ? conds.to_a.map{|k,v|[k,v,:not]} : conds.to_a) - end - - def build_order - order = @order.map do |key, value| - "#{key} #{value}" - end.join(", ") - order.empty? ? "meta().id" : order - end - - def build_where - ([[:type, @model.design_document]] + @where).map do |key, value, opt| - opt == :not ? - @model.build_not_match(key, value) : - @model.build_match(key, value) - end.join(" AND ") - end - end - - module ClassMethods - def where(**conds) - CouchbaseOrm_Relation.new(model: self, where: conds) - end - - def not(**conds) - CouchbaseOrm_Relation.new(model: self, where: conds, _not: true) - end - - def order(*ordersl, **ordersh) - order = ordersh.reverse_merge(ordersl.map{ |o| [o, :asc] }.to_h) - CouchbaseOrm_Relation.new(model: self, order: order) - end - - def limit(limit) - CouchbaseOrm_Relation.new(model: self, limit: limit) - end - - def all - CouchbaseOrm_Relation.new(model: self) - end - - delegate :ids, :delete_all, :count, to: :all - end - end -end diff --git a/lib/couchbase-orm/types/date.rb b/lib/couchbase-orm/types/date.rb deleted file mode 100644 index 2e836cf8..00000000 --- a/lib/couchbase-orm/types/date.rb +++ /dev/null @@ -1,9 +0,0 @@ -module CouchbaseOrm - module Types - class Date < ActiveModel::Type::Date - def serialize(value) - value&.iso8601 - end - end - end -end diff --git a/lib/couchbase-orm/types/date_time.rb b/lib/couchbase-orm/types/date_time.rb deleted file mode 100644 index a9a26984..00000000 --- a/lib/couchbase-orm/types/date_time.rb +++ /dev/null @@ -1,13 +0,0 @@ -module CouchbaseOrm - module Types - class DateTime < ActiveModel::Type::DateTime - def cast(value) - super(value)&.utc - end - - def serialize(value) - value&.iso8601 - end - end - end -end diff --git a/lib/couchbase-orm/types/timestamp.rb b/lib/couchbase-orm/types/timestamp.rb deleted file mode 100644 index 51f8acbc..00000000 --- a/lib/couchbase-orm/types/timestamp.rb +++ /dev/null @@ -1,18 +0,0 @@ -module CouchbaseOrm - module Types - class Timestamp < ActiveModel::Type::DateTime - def cast(value) - return nil if value.nil? - return Time.at(value) if value.is_a?(Integer) - return Time.at(value.to_i) if value.is_a?(String) && value =~ /^[0-9]+$/ - return value.utc if value.is_a?(Time) - super(value).utc - end - - def serialize(value) - value&.to_i - end - end - end -end - diff --git a/lib/couchbase-orm/utilities/ensure_unique.rb b/lib/couchbase-orm/utilities/ensure_unique.rb deleted file mode 100644 index 0da3da49..00000000 --- a/lib/couchbase-orm/utilities/ensure_unique.rb +++ /dev/null @@ -1,18 +0,0 @@ -module CouchbaseOrm - module EnsureUnique - private - - def ensure_unique(attrs, name = nil, presence: true, &processor) - # index uses a special bucket key to allow record lookups based on - # the values of attrs. ensure_unique adds a simple lookup using - # one of the added methods to identify duplicate - name = index(attrs, name, presence: presence, &processor) - - validate do |record| - unless record.send("#{name}_unique?") - errors.add(name, 'has already been taken') - end - end - end - end -end diff --git a/lib/couchbase-orm/utilities/enum.rb b/lib/couchbase-orm/utilities/enum.rb deleted file mode 100644 index 6032f90c..00000000 --- a/lib/couchbase-orm/utilities/enum.rb +++ /dev/null @@ -1,61 +0,0 @@ -module CouchbaseOrm - module Enum - private - - def enum(options) - # options contains an optional default value, and the name of the - # enum, e.g enum visibility: %i(group org public), default: :group - default = options.delete(:default) - name = options.keys.first.to_sym - values = options[name] - - # values is assumed to be a list of symbols. each value is assigned an - # integer, and this number is used for db storage. numbers start at 1. - mapping = {} - values.each_with_index do |value, i| - mapping[value.to_sym] = i + 1 - mapping[i + 1] = value.to_sym - end - - # VISIBILITY = {group: 0, 0: group ...} - const_set(name.to_s.upcase, mapping) - - # lookup the default's integer value - if default - default_value = mapping[default] - raise 'Unknown default value' unless default_value - else - default_value = 1 - end - attribute name, :integer, default: default_value - - define_method "#{name}=" do |value| - unless value.nil? - value = case value - when Symbol, String - self.class.const_get(name.to_s.upcase)[value.to_sym] - else - Integer(value) - end - end - super(value) - end - - # keep the attribute's value within bounds - before_save do |record| - value = record[name] - - unless value.nil? - value = case value - when Symbol, String - record.class.const_get(name.to_s.upcase)[value.to_sym] - else - Integer(value) - end - end - - record[name] = (1..values.length).cover?(value) ? value : default_value - end - end - end -end diff --git a/lib/couchbase-orm/utilities/has_many.rb b/lib/couchbase-orm/utilities/has_many.rb deleted file mode 100644 index 164b75f0..00000000 --- a/lib/couchbase-orm/utilities/has_many.rb +++ /dev/null @@ -1,109 +0,0 @@ -module CouchbaseOrm - module HasMany - # :foreign_key, :class_name, :through - 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 - - instance_var = "@__assoc_#{model}" - - klass = begin - class_name.constantize - rescue NameError - warn "WARNING: #{class_name} referenced in #{self.name} before it was aded" - - # 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) - - if remote_class - define_method(model) do - return self.instance_variable_get(instance_var) if instance_variable_defined?(instance_var) - - remote_klass = remote_class.constantize - raise ArgumentError, "Can't find #{remote_method} without an id" unless self.id.present? - enum = klass.__send__(remote_method, key: self.id) { |row| - case type - when :n1ql - remote_klass.find(row) - when :view - remote_klass.find(row[through_key]) - else - raise 'type is unknown' - end - } - - self.instance_variable_set(instance_var, enum) - end - else - define_method(model) do - return self.instance_variable_get(instance_var) if instance_variable_defined?(instance_var) - self.instance_variable_set(instance_var, self.id ? 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) - when :view - build_index_view(klass, remote_class, remote_method, through_key, foreign_key) - else - raise 'type is unknown' - 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 - else - klass.class_eval do - index_view foreign_key, validate: false - end - end - end - - def build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_key) - if remote_class - klass.class_eval do - n1ql remote_method, emit_key: 'id', query_fn: proc { |bucket, values, options| - raise ArgumentError, "values[0] must not be blank" if values[0].blank? - cluster.query("SELECT raw #{through_key} FROM `#{bucket.name}` where type = \"#{design_document}\" and #{foreign_key} = #{quote(values[0])}", options) - } - end - else - klass.class_eval do - index_n1ql foreign_key, validate: false - end - end - end - end -end diff --git a/lib/couchbase-orm/utilities/index.rb b/lib/couchbase-orm/utilities/index.rb deleted file mode 100644 index bec602f2..00000000 --- a/lib/couchbase-orm/utilities/index.rb +++ /dev/null @@ -1,137 +0,0 @@ -module CouchbaseOrm - module Index - private - - def index(attrs, name = nil, presence: true, &processor) - attrs = Array(attrs).flatten - name ||= attrs.map(&:to_s).join('_') - - find_by_method = "find_by_#{name}" - processor_method = "process_#{name}" - bucket_key_method = "#{name}_bucket_key" - bucket_key_vals_method = "#{name}_bucket_key_vals" - class_bucket_key_method = "generate_#{bucket_key_method}" - original_bucket_key_var = "@original_#{bucket_key_method}" - - - #---------------- - # keys - #---------------- - # class method to generate a bucket key given input values - define_singleton_method(class_bucket_key_method) do |*values| - processed = self.send(processor_method, *values) - "#{@design_document}#{name}-#{processed}" - end - - # instance method that uses the class method to generate a bucket key - # given the current value of each of the key's component attributes - define_method(bucket_key_method) do |args = nil| - self.class.send(class_bucket_key_method, *self.send(bucket_key_vals_method)) - end - - # collect a list of values for each key component attribute - define_method(bucket_key_vals_method) do - attrs.collect {|attr| self.class.attribute_types[attr.to_s].cast(self[attr])} - end - - - #---------------- - # helpers - #---------------- - # simple wrapper around the processor proc if supplied - define_singleton_method(processor_method) do |*values| - values = attrs.zip(values).map { |attr,value| attribute_types[attr.to_s].serialize(attribute_types[attr.to_s].cast(value)) } - if processor - processor.call(values.length == 1 ? values.first : values) - else - values.join('-') - end - end - - # use the bucket key as an index - lookup records by attr values - define_singleton_method(find_by_method) do |*values| - key = self.send(class_bucket_key_method, *values) - CouchbaseOrm.logger.debug { "#{find_by_method}: #{class_bucket_key_method} with values #{values.inspect} give key: #{key}" } - id = self.collection.get(key)&.content - if id - mod = self.find_by_id(id) - return mod if mod - - # Clean up record if the id doesn't exist - self.collection.remove(key) - else - CouchbaseOrm.logger.debug("#{find_by_method}: #{key} not found") - end - - nil - end - - - #---------------- - # validations - #---------------- - # ensure each component of the unique key is present - if presence - attrs.each do |attr| - validates attr, presence: true - attribute attr - end - end - - define_method("#{name}_unique?") do - values = self.send(bucket_key_vals_method) - other = self.class.send(find_by_method, *values) - !other || other.id == self.id - end - - - #---------------- - # callbacks - #---------------- - # before a save is complete, while changes are still available, store - # a copy of the current bucket key for comparison if any of the key - # components have been modified - before_save do |record| - if attrs.any? { |attr| record.changes.include?(attr) } - args = attrs.collect { |attr| send(:"#{attr}_was") || send(attr) } - instance_variable_set(original_bucket_key_var, self.class.send(class_bucket_key_method, *args)) - end - end - - # after the values are persisted, delete the previous key and store the - # new one. the id of the current record is used as the key's value. - after_save do |record| - original_key = instance_variable_get(original_bucket_key_var) - - if original_key - begin - check_ref_id = record.class.collection.get(original_key) - if check_ref_id && check_ref_id.content == record.id - CouchbaseOrm.logger.debug { "Removing old key #{original_key}" } - record.class.collection.remove(original_key, cas: check_ref_id.cas) - end - end - end - - record.class.collection.upsert(record.send(bucket_key_method), record.id) - - instance_variable_set(original_bucket_key_var, nil) - end - - # cleanup by removing the bucket key before the record is deleted - # TODO: handle unpersisted, modified component values - before_destroy do |record| - check_ref_id = record.class.collection.get(record.send(bucket_key_method)) - if check_ref_id && check_ref_id.content == record.id - record.class.collection.remove(record.send(bucket_key_method), cas: check_ref_id.cas) - end - true - end - - # return the name used to construct the added method names so other - # code can call the special index methods easily - return name - end - - end -end diff --git a/lib/couchbase-orm/utilities/join.rb b/lib/couchbase-orm/utilities/join.rb deleted file mode 100644 index 3dc74ec8..00000000 --- a/lib/couchbase-orm/utilities/join.rb +++ /dev/null @@ -1,68 +0,0 @@ -module CouchbaseOrm - module Join - private - - # join adds methods for retrieving the join model by user or group, and - # methods for retrieving either model through the join model (e.g all - # users who are in a group). model_a and model_b must be strings or symbols - # and are assumed to be singularised, underscored versions of model names - def join(model_a, model_b, options={}) - # store the join model names for use by has_many associations - @join_models = [model_a.to_s, model_b.to_s] - - # join :user, :group => design_document :ugj - doc_name = options[:design_document] || "#{model_a.to_s[0]}#{model_b.to_s[0]}j".to_sym - design_document doc_name - - # a => b - add_single_sided_features(model_a) - add_joint_lookups(model_a, model_b) - - # b => a - add_single_sided_features(model_b) - add_joint_lookups(model_b, model_a, true) - - # use Index to allow lookups of joint records more efficiently than - # with a view or search - index ["#{model_a}_id".to_sym, "#{model_b}_id".to_sym], :join - end - - def add_single_sided_features(model) - # belongs_to :group - belongs_to model - - # view :by_group_id - view "by_#{model}_id" - - # find_by_group_id - instance_eval " - def self.find_by_#{model}_id(#{model}_id) - by_#{model}_id(key: #{model}_id) - end - " - end - - def add_joint_lookups(model_a, model_b, reverse = false) - # find_by_user_id_and_group_id - instance_eval " - def self.find_by_#{model_a}_id_and_#{model_b}_id(#{model_a}_id, #{model_b}_id) - self.find_by_join([#{reverse ? model_b : model_a}_id, #{reverse ? model_a : model_b}_id]) - end - " - - # user_ids_by_group_id - instance_eval " - def self.#{model_a}_ids_by_#{model_b}_id(#{model_b}_id) - self.find_by_#{model_b}_id(#{model_b}_id).map(&:#{model_a}_id) - end - " - - # users_by_group_id - instance_eval " - def self.#{model_a.to_s.pluralize}_by_#{model_b}_id(#{model_b}_id) - self.find_by_#{model_b}_id(#{model_b}_id).map(&:#{model_a}) - end - " - end - end -end diff --git a/lib/couchbase-orm/utilities/query_helper.rb b/lib/couchbase-orm/utilities/query_helper.rb deleted file mode 100644 index 7b5a159b..00000000 --- a/lib/couchbase-orm/utilities/query_helper.rb +++ /dev/null @@ -1,59 +0,0 @@ -module CouchbaseOrm - module QueryHelper - extend ActiveSupport::Concern - - module ClassMethods - - def build_match(key, value) - case - when value.nil? - "#{key} IS NOT VALUED" - when value.is_a?(Array) && value.include?(nil) - "(#{build_match(key, nil)} OR #{build_match(key, value.compact)})" - when value.is_a?(Array) - "#{key} IN #{quote(value)}" - else - "#{key} = #{quote(value)}" - end - end - - def build_not_match(key, value) - case - when value.nil? - "#{key} IS VALUED" - when value.is_a?(Array) && value.include?(nil) - "(#{build_not_match(key, nil)} AND #{build_not_match(key, value.compact)})" - when value.is_a?(Array) - "#{key} NOT IN #{quote(value)}" - else - "#{key} != #{quote(value)}" - end - end - - def serialize_value(key, value_before_type_cast) - value = - if value_before_type_cast.is_a?(Array) - value_before_type_cast.map do |v| - attribute_types[key.to_s].serialize(attribute_types[key.to_s].cast(v)) - end - else - attribute_types[key.to_s].serialize(attribute_types[key.to_s].cast(value_before_type_cast)) - end - CouchbaseOrm.logger.debug { "convert_values: #{key} => #{value_before_type_cast.inspect} => #{value.inspect} #{value.class} #{attribute_types[key.to_s]}" } - value - end - - def quote(value) - if value.is_a? String - "'#{N1ql.sanitize(value)}'" - elsif value.is_a? Array - "[#{value.map{|v|quote(v)}.join(', ')}]" - elsif value.nil? - nil - else - N1ql.sanitize(value).to_s - end - end - end - end -end diff --git a/lib/couchbase-orm/version.rb b/lib/couchbase-orm/version.rb deleted file mode 100644 index a908afd1..00000000 --- a/lib/couchbase-orm/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -module CouchbaseOrm - VERSION = '1.1.1' -end diff --git a/lib/couchbase-orm/views.rb b/lib/couchbase-orm/views.rb deleted file mode 100644 index b2e55493..00000000 --- a/lib/couchbase-orm/views.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'active_model' - -module CouchbaseOrm - module Views - extend ActiveSupport::Concern - - - module ClassMethods - # Defines a view for the model - # - # @param [Symbol, String, Array] names names of the views - # @param [Hash] options options passed to the {Couchbase::View} - # - # @example Define some views for a model - # class Post < CouchbaseOrm::Base - # view :all - # view :by_rating, emit_key: :rating - # end - # - # Post.by_rating do |response| - # # ... - # end - def view(name, map: nil, emit_key: nil, reduce: nil, **options) - raise ArgumentError, "#{self} already respond_to? #{name}" if self.respond_to?(name) - - if emit_key.class == Array - emit_key.each do |key| - raise "unknown emit_key attribute for view :#{name}, emit_key: :#{key}" if key && !attribute_names.include?(key.to_s) - end - else - raise "unknown emit_key attribute for view :#{name}, emit_key: :#{emit_key}" if emit_key && !attribute_names.include?(emit_key.to_s) - end - - options = ViewDefaults.merge(options) - - method_opts = {} - method_opts[:map] = map if map - method_opts[:reduce] = reduce if reduce - - unless method_opts.has_key? :map - if emit_key.class == Array - method_opts[:map] = <<-EMAP -function(doc) { - if (doc.type === "{{design_document}}") { - emit([#{emit_key.map{|key| "doc."+key.to_s}.join(',')}], null); - } -} -EMAP - else - emit_key = emit_key || :created_at - method_opts[:map] = <<-EMAP -function(doc) { - if (doc.type === "{{design_document}}") { - emit(doc.#{emit_key}, null); - } -} -EMAP - end - end - - @views ||= {} - - name = name.to_sym - @views[name] = method_opts - - singleton_class.__send__(:define_method, name) do |**opts, &result_modifier| - opts = options.merge(opts).reverse_merge(scan_consistency: :request_plus) - 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) - elsif opts[:include_docs] - include_docs(bucket.view_query(@design_document, name.to_s, Couchbase::Options::View.new(**opts.except(:include_docs)))) - else - bucket.view_query(@design_document, name.to_s, Couchbase::Options::View.new(**opts.except(:include_docs))) - end - end - end - ViewDefaults = {include_docs: true} - - # add a view and lookup method to the model for finding all records - # using a value in the supplied attr. - def index_view(attr, validate: true, find_method: nil, view_method: nil) - view_method ||= "by_#{attr}" - find_method ||= "find_#{view_method}" - - validates(attr, presence: true) if validate - view view_method, emit_key: attr - - instance_eval " - def self.#{find_method}(#{attr}) - #{view_method}(key: #{attr}) - end - " - end - - def ensure_design_document! - return false unless @views && !@views.empty? - existing = {} - update_required = false - - # Grab the existing view details - begin - ddoc = bucket.view_indexes.get_design_document(@design_document, :production) - rescue Couchbase::Error::DesignDocumentNotFound - end - existing = ddoc.views if ddoc - views_actual = {} - # Fill in the design documents - @views.each do |name, document| - views_actual[name.to_s] = Couchbase::Management::View.new( - document[:map]&.gsub('{{design_document}}', @design_document), - document[:reduce]&.gsub('{{design_document}}', @design_document) - ) - end - - # Check there are no changes we need to apply - views_actual.each do |name, desired| - check = existing[name] - if check - cmap = (check.map || '').gsub(/\s+/, '') - creduce = (check.reduce || '').gsub(/\s+/, '') - dmap = (desired.map || '').gsub(/\s+/, '') - dreduce = (desired.reduce || '').gsub(/\s+/, '') - - unless cmap == dmap && creduce == dreduce - update_required = true - break - end - else - update_required = true - break - end - end - - # Updated the design document - if update_required - document = Couchbase::Management::DesignDocument.new - document.views = views_actual - document.name = @design_document - bucket.view_indexes.upsert_design_document(document, :production) - - true - else - false - end - end - - def include_docs(view_result) - if view_result.rows.length > 1 - self.find(view_result.rows.map(&:id)) - elsif view_result.rows.length == 1 - [self.find(view_result.rows.first.id)] - else - [] - end - end - end - end -end diff --git a/lib/couchbase_orm.rb b/lib/couchbase_orm.rb new file mode 100644 index 00000000..558ed713 --- /dev/null +++ b/lib/couchbase_orm.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'logger' +require 'active_support/lazy_load_hooks' + +ActiveSupport.on_load(:i18n) do + I18n.load_path << File.expand_path('couchbase_orm/locale/en.yml', __dir__) +end + +module CouchbaseOrm + autoload :Error, 'couchbase_orm/error' + autoload :Connection, 'couchbase_orm/connection' + autoload :IdGenerator, 'couchbase_orm/id_generator' + autoload :Base, 'couchbase_orm/base' + autoload :HasMany, 'couchbase_orm/utilities/has_many' + + def self.logger + @@logger ||= if defined?(Rails) + Rails.logger + else + Logger.new($stdout).tap do |l| + l.level = Logger::INFO unless ENV['COUCHBASE_ORM_DEBUG'] + end + end + end + + def self.logger=(logger) + @@logger = logger + end + + def self.try_load(id) + was_array = id.is_a?(Array) + query_id = if was_array && id.length == 1 + id.first + else + id + end + + result = query_id.is_a?(Array) ? CouchbaseOrm::Base.bucket.default_collection.get_multi(query_id) : CouchbaseOrm::Base.bucket.default_collection.get(query_id) + + result = Array.wrap(result) if was_array + + return result.zip(id).map { |r, i| try_load_create_model(r, i) }.compact if result.is_a?(Array) + + try_load_create_model(result, id) + end + + def self.try_load_create_model(result, id) + ddoc = result&.content&.[]('type') + return nil unless ddoc + + ::CouchbaseOrm::Base.descendants.each do |model| + return model.new(result, id: id) if model.design_document == ddoc + end + nil + end +end + +# Provide Boolean conversion function +# See: http://www.virtuouscode.com/2012/05/07/a-ruby-conversion-idiom/ +module Kernel + private + + def Boolean(value) + case value + when String, Symbol + case value.to_s.strip.downcase + when 'true' + return true + when 'false' + return false + end + when Integer + return value != 0 + when false, nil + return false + when true + return true + end + + raise ArgumentError, "invalid value for Boolean(): \"#{value.inspect}\"" + end +end + +class Boolean < TrueClass; end + +# If we are using Rails then we will include the Couchbase railtie. +require 'couchbase_orm/railtie' if defined?(Rails) diff --git a/lib/couchbase_orm/associations.rb b/lib/couchbase_orm/associations.rb new file mode 100644 index 00000000..4c3d8db8 --- /dev/null +++ b/lib/couchbase_orm/associations.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'active_model' + +module CouchbaseOrm + module Associations + extend ActiveSupport::Concern + + module ClassMethods + # Defines a belongs_to association for the model + def belongs_to(name, **options) + @associations ||= [] + @associations << [name.to_sym, options[:dependent]] + + ref = options[:foreign_key] || :"#{name}_id" + ref_ass = :"#{ref}=" + instance_var = :"@__assoc_#{name}" + + # Class reference + assoc = (options[:class_name] || name.to_s.camelize).to_s + + # Create the local setter / getter + attribute(ref) do |value| + remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) + value + end + + # Define reader + define_method(name) do + return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) + + val = if options[:polymorphic] + ::CouchbaseOrm.try_load(send(ref)) + else + assoc.constantize.find(send(ref), quiet: true) + end + instance_variable_set(instance_var, val) + val + end + + # Define writer + attr_writer name + + define_method(:"#{name}=") do |value| + if value + unless options[:polymorphic] + klass = assoc.constantize + if klass.design_document != value.class.design_document + raise ArgumentError, + "type mismatch on association: #{klass.design_document} != #{value.class.design_document}" + end + end + send(ref_ass, value.id) + else + send(ref_ass, nil) + end + + instance_variable_set(instance_var, value) + end + end + + def has_and_belongs_to_many(name, **options) + @associations ||= [] + @associations << [name.to_sym, options[:dependent]] + + ref = options[:foreign_key] || :"#{name.to_s.singularize}_ids" + ref_ass = :"#{ref}=" + instance_var = :"@__assoc_#{name}" + + # Class reference + assoc = (options[:class_name] || name.to_s.singularize.camelize).to_s + + # Create the local setter / getter + attribute(ref) do |value| + remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) + value + end + + # Define reader + define_method(name) do + return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) + + ref_value = send(ref) + ref_value = nil if ref_value.respond_to?(:empty?) && ref_value.empty? + + val = if options[:polymorphic] + ::CouchbaseOrm.try_load(ref_value) if ref_value + elsif ref_value + assoc.constantize.find(ref_value) + end + val = Array.wrap(val || []) + instance_variable_set(instance_var, val) + val + end + + # Define writer + attr_writer name + + define_method(:"#{name}=") do |value| + if value + unless options[:polymorphic] + klass = assoc.constantize + value.each do |v| + if klass.design_document != v.class.design_document + raise ArgumentError, + "type mismatch on association: #{klass.design_document} != #{v.class.design_document}" + end + end + end + send(ref_ass, value.map(&:id)) + else + send(ref_ass, nil) + end + + instance_variable_set(instance_var, value) + end + + return unless options[:autosave] + + save_method = :"autosave_associated_records_for_#{name}" + + define_non_cyclic_method(save_method) do + old, new = previous_changes[ref] + adds = (new || []) - (old || []) + subs = (old || []) - (new || []) + update_has_and_belongs_to_many_reverse_association(assoc, adds, true, **options) if adds.any? + update_has_and_belongs_to_many_reverse_association(assoc, subs, false, **options) if subs.any? + end + + after_create save_method + after_update save_method + end + + def associations + @associations || [] + end + + def define_non_cyclic_method(name, &block) + return if method_defined?(name) + + define_method(name) do |*_args| + result = true + @_already_called ||= {} + # Loop prevention for validation of associations + unless @_already_called[name] + begin + @_already_called[name] = true + result = instance_eval(&block) + ensure + @_already_called[name] = false + end + end + result + end + end + end + + def update_has_and_belongs_to_many_reverse_association(assoc, keys, is_add, **options) + remote_method = options[:inverse_of] || self.class.to_s.pluralize.underscore.to_sym + return if keys.empty? + + models = if options[:polymorphic] + ::CouchbaseOrm.try_load(keys) + else + assoc.constantize.find(keys, quiet: true) + end + models = Array.wrap(models) + models.each do |v| + next unless v.respond_to?(remote_method) + + tab = v.__send__(remote_method) || [] + index = tab.find_index(self) + if is_add && !index + tab = tab.dup + tab.push(self) + elsif !is_add && index + tab = tab.dup + tab.delete_at(index) + else + next + end + v[remote_method] = tab + v.save! + end + end + + def destroy_associations! + assoc = self.class.associations + assoc.each do |name, dependent| + next unless dependent + + model = __send__(name) + next unless model.present? + + case dependent + when :destroy, :delete + if model.respond_to?(:stream) + model.stream { |mod| mod.__send__(dependent) } + elsif model.is_a?(Array) || model.is_a?(CouchbaseOrm::ResultsProxy) + model.each { |m| m.__send__(dependent) } + else + model.__send__(dependent) + end + when :restrict_with_exception + raise RecordExists.new("#{self.class.name} instance maintains a restricted reference to #{name}", self) + when :restrict_with_error + # TODO: : + end + end + end + + def reset_associations + assoc = self.class.associations + assoc.each do |name, _| + instance_var = :"@__assoc_#{name}" + remove_instance_variable(instance_var) if instance_variable_defined?(instance_var) + end + end + end +end diff --git a/lib/couchbase_orm/base.rb b/lib/couchbase_orm/base.rb new file mode 100644 index 00000000..c9589216 --- /dev/null +++ b/lib/couchbase_orm/base.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require 'active_model' +require 'active_record' +if ActiveModel::VERSION::MAJOR >= 6 + require 'active_record/database_configurations' +else + require 'active_model/type' +end +require 'active_support/hash_with_indifferent_access' +require 'couchbase' +require 'couchbase_orm/error' +require 'couchbase_orm/views' +require 'couchbase_orm/n1ql' +require 'couchbase_orm/persistence' +require 'couchbase_orm/associations' +require 'couchbase_orm/types' +require 'couchbase_orm/relation' +require 'couchbase_orm/proxies/bucket_proxy' +require 'couchbase_orm/proxies/collection_proxy' +require 'couchbase_orm/utilities/join' +require 'couchbase_orm/utilities/enum' +require 'couchbase_orm/utilities/index' +require 'couchbase_orm/utilities/has_many' +require 'couchbase_orm/utilities/ensure_unique' +require 'couchbase_orm/utilities/query_helper' + +module CouchbaseOrm + module ActiveRecordCompat + # try to avoid dependencies on too many active record classes + # by exemple we don't want to go down to the concept of tables + + extend ActiveSupport::Concern + + module ClassMethods + def primary_key + 'id' + end + + def base_class? + true + end + + # can't be an alias for now + def column_names + attribute_names + end + + def abstract_class? + false + end + + def connected? + true + end + + def table_exists? + true + end + + if ActiveModel::VERSION::MAJOR < 6 + def attribute_names + attribute_types.keys + end + end + end + + def _has_attribute?(attr_name) + attribute_names.include?(attr_name.to_s) + end + + def attribute_for_inspect(attr_name) + value = send(attr_name) + value.inspect + end + + if ActiveModel::VERSION::MAJOR < 6 + def attribute_names + self.class.attribute_names + end + + def has_attribute?(attr_name) + @attributes.key?(attr_name.to_s) + end + + def attribute_present?(attribute) + value = send(attribute) + !value.nil? && !(value.respond_to?(:empty?) && value.empty?) + end + + def _write_attribute(attr_name, value) + @attributes.write_from_user(attr_name.to_s, value) + value + end + end + end + + class Base + include ::ActiveModel::Model + include ::ActiveModel::Dirty + include ::ActiveModel::Attributes + include ::ActiveModel::Serializers::JSON + + include ::ActiveModel::Validations + include ::ActiveModel::Validations::Callbacks + + include ::ActiveRecord::Core + include ActiveRecordCompat + + define_model_callbacks :initialize, only: :after + define_model_callbacks :create, :destroy, :save, :update + + include Persistence + include ::ActiveRecord::AttributeMethods::Dirty + include ::ActiveRecord::Timestamp # must be included after Persistence + include Associations + include Views + include QueryHelper + include N1ql + include Relation + + extend Join + extend Enum + extend EnsureUnique + extend HasMany + extend Index + + Metadata = Struct.new(:key, :cas) + + class << self + def connect(**options) + @bucket = BucketProxy.new(::MTLibcouchbase::Bucket.new(**options)) + end + + def bucket=(bucket) + @bucket = bucket.is_a?(BucketProxy) ? bucket : BucketProxy.new(bucket) + end + + def bucket + @bucket ||= BucketProxy.new(Connection.bucket) + end + + def cluster + Connection.cluster + end + + def collection + CollectionProxy.new(bucket.default_collection) + end + + def uuid_generator + @uuid_generator ||= IdGenerator + end + + attr_writer :uuid_generator + + def find(*ids, quiet: false) + CouchbaseOrm.logger.debug { "Base.find(l##{ids.length}) #{ids}" } + + ids = ids.flatten.select(&:present?) + raise CouchbaseOrm::Error::EmptyNotAllowed, 'no id(s) provided' if ids.empty? + + records = quiet ? collection.get_multi(ids) : collection.get_multi!(ids) + CouchbaseOrm.logger.debug { "Base.find found(#{records})" } + records = records.zip(ids).map do |record, id| + new(record, id: id) if record + end + records.compact! + ids.length > 1 ? records : records[0] + end + + def find_by_id(*ids, **options) + options[:quiet] = true + find(*ids, **options) + end + alias [] find_by_id + + def exists?(id) + CouchbaseOrm.logger.debug { "Data - Exists? #{id}" } + collection.exists(id).exists + end + alias has_key? exists? + end + + class MismatchTypeError < RuntimeError; end + + # Add support for libcouchbase response objects + def initialize(model = nil, ignore_doc_type: false, **attributes) + CouchbaseOrm.logger.debug { "Initialize model #{model} with #{attributes.to_s.truncate(200)}" } + @__metadata__ = Metadata.new + + super() + + if model + case model + when Couchbase::Collection::GetResult + doc = HashWithIndifferentAccess.new(model.content) || raise('empty response provided') + type = doc.delete(:type) + doc.delete(:id) + + if type && !ignore_doc_type && type.to_s != self.class.design_document + raise CouchbaseOrm::Error::TypeMismatchError.new( + "document type mismatch, #{type} != #{self.class.design_document}", self + ) + end + + self.id = attributes[:id] if attributes[:id].present? + @__metadata__.cas = model.cas + + assign_attributes(doc) + when CouchbaseOrm::Base + clear_changes_information + super(model.attributes.except(:id, 'type')) + else + clear_changes_information + assign_attributes(**attributes.merge(Hash(model)).symbolize_keys) + end + else + clear_changes_information + super(attributes) + end + yield self if block_given? + + run_callbacks :initialize + end + + # Document ID is a special case as it is not stored in the document + attr_reader :id + + def id=(value) + raise 'ID cannot be changed' if @__metadata__.cas && value + + attribute_will_change!(:id) + @id = value.to_s.presence + end + + def [](key) + send(key) + end + + def []=(key, value) + send(:"#{key}=", value) + end + + # Public: Allows for access to ActiveModel functionality. + # + # Returns self. + def to_model + self + end + + # Public: Hashes identifying properties of the instance + # + # Ruby normally hashes an object to be used in comparisons. In our case + # we may have two techincally different objects referencing the same entity id. + # + # Returns a string representing the unique key. + def hash + "#{self.class.name}-#{id}-#{@__metadata__.cas}-#{@__attributes__.hash}".hash + end + + # Public: Overrides eql? to use == in the comparison. + # + # other - Another object to compare to + # + # Returns a boolean. + def eql?(other) + self == other + end + + # Public: Overrides == to compare via class and entity id. + # + # other - Another object to compare to + # + # Returns a boolean. + def ==(other) + super || (other.instance_of?(self.class) && !id.nil? && other.id == id) + end + end +end diff --git a/lib/couchbase_orm/connection.rb b/lib/couchbase_orm/connection.rb new file mode 100644 index 00000000..844402f4 --- /dev/null +++ b/lib/couchbase_orm/connection.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'couchbase' + +module CouchbaseOrm + class Connection + @@config = nil + def self.config + @@config || { + connection_string: "couchbase://#{ENV['COUCHBASE_HOST'] || '127.0.0.1'}", + username: ENV.fetch('COUCHBASE_USER', nil), + password: ENV.fetch('COUCHBASE_PASSWORD', nil), + bucket: ENV.fetch('COUCHBASE_BUCKET', nil), + } + end + + def self.config=(config) + @@config = config + end + + def self.cluster + @cluster ||= begin + cb_config = Couchbase::Configuration.new + cb_config.connection_string = config[:connection_string] || raise(CouchbaseOrm::Error, + 'Missing CouchbaseOrm connection string') + cb_config.username = config[:username] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm username') + cb_config.password = config[:password] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm password') + Couchbase::Cluster.connect(cb_config) + end + end + + def self.bucket + @bucket ||= begin + bucket_name = config[:bucket] || raise(CouchbaseOrm::Error, 'Missing CouchbaseOrm bucket name') + cluster.bucket(bucket_name) + end + end + end +end diff --git a/lib/couchbase_orm/error.rb b/lib/couchbase_orm/error.rb new file mode 100644 index 00000000..7e651ff9 --- /dev/null +++ b/lib/couchbase_orm/error.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module CouchbaseOrm + class Error < ::StandardError + attr_reader :record + + def initialize(message = nil, record = nil) + @record = record + super(message) + end + + class RecordInvalid < Error + def initialize(message = nil, record = nil) + if record + errors = record.errors.full_messages.join(', ') + message = I18n.t( + :"couchbase.#{record.class.design_document}.errors.messages.record_invalid", + errors: errors, + default: :'couchbase.errors.messages.record_invalid' + ) + end + super(message, record) + end + end + + class TypeMismatchError < Error; end + class RecordExists < Error; end + class EmptyNotAllowed < Error; end + end +end diff --git a/lib/couchbase_orm/id_generator.rb b/lib/couchbase_orm/id_generator.rb new file mode 100644 index 00000000..ff884ae9 --- /dev/null +++ b/lib/couchbase_orm/id_generator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'radix/base' + +module CouchbaseOrm + class IdGenerator + # Using base 65 as a form of compression (reduced length of ID string) + # No escape characters are required to display these in a URL + B65 = ::Radix::Base.new(::Radix::BASE::B62 + ['-', '_', '~']) + B10 = ::Radix::Base.new(10) + + # We don't really care about dates before this library was created + # This reduces the length of the ID significantly + SKIP_46_YEARS = 1_451_649_600 # 46.years.to_i + + # Generate a unique, orderable, ID using minimal bytes + def self.next(model) + # We are unlikely to see a clash here + now = Time.now + time = ((now.to_i - SKIP_46_YEARS) * 1_000_000) + now.usec + + # This makes it very very improbable that there will ever be an ID clash + # Distributed system safe! + prefix = time.to_s + tail = rand(1..9999).to_s.rjust(4, '0') + + "#{model.class.design_document}-#{Radix.convert("#{prefix}#{tail}", B10, B65)}" + end + end +end diff --git a/lib/couchbase-orm/locale/en.yml b/lib/couchbase_orm/locale/en.yml similarity index 100% rename from lib/couchbase-orm/locale/en.yml rename to lib/couchbase_orm/locale/en.yml diff --git a/lib/couchbase_orm/n1ql.rb b/lib/couchbase_orm/n1ql.rb new file mode 100644 index 00000000..52d4872f --- /dev/null +++ b/lib/couchbase_orm/n1ql.rb @@ -0,0 +1,132 @@ +# 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 + NO_VALUE = :no_value_specified + # sanitize for injection query + def self.sanitize(value) + case value + when String + value.gsub("'", "''").gsub('\\') { '\\\\' }.gsub('"', '\"') + when Array + value.map { |v| sanitize(v) } + else + value + end + end + + module ClassMethods + # 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 N1QL queries for a model + # class Post < CouchbaseOrm::Base + # n1ql :by_rating, emit_key: :rating + # end + # + # Post.by_rating do |response| + # # ... + # end + # TODO: add range keys [:startkey, :endkey] + def n1ql(name, query_fn: nil, emit_key: [], custom_order: nil, **options) + raise ArgumentError, "#{self} already respond_to? #{name}" if respond_to?(name) + + emit_key = Array.wrap(emit_key) + emit_key.each do |key| + raise "unknown emit_key attribute for n1ql :#{name}, emit_key: :#{key}" if key && !attribute_names.include?(key.to_s) + end + options = N1QL_DEFAULTS.merge(options) + method_opts = {} + method_opts[:emit_key] = emit_key + + @indexes ||= {} + @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) + 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 + 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 + N1QL_DEFAULTS = {include_docs: true}.freeze + + # add a n1ql query 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 + + define_singleton_method find_method do |value| + send n1ql_method, key: [value] + end + end + + private + + def convert_values(keys, values) + raise ArgumentError, "Empty keys but values are present, can't type cast" if keys.empty? && Array.wrap(values).any? + + keys.zip(Array.wrap(values)).map do |key, value_before_type_cast| + serialize_value(key, value_before_type_cast) + end + end + + def build_where(keys, values) + where = if values == NO_VALUE + '' + else + keys.zip(Array.wrap(values)) + .reject { |key, value| key.nil? && value.nil? } + .map { |key, value| build_match(key, value) } + .join(' AND ') + end + "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(',').to_s + end + + def build_limit(limit) + limit ? "limit #{limit}" : '' + end + + def run_query(keys, values, query_fn, custom_order: nil, descending: false, limit: nil, **options) + if query_fn + N1qlProxy.new(query_fn.call(bucket, values, Couchbase::Options::Query.new(**options))) + else + bucket_name = bucket.name + where = build_where(keys, values) + order = custom_order || build_order(keys, descending) + 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" } + N1qlProxy.new(result) + end + end + end + end +end diff --git a/lib/couchbase_orm/persistence.rb b/lib/couchbase_orm/persistence.rb new file mode 100644 index 00000000..68fff253 --- /dev/null +++ b/lib/couchbase_orm/persistence.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require 'active_model' +require 'active_support/hash_with_indifferent_access' + +module CouchbaseOrm + module Persistence + extend ActiveSupport::Concern + + included do + attribute :id, :string + end + + module ClassMethods + def create(attributes = nil, &block) + if attributes.is_a?(Array) + attributes.collect { |attr| create(attr, &block) } + else + instance = new(attributes, &block) + instance.save + instance + end + end + + def create!(attributes = nil, &block) + if attributes.is_a?(Array) + attributes.collect { |attr| create!(attr, &block) } + else + instance = new(attributes, &block) + instance.save! + instance + end + end + + # Raise an error if validation failed. + def fail_validate!(document) + raise Error::RecordInvalid.new('Failed to save the record', document) + end + + # Allow classes to overwrite the default document name + # extend ActiveModel::Naming (included by ActiveModel::Model) + def design_document(name = nil) + return @design_document unless name + + @design_document = name.to_s + end + + # Set a default design document + def inherited(child) + super + child.instance_eval do + @design_document = child.name.underscore + end + end + end + + # Returns true if this object hasn't been saved yet -- that is, a record + # for the object doesn't exist in the database yet; otherwise, returns false. + def new_record? + @__metadata__.cas.nil? && id.nil? + end + alias new? new_record? + + # Returns true if this object has been destroyed, otherwise returns false. + def destroyed? + !!(@__metadata__.cas && id.blank?) + end + + # Returns true if the record is persisted, i.e. it's not a new record and it was + # not destroyed, otherwise returns false. + def persisted? + id.present? + end + alias exists? persisted? + + # Saves the model. + # + # If the model is new, a record gets created in the database, otherwise + # the existing record gets updated. + def save(**options) + raise 'Cannot save a destroyed document!' if destroyed? + + new_record? ? _create_record(**options) : _update_record(**options) + end + + # Saves the model. + # + # If the model is new, a record gets created in the database, otherwise + # the existing record gets updated. + # + # By default, #save! always runs validations. If any of them fail + # CouchbaseOrm::Error::RecordInvalid gets raised, and the record won't be saved. + def save!(**options) + self.class.fail_validate!(self) unless save(**options) + self + end + + # Deletes the record in the database and freezes this instance to + # reflect that no changes should be made (since they can't be + # persisted). Returns the frozen instance. + # + # The record is simply removed, no callbacks are executed. + def delete(with_cas: false, **options) + options[:cas] = @__metadata__.cas if with_cas + CouchbaseOrm.logger.debug "Data - Delete #{id}" + self.class.collection.remove(id, **options) + + self.id = nil + clear_changes_information + freeze + self + end + + alias remove delete + + # Deletes the record in the database and freezes this instance to reflect + # that no changes should be made (since they can't be persisted). + # + # There's a series of callbacks associated with #destroy. + def destroy(with_cas: false, **options) + return self if destroyed? + raise 'model not persisted' unless persisted? + + run_callbacks :destroy do + destroy_associations! + + options[:cas] = @__metadata__.cas if with_cas + CouchbaseOrm.logger.debug "Data - Destroy #{id}" + self.class.collection.remove(id, **options) + + self.id = nil + + clear_changes_information + freeze + end + end + alias destroy! destroy + + # Updates a single attribute and saves the record. + # This is especially useful for boolean flags on existing records. Also note that + # + # * Validation is skipped. + # * \Callbacks are invoked. + def update_attribute(name, value) + public_send(:"#{name}=", value) + changed? ? save(validate: false) : true + end + + def assign_attributes(hash) + super(hash.with_indifferent_access.except('type')) + end + + # Updates the attributes of the model from the passed-in hash and saves the + # record. If the object is invalid, the saving will fail and false will be returned. + def update(hash) + assign_attributes(hash) + save + end + alias update_attributes update + + # Updates its receiver just like #update but calls #save! instead + # of +save+, so an exception is raised if the record is invalid and saving will fail. + def update!(hash) + assign_attributes(hash) # Assign attributes is provided by ActiveModel::AttributeAssignment + save! + end + alias update_attributes! update! + + # Updates the record without validating or running callbacks. + # Updates only the attributes that are passed in as parameters + # except if there is more than 16 attributes, in which case + # the whole record is saved. + def update_columns(with_cas: false, **hash) + raise 'unable to update columns, model not persisted' unless id + + assign_attributes(hash) + + options = {extended: true} + options[:cas] = @__metadata__.cas if with_cas + + # There is a limit of 16 subdoc operations per request + resp = if hash.length <= 16 + self.class.collection.mutate_in( + id, + hash.map { |k, v| Couchbase::MutateInSpec.replace(k.to_s, v) } + ) + else + # Fallback to writing the whole document + CouchbaseOrm.logger.debug { "Data - Replace #{id} #{attributes.to_s.truncate(200)}" } + self.class.collection.replace(id, attributes.except(:id).merge(type: self.class.design_document), **options) + end + + # Ensure the model is up to date + @__metadata__.cas = resp.cas + + changes_applied + self + end + + # Reloads the record from the database. + # + # This method finds record by its key and modifies the receiver in-place: + def reload + raise 'unable to reload, model not persisted' unless id + + CouchbaseOrm.logger.debug "Data - Get #{id}" + resp = self.class.collection.get!(id) + assign_attributes(resp.content.except('id')) # API return a nil id + @__metadata__.cas = resp.cas + + reset_associations + clear_changes_information + self + end + + # Updates the TTL of the document + def touch(**options) + CouchbaseOrm.logger.debug "Data - Touch #{id}" + _res = self.class.collection.touch(id, async: false, **options) + @__metadata__.cas = resp.cas + self + end + + protected + + def serialized_attributes + attributes.to_h do |k, v| + [k, self.class.attribute_types[k].serialize(v)] + end + end + + def _update_record(*_args, with_cas: false, **options) + return false unless perform_validations(:update, options) + return true unless changed? + + run_callbacks :update do + run_callbacks :save do + options[:cas] = @__metadata__.cas if with_cas + CouchbaseOrm.logger.debug { "_update_record - replace #{id} #{serialized_attributes.to_s.truncate(200)}" } + resp = self.class.collection.replace(id, serialized_attributes.except(:id).merge(type: self.class.design_document), + Couchbase::Options::Replace.new(**options)) + + # Ensure the model is up to date + @__metadata__.cas = resp.cas + + changes_applied + true + end + end + end + + def _create_record(*_args, **options) + return false unless perform_validations(:create, options) + + run_callbacks :create do + run_callbacks :save do + assign_attributes(id: self.class.uuid_generator.next(self)) unless id + CouchbaseOrm.logger.debug { "_create_record - Upsert #{id} #{serialized_attributes.to_s.truncate(200)}" } + + resp = self.class.collection.upsert(id, serialized_attributes.except(:id).merge(type: self.class.design_document), + Couchbase::Options::Upsert.new(**options)) + + # Ensure the model is up to date + @__metadata__.cas = resp.cas + + changes_applied + true + end + end + end + + def perform_validations(context, options = {}) + return valid?(context) if options[:validate] != false + + true + end + end +end diff --git a/lib/couchbase_orm/proxies/bucket_proxy.rb b/lib/couchbase_orm/proxies/bucket_proxy.rb new file mode 100644 index 00000000..1a7ea667 --- /dev/null +++ b/lib/couchbase_orm/proxies/bucket_proxy.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'couchbase_orm/proxies/n1ql_proxy' + +module CouchbaseOrm + class BucketProxy + def initialize(proxyfied) + raise ArgumentError, 'Must proxy a non nil object' if proxyfied.nil? + + @proxyfied = proxyfied + + self.class.define_method(:n1ql) do + N1qlProxy.new(@proxyfied.n1ql) + end + + self.class.define_method(:view) do |design, view, **opts, &block| + @results = nil if @current_query != "#{design}_#{view}" + @current_query = "#{design}_#{view}" + return @results if @results + + CouchbaseOrm.logger.debug "View - #{design} #{view}" + @results = ResultsProxy.new(@proxyfied.send(:view, design, view, **opts, &block)) + end + end + + if RUBY_VERSION.to_i >= 3 + def method_missing(name, *args, **options, &block) + @proxyfied.public_send(name, *args, **options, &block) + end + else + def method_missing(name, *args, &block) + @proxyfied.public_send(name, *args, &block) + end + end + end +end diff --git a/lib/couchbase_orm/proxies/collection_proxy.rb b/lib/couchbase_orm/proxies/collection_proxy.rb new file mode 100644 index 00000000..93a1cf99 --- /dev/null +++ b/lib/couchbase_orm/proxies/collection_proxy.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'couchbase' + +module CouchbaseOrm + class CollectionProxy + def get!(id, **options) + @proxyfied.get(id, Couchbase::Options::Get.new(**options)) + end + + def get(id, **options) + @proxyfied.get(id, Couchbase::Options::Get.new(**options)) + rescue Couchbase::Error::DocumentNotFound + nil + end + + def get_multi!(*ids, **options) + result = @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) + first_result_with_error = result.find(&:error) + raise first_result_with_error.error if first_result_with_error + + result + end + + def get_multi(*ids, **options) + result = @proxyfied.get_multi(*ids, Couchbase::Options::GetMulti.new(**options)) + result.reject(&:error) + end + + def remove!(id, **options) + @proxyfied.remove(id, Couchbase::Options::Remove.new(**options)) + end + + def remove(id, **options) + @proxyfied.remove(id, Couchbase::Options::Remove.new(**options)) + rescue Couchbase::Error::DocumentNotFound + nil + end + + def initialize(proxyfied) + raise 'Must proxy a non nil object' if proxyfied.nil? + + @proxyfied = proxyfied + end + + if RUBY_VERSION.to_i >= 3 + def method_missing(name, *args, **options, &block) + @proxyfied.public_send(name, *args, **options, &block) + end + else # :nocov: + def method_missing(name, *args, &block) + @proxyfied.public_send(name, *args, &block) + end + end + end +end diff --git a/lib/couchbase_orm/proxies/n1ql_proxy.rb b/lib/couchbase_orm/proxies/n1ql_proxy.rb new file mode 100644 index 00000000..0695569b --- /dev/null +++ b/lib/couchbase_orm/proxies/n1ql_proxy.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'couchbase_orm/proxies/results_proxy' + +module CouchbaseOrm + class N1qlProxy + def initialize(proxyfied) + @proxyfied = proxyfied + + self.class.define_method(:results) do |*_params, &block| + @results = nil if @current_query != to_s + @current_query = to_s + return @results if @results + + CouchbaseOrm.logger.debug { "Query - #{self}" } + + results = @proxyfied.rows + results = results.map { |r| block.call(r) } if block + @results = ResultsProxy.new(results.to_a) + end + + self.class.define_method(:to_s) do + @proxyfied.to_s.tr("\n", ' ') + end + + proxyfied.public_methods.each do |method| + next if public_methods.include?(method) + + self.class.define_method(method) do |*params, &block| + ret = @proxyfied.send(method, *params, &block) + ret.is_a?(@proxyfied.class) ? self : ret + end + end + end + + def method_missing(m, *args, &block) + results.send(m, *args, &block) + end + end +end diff --git a/lib/couchbase_orm/proxies/results_proxy.rb b/lib/couchbase_orm/proxies/results_proxy.rb new file mode 100644 index 00000000..0804923e --- /dev/null +++ b/lib/couchbase_orm/proxies/results_proxy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CouchbaseOrm + class ResultsProxy + def initialize(proxyfied) + @proxyfied = proxyfied + + raise ArgumentError, 'Proxyfied object must respond to :to_a' unless @proxyfied.respond_to?(:to_a) + + proxyfied.public_methods.each do |method| + next if public_methods.include?(method) + + self.class.define_method(method) do |*params, &block| + @proxyfied.send(method, *params, &block) + end + end + end + + def method_missing(m, *args, &block) + @proxyfied.to_a.send(m, *args, &block) + end + end +end diff --git a/lib/couchbase_orm/railtie.rb b/lib/couchbase_orm/railtie.rb new file mode 100644 index 00000000..36729a37 --- /dev/null +++ b/lib/couchbase_orm/railtie.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# +# Author:: Couchbase +# Copyright:: 2012 Couchbase, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'yaml' +require 'couchbase_orm/base' + +module Rails # :nodoc: + module Couchbase # :nodoc: + class Railtie < Rails::Railtie # :nodoc: + config.couchbase_orm = ActiveSupport::OrderedOptions.new + config.couchbase_orm.ensure_design_documents = true + + # Maping of rescued exceptions to HTTP responses + # + # @example + # railtie.rescue_responses + # + # @return [Hash] rescued responses + def self.rescue_responses + { + } + end + + config.send(:app_generators).orm :couchbase_orm, migration: false + + config.action_dispatch.rescue_responses&.merge!(rescue_responses) + + initializer 'couchbase_orm.setup_connection_config' do + CouchbaseOrm::Connection.config = Rails.application.config_for(:couchbase) + end + + # After initialization we will warn the user if we can't find a couchbase.yml and + # alert to create one. + initializer 'couchbase.warn_configuration_missing' do + unless ARGV.include?('couchbase:config') + config.after_initialize do + unless Rails.root.join('config', 'couchbase.yml').file? + puts "\nCouchbase config not found. Create a config file at: config/couchbase.yml" + puts "to generate one run: rails generate couchbase:config\n\n" + end + end + end + end + + # Set the proper error types for Rails. NotFound errors should be + # 404s and not 500s, validation errors are 422s. + initializer 'couchbase.load_http_errors' do |_app| + config.after_initialize do + ActionDispatch::ShowExceptions.rescue_responses.update(Railtie.rescue_responses) unless config.action_dispatch.rescue_responses + end + end + + # Check (and upgrade if needed) all design documents + config.after_initialize do |_app| + if config.couchbase_orm.ensure_design_documents + begin + ::CouchbaseOrm::Base.descendants.each(&:ensure_design_document!) + rescue ::MTLibcouchbase::Error::Timedout, ::MTLibcouchbase::Error::ConnectError, ::MTLibcouchbase::Error::NetworkError + # skip connection errors for now + end + end + end + end + end +end diff --git a/lib/couchbase_orm/relation.rb b/lib/couchbase_orm/relation.rb new file mode 100644 index 00000000..ccaa7395 --- /dev/null +++ b/lib/couchbase_orm/relation.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Relation + extend ActiveSupport::Concern + + class CouchbaseOrm_Relation + def initialize(model:, where: nil, order: nil, limit: nil, _not: false) + CouchbaseOrm.logger.debug "CouchbaseOrm_Relation init: #{model} where:#{where.inspect} not:#{_not.inspect} order:#{order.inspect} limit: #{limit}" + @model = model + @limit = limit + @where = [] + @order = {} + @order = merge_order(**order) if order + @where = merge_where(where, _not) if where + CouchbaseOrm.logger.debug "- #{self}" + end + + def to_s + "CouchbaseOrm_Relation: #{@model} where:#{@where.inspect} order:#{@order.inspect} limit: #{@limit}" + end + + def to_n1ql + bucket_name = @model.bucket.name + where = build_where + order = build_order + limit = build_limit + "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" + end + + def query + CouchbaseOrm.logger.debug("Query: #{self}") + n1ql_query = to_n1ql + 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" } + N1qlProxy.new(result) + end + + def ids + query.to_a + end + + def count + query.count + end + + def to_ary + query.results { |res| @model.find(res) }.to_ary + end + + alias to_a to_ary + + delegate :each, :map, :collect, to: :to_ary + + def delete_all + CouchbaseOrm.logger.debug { "Delete all: #{self}" } + ids = query.to_a + CouchbaseOrm::Connection.bucket.default_collection.remove_multi(ids) unless ids.empty? + end + + def where(**conds) + CouchbaseOrm_Relation.new(**initializer_arguments.merge(where: merge_where(conds))) + end + + def not(**conds) + CouchbaseOrm_Relation.new(**initializer_arguments.merge(where: merge_where(conds, _not: true))) + end + + def order(*lorder, **horder) + CouchbaseOrm_Relation.new(**initializer_arguments.merge(order: merge_order(*lorder, **horder))) + end + + def limit(limit) + CouchbaseOrm_Relation.new(**initializer_arguments.merge(limit: limit)) + end + + def all + CouchbaseOrm_Relation.new(**initializer_arguments) + end + + private + + def build_limit + @limit ? "limit #{@limit}" : '' + end + + def initializer_arguments + {model: @model, order: @order, where: @where, limit: @limit} + end + + def merge_order(*lorder, **horder) + raise ArgumentError, "invalid order passed by list: #{lorder.inspect}, must be symbols" unless lorder.all?(Symbol) + + order_literals = [:asc, :desc].freeze + unless horder.all? { |k, v| k.is_a?(Symbol) && order_literals.include?(v) } + raise ArgumentError, "Invalid order passed by hash: #{horder.inspect}, must be symbol -> :asc|:desc" + end + + @order + .merge(Array.wrap(lorder).to_h { |o| [o, :asc] }) + .merge(horder) + end + + def merge_where(conds, _not = false) + @where + (_not ? conds.to_a.map { |k, v| [k, v, :not] } : conds.to_a) + end + + def build_order + order = @order.map do |key, value| + "#{key} #{value}" + end.join(', ') + order.empty? ? 'meta().id' : order + end + + def build_where + ([[:type, @model.design_document]] + @where).map do |key, value, opt| + if opt == :not + @model.build_not_match(key, value) + else + @model.build_match(key, value) + end + end.join(' AND ') + end + end + + module ClassMethods + def where(**conds) + CouchbaseOrm_Relation.new(model: self, where: conds) + end + + def not(**conds) + CouchbaseOrm_Relation.new(model: self, where: conds, _not: true) + end + + def order(*ordersl, **ordersh) + order = ordersh.reverse_merge(ordersl.to_h { |o| [o, :asc] }) + CouchbaseOrm_Relation.new(model: self, order: order) + end + + def limit(limit) + CouchbaseOrm_Relation.new(model: self, limit: limit) + end + + def all + CouchbaseOrm_Relation.new(model: self) + end + + delegate :ids, :delete_all, :count, to: :all + end + end +end diff --git a/lib/couchbase-orm/types.rb b/lib/couchbase_orm/types.rb similarity index 76% rename from lib/couchbase-orm/types.rb rename to lib/couchbase_orm/types.rb index e6674c0d..df0bf2d4 100644 --- a/lib/couchbase-orm/types.rb +++ b/lib/couchbase_orm/types.rb @@ -1,6 +1,8 @@ -require "couchbase-orm/types/date" -require "couchbase-orm/types/date_time" -require "couchbase-orm/types/timestamp" +# frozen_string_literal: true + +require 'couchbase_orm/types/date' +require 'couchbase_orm/types/date_time' +require 'couchbase_orm/types/timestamp' if ActiveModel::VERSION::MAJOR < 6 # In Rails 5, the type system cannot allow overriding the default types diff --git a/lib/couchbase_orm/types/date.rb b/lib/couchbase_orm/types/date.rb new file mode 100644 index 00000000..eadf906e --- /dev/null +++ b/lib/couchbase_orm/types/date.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Types + class Date < ActiveModel::Type::Date + def serialize(value) + value&.iso8601 + end + end + end +end diff --git a/lib/couchbase_orm/types/date_time.rb b/lib/couchbase_orm/types/date_time.rb new file mode 100644 index 00000000..7057c4fe --- /dev/null +++ b/lib/couchbase_orm/types/date_time.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Types + class DateTime < ActiveModel::Type::DateTime + def cast(value) + super(value)&.utc + end + + def serialize(value) + value&.iso8601 + end + end + end +end diff --git a/lib/couchbase_orm/types/timestamp.rb b/lib/couchbase_orm/types/timestamp.rb new file mode 100644 index 00000000..5ec01f64 --- /dev/null +++ b/lib/couchbase_orm/types/timestamp.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Types + class Timestamp < ActiveModel::Type::DateTime + def cast(value) + return nil if value.nil? + return Time.at(value) if value.is_a?(Integer) + return Time.at(value.to_i) if value.is_a?(String) && value =~ /^[0-9]+$/ + return value.utc if value.is_a?(Time) + + super(value).utc + end + + def serialize(value) + value&.to_i + end + end + end +end diff --git a/lib/couchbase_orm/utilities/ensure_unique.rb b/lib/couchbase_orm/utilities/ensure_unique.rb new file mode 100644 index 00000000..59b110fa --- /dev/null +++ b/lib/couchbase_orm/utilities/ensure_unique.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module EnsureUnique + private + + def ensure_unique(attrs, name = nil, presence: true, &processor) + # index uses a special bucket key to allow record lookups based on + # the values of attrs. ensure_unique adds a simple lookup using + # one of the added methods to identify duplicate + name = index(attrs, name, presence: presence, &processor) + + validate do |record| + errors.add(name, 'has already been taken') unless record.send("#{name}_unique?") + end + end + end +end diff --git a/lib/couchbase_orm/utilities/enum.rb b/lib/couchbase_orm/utilities/enum.rb new file mode 100644 index 00000000..580986da --- /dev/null +++ b/lib/couchbase_orm/utilities/enum.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Enum + private + + def enum(options) + # options contains an optional default value, and the name of the + # enum, e.g enum visibility: %i(group org public), default: :group + default = options.delete(:default) + name = options.keys.first.to_sym + values = options[name] + + # values is assumed to be a list of symbols. each value is assigned an + # integer, and this number is used for db storage. numbers start at 1. + mapping = {} + values.each_with_index do |value, i| + mapping[value.to_sym] = i + 1 + mapping[i + 1] = value.to_sym + end + + # VISIBILITY = {group: 0, 0: group ...} + const_set(name.to_s.upcase, mapping) + + # lookup the default's integer value + if default + default_value = mapping[default] + raise 'Unknown default value' unless default_value + else + default_value = 1 + end + attribute name, :integer, default: default_value + + define_method "#{name}=" do |value| + unless value.nil? + value = case value + when Symbol, String + self.class.const_get(name.to_s.upcase)[value.to_sym] + else + Integer(value) + end + end + super(value) + end + + # keep the attribute's value within bounds + before_save do |record| + value = record[name] + + unless value.nil? + value = case value + when Symbol, String + record.class.const_get(name.to_s.upcase)[value.to_sym] + else + Integer(value) + end + end + + record[name] = (1..values.length).cover?(value) ? value : default_value + end + end + end +end diff --git a/lib/couchbase_orm/utilities/has_many.rb b/lib/couchbase_orm/utilities/has_many.rb new file mode 100644 index 00000000..eadf73ac --- /dev/null +++ b/lib/couchbase_orm/utilities/has_many.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module HasMany + # :foreign_key, :class_name, :through + 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(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 + + instance_var = "@__assoc_#{model}" + + klass = begin + class_name.constantize + rescue NameError + warn "WARNING: #{class_name} referenced in #{name} before it was aded" + + # 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, __FILE__, __LINE__ + 1 + class #{class_name} < CouchbaseOrm::Base # class Books < CouchbaseOrm::Base + attribute :#{foreign_key} # attribute :author_id + end # end + EKLASS + class_name.constantize + end + + build_index(type, klass, remote_class, remote_method, through_key, foreign_key) + + if remote_class + define_method(model) do + return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) + + remote_klass = remote_class.constantize + raise ArgumentError, "Can't find #{remote_method} without an id" unless id.present? + + enum = klass.__send__(remote_method, key: id) do |row| + case type + when :n1ql + remote_klass.find(row) + when :view + remote_klass.find(row[through_key]) + else + raise 'type is unknown' + end + end + + instance_variable_set(instance_var, enum) + end + else + define_method(model) do + return instance_variable_get(instance_var) if instance_variable_defined?(instance_var) + + instance_variable_set(instance_var, id ? klass.__send__(remote_method, 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) + when :view + build_index_view(klass, remote_class, remote_method, through_key, foreign_key) + else + raise 'type is unknown' + 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 + else + klass.class_eval do + index_view foreign_key, validate: false + end + end + end + + def build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_key) + if remote_class + klass.class_eval do + n1ql remote_method, emit_key: 'id', query_fn: proc { |bucket, values, options| + raise ArgumentError, 'values[0] must not be blank' if values[0].blank? + + cluster.query(<<~QUERY, options) + SELECT raw #{through_key} FROM `#{bucket.name}`#{' '} + WHERE type = \"#{design_document}\" AND #{foreign_key} = #{quote(values[0])} + QUERY + } + end + else + klass.class_eval do + index_n1ql foreign_key, validate: false + end + end + end + end +end diff --git a/lib/couchbase_orm/utilities/index.rb b/lib/couchbase_orm/utilities/index.rb new file mode 100644 index 00000000..abe99270 --- /dev/null +++ b/lib/couchbase_orm/utilities/index.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Index + private + + def index(attrs, name = nil, presence: true, &processor) + attrs = Array(attrs).flatten + name ||= attrs.map(&:to_s).join('_') + + find_by_method = "find_by_#{name}" + processor_method = "process_#{name}" + bucket_key_method = "#{name}_bucket_key" + bucket_key_vals_method = "#{name}_bucket_key_vals" + class_bucket_key_method = "generate_#{bucket_key_method}" + original_bucket_key_var = "@original_#{bucket_key_method}" + + #---------------- + # keys + #---------------- + # class method to generate a bucket key given input values + define_singleton_method(class_bucket_key_method) do |*values| + processed = send(processor_method, *values) + "#{@design_document}#{name}-#{processed}" + end + + # instance method that uses the class method to generate a bucket key + # given the current value of each of the key's component attributes + define_method(bucket_key_method) do |_args = nil| + self.class.send(class_bucket_key_method, *send(bucket_key_vals_method)) + end + + # collect a list of values for each key component attribute + define_method(bucket_key_vals_method) do + attrs.collect { |attr| self.class.attribute_types[attr.to_s].cast(self[attr]) } + end + + #---------------- + # helpers + #---------------- + # simple wrapper around the processor proc if supplied + define_singleton_method(processor_method) do |*values| + values = attrs.zip(values).map { |attr, value| attribute_types[attr.to_s].serialize(attribute_types[attr.to_s].cast(value)) } + if processor + yield(values.length == 1 ? values.first : values) + else + values.join('-') + end + end + + # use the bucket key as an index - lookup records by attr values + define_singleton_method(find_by_method) do |*values| + key = send(class_bucket_key_method, *values) + CouchbaseOrm.logger.debug { "#{find_by_method}: #{class_bucket_key_method} with values #{values.inspect} give key: #{key}" } + id = collection.get(key)&.content + if id + mod = find_by_id(id) + return mod if mod + + # Clean up record if the id doesn't exist + collection.remove(key) + else + CouchbaseOrm.logger.debug("#{find_by_method}: #{key} not found") + end + + nil + end + + #---------------- + # validations + #---------------- + # ensure each component of the unique key is present + if presence + attrs.each do |attr| + validates attr, presence: true + attribute attr + end + end + + define_method("#{name}_unique?") do + values = send(bucket_key_vals_method) + other = self.class.send(find_by_method, *values) + !other || other.id == self.id + end + + #---------------- + # callbacks + #---------------- + # before a save is complete, while changes are still available, store + # a copy of the current bucket key for comparison if any of the key + # components have been modified + before_save do |record| + if attrs.any? { |attr| record.changes.include?(attr) } + args = attrs.collect { |attr| send(:"#{attr}_was") || send(attr) } + instance_variable_set(original_bucket_key_var, self.class.send(class_bucket_key_method, *args)) + end + end + + # after the values are persisted, delete the previous key and store the + # new one. the id of the current record is used as the key's value. + after_save do |record| + original_key = instance_variable_get(original_bucket_key_var) + + if original_key + begin + check_ref_id = record.class.collection.get(original_key) + if check_ref_id && check_ref_id.content == record.id + CouchbaseOrm.logger.debug { "Removing old key #{original_key}" } + record.class.collection.remove(original_key, cas: check_ref_id.cas) + end + end + end + + record.class.collection.upsert(record.send(bucket_key_method), record.id) + + instance_variable_set(original_bucket_key_var, nil) + end + + # cleanup by removing the bucket key before the record is deleted + # TODO: handle unpersisted, modified component values + before_destroy do |record| + check_ref_id = record.class.collection.get(record.send(bucket_key_method)) + record.class.collection.remove(record.send(bucket_key_method), cas: check_ref_id.cas) if check_ref_id && check_ref_id.content == record.id + true + end + + # return the name used to construct the added method names so other + # code can call the special index methods easily + name + end + end +end diff --git a/lib/couchbase_orm/utilities/join.rb b/lib/couchbase_orm/utilities/join.rb new file mode 100644 index 00000000..a145044e --- /dev/null +++ b/lib/couchbase_orm/utilities/join.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module Join + private + + # join adds methods for retrieving the join model by user or group, and + # methods for retrieving either model through the join model (e.g all + # users who are in a group). model_a and model_b must be strings or symbols + # and are assumed to be singularised, underscored versions of model names + def join(model_a, model_b, options = {}) + # store the join model names for use by has_many associations + @join_models = [model_a.to_s, model_b.to_s] + + # join :user, :group => design_document :ugj + doc_name = options[:design_document] || "#{model_a.to_s[0]}#{model_b.to_s[0]}j".to_sym + design_document doc_name + + # a => b + add_single_sided_features(model_a) + add_joint_lookups(model_a, model_b) + + # b => a + add_single_sided_features(model_b) + add_joint_lookups(model_b, model_a, true) + + # use Index to allow lookups of joint records more efficiently than + # with a view or search + index ["#{model_a}_id".to_sym, "#{model_b}_id".to_sym], :join + end + + def add_single_sided_features(model) + # belongs_to :group + belongs_to model + + # view :by_group_id + view "by_#{model}_id" + + # find_by_group_id + instance_eval " + def self.find_by_#{model}_id(#{model}_id) # def self.find_by_group_id(group_id) + by_#{model}_id(key: #{model}_id) # by_group_id(key: group_id) + end # end + ", __FILE__, __LINE__ - 4 + end + + def add_joint_lookups(model_a, model_b, reverse = false) + # find_by_user_id_and_group_id + instance_eval " + def self.find_by_#{model_a}_id_and_#{model_b}_id(#{model_a}_id, #{model_b}_id) # def self.find_by_user_id_and_group_id(user_id, group_id) + self.find_by_join([#{reverse ? model_b : model_a}_id, #{reverse ? model_a : model_b}_id]) # self.find_by_join([user_id, group_id]) + end # end + ", __FILE__, __LINE__ - 4 + + # user_ids_by_group_id + instance_eval " + def self.#{model_a}_ids_by_#{model_b}_id(#{model_b}_id) # def self.user_ids_by_group_id(group_id) + self.find_by_#{model_b}_id(#{model_b}_id).map(&:#{model_a}_id) # self.find_by_group_id(group_id).map(&:user_id) + end # end + ", __FILE__, __LINE__ - 4 + + # users_by_group_id + instance_eval " + def self.#{model_a.to_s.pluralize}_by_#{model_b}_id(#{model_b}_id) # def self.users_by_group_id(group_id) + self.find_by_#{model_b}_id(#{model_b}_id).map(&:#{model_a}) # self.find_by_group_id(group_id).map(&:user) + end # end + ", __FILE__, __LINE__ - 4 + end + end +end diff --git a/lib/couchbase_orm/utilities/query_helper.rb b/lib/couchbase_orm/utilities/query_helper.rb new file mode 100644 index 00000000..eafd0345 --- /dev/null +++ b/lib/couchbase_orm/utilities/query_helper.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module CouchbaseOrm + module QueryHelper + extend ActiveSupport::Concern + + module ClassMethods + def build_match(key, value) + if value.nil? + "#{key} IS NOT VALUED" + elsif value.is_a?(Array) && value.include?(nil) + "(#{build_match(key, nil)} OR #{build_match(key, value.compact)})" + elsif value.is_a?(Array) + "#{key} IN #{quote(value)}" + else + "#{key} = #{quote(value)}" + end + end + + def build_not_match(key, value) + if value.nil? + "#{key} IS VALUED" + elsif value.is_a?(Array) && value.include?(nil) + "(#{build_not_match(key, nil)} AND #{build_not_match(key, value.compact)})" + elsif value.is_a?(Array) + "#{key} NOT IN #{quote(value)}" + else + "#{key} != #{quote(value)}" + end + end + + def serialize_value(key, value_before_type_cast) + value = + if value_before_type_cast.is_a?(Array) + value_before_type_cast.map do |v| + attribute_types[key.to_s].serialize(attribute_types[key.to_s].cast(v)) + end + else + attribute_types[key.to_s].serialize(attribute_types[key.to_s].cast(value_before_type_cast)) + end + CouchbaseOrm.logger.debug do + "convert_values: #{key} => #{value_before_type_cast.inspect} => #{value.inspect} #{value.class} #{attribute_types[key.to_s]}" + end + value + end + + def quote(value) + if value.is_a? String + "'#{N1ql.sanitize(value)}'" + elsif value.is_a? Array + "[#{value.map { |v| quote(v) }.join(', ')}]" + elsif value.nil? + nil + else + N1ql.sanitize(value).to_s + end + end + end + end +end diff --git a/lib/couchbase_orm/version.rb b/lib/couchbase_orm/version.rb new file mode 100644 index 00000000..d19a1c98 --- /dev/null +++ b/lib/couchbase_orm/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module CouchbaseOrm + VERSION = '1.1.1' +end diff --git a/lib/couchbase_orm/views.rb b/lib/couchbase_orm/views.rb new file mode 100644 index 00000000..12efddd3 --- /dev/null +++ b/lib/couchbase_orm/views.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'active_model' + +module CouchbaseOrm + module Views + extend ActiveSupport::Concern + + module ClassMethods + # Defines a view for the model + # + # @param [Symbol, String, Array] names names of the views + # @param [Hash] options options passed to the {Couchbase::View} + # + # @example Define some views for a model + # class Post < CouchbaseOrm::Base + # view :all + # view :by_rating, emit_key: :rating + # end + # + # Post.by_rating do |response| + # # ... + # end + def view(name, map: nil, emit_key: nil, reduce: nil, **options) + raise ArgumentError, "#{self} already respond_to? #{name}" if respond_to?(name) + + if emit_key.instance_of?(Array) + emit_key.each do |key| + raise "unknown emit_key attribute for view :#{name}, emit_key: :#{key}" if key && !attribute_names.include?(key.to_s) + end + elsif emit_key && !attribute_names.include?(emit_key.to_s) + raise "unknown emit_key attribute for view :#{name}, emit_key: :#{emit_key}" + end + + options = VIEW_DEFAULTS.merge(options) + + method_opts = {} + method_opts[:map] = map if map + method_opts[:reduce] = reduce if reduce + + unless method_opts.key? :map + if emit_key.instance_of?(Array) + method_opts[:map] = <<~EMAP + function(doc) { + if (doc.type === "{{design_document}}") { + emit([#{emit_key.map { |key| "doc.#{key}" }.join(',')}], null); + } + } + EMAP + else + emit_key ||= :created_at + method_opts[:map] = <<~EMAP + function(doc) { + if (doc.type === "{{design_document}}") { + emit(doc.#{emit_key}, null); + } + } + EMAP + end + end + + @views ||= {} + + name = name.to_sym + @views[name] = method_opts + + singleton_class.__send__(:define_method, name) do |**opts, &result_modifier| + opts = options.merge(opts).reverse_merge(scan_consistency: :request_plus) + 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) + elsif opts[:include_docs] + include_docs(bucket.view_query(@design_document, name.to_s, + Couchbase::Options::View.new(**opts.except(:include_docs)))) + else + bucket.view_query(@design_document, name.to_s, Couchbase::Options::View.new(**opts.except(:include_docs))) + end + end + end + + VIEW_DEFAULTS = {include_docs: true}.freeze + + # add a view and lookup method to the model for finding all records + # using a value in the supplied attr. + def index_view(attr, validate: true, find_method: nil, view_method: nil) + view_method ||= "by_#{attr}" + find_method ||= "find_#{view_method}" + + validates(attr, presence: true) if validate + view view_method, emit_key: attr + + instance_eval " + def self.#{find_method}(#{attr}) # def self.find_by_author(author) + #{view_method}(key: #{attr}) # by_author(key: author) + end # end + ", __FILE__, __LINE__ - 4 + end + + def ensure_design_document! + return false unless @views && !@views.empty? + + existing = {} + update_required = false + + # Grab the existing view details + ddoc = + begin + bucket.view_indexes.get_design_document(@design_document, :production) + rescue Couchbase::Error::DesignDocumentNotFound + nil + end + existing = ddoc.views if ddoc + views_actual = {} + # Fill in the design documents + @views.each do |name, document| + views_actual[name.to_s] = Couchbase::Management::View.new( + document[:map]&.gsub('{{design_document}}', @design_document), + document[:reduce]&.gsub('{{design_document}}', @design_document) + ) + end + + # Check there are no changes we need to apply + views_actual.each do |name, desired| + check = existing[name] + if check + cmap = (check.map || '').gsub(/\s+/, '') + creduce = (check.reduce || '').gsub(/\s+/, '') + dmap = (desired.map || '').gsub(/\s+/, '') + dreduce = (desired.reduce || '').gsub(/\s+/, '') + + unless cmap == dmap && creduce == dreduce + update_required = true + break + end + else + update_required = true + break + end + end + + # Updated the design document + if update_required + document = Couchbase::Management::DesignDocument.new + document.views = views_actual + document.name = @design_document + bucket.view_indexes.upsert_design_document(document, :production) + + true + else + false + end + end + + def include_docs(view_result) + if view_result.rows.length > 1 + find(view_result.rows.map(&:id)) + elsif view_result.rows.length == 1 + [find(view_result.rows.first.id)] + else + [] + end + end + end + end +end diff --git a/lib/ext/query_n1ql.rb b/lib/ext/query_n1ql.rb index 1d1bd0cd..0379de79 100644 --- a/lib/ext/query_n1ql.rb +++ b/lib/ext/query_n1ql.rb @@ -1,124 +1,120 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true module MTLibcouchbase - class QueryN1QL - N1P_QUERY_STATEMENT = 1 - N1P_CONSISTENCY_REQUEST = 2 + class QueryN1QL + N1P_QUERY_STATEMENT = 1 + N1P_CONSISTENCY_REQUEST = 2 - def initialize(connection, reactor, n1ql, **_opts) - @connection = connection - @reactor = reactor + def initialize(connection, reactor, n1ql, **_opts) + @connection = connection + @reactor = reactor - @n1ql = n1ql - @request_handle = FFI::MemoryPointer.new :pointer, 1 - end - - attr_reader :connection, :n1ql + @n1ql = n1ql + @request_handle = FFI::MemoryPointer.new :pointer, 1 + end - def get_count(metadata) - metadata[:metrics][:resultCount] - end + attr_reader :connection, :n1ql - def perform(limit: nil, **_options, &blk) - raise 'not connected' unless @connection.handle - raise 'query already in progress' if @query_text - raise 'callback required' unless block_given? - - # customise the size based on the request being made - orig_limit = @n1ql.limit - begin - if orig_limit && limit - @n1ql.limit = limit if orig_limit > limit - end - @query_text = @n1ql.to_s - rescue StandardError - @query_text = nil - raise - ensure - @n1ql.limit = orig_limit - end + def get_count(metadata) + metadata[:metrics][:resultCount] + end - @reactor.schedule do - @error = nil - @callback = blk - - @cmd = Ext::CMDN1QL.new - @params = Ext.n1p_new - err = Ext.n1p_setconsistency(@params, N1P_CONSISTENCY_REQUEST) - if err == :success - err = Ext.n1p_setquery(@params, @query_text, @query_text.bytesize, N1P_QUERY_STATEMENT) - if err == :success - - err = Ext.n1p_mkcmd(@params, @cmd) - if err == :success - pointer = @cmd.to_ptr - @connection.requests[pointer.address] = self - - @cmd[:callback] = @connection.get_callback(:n1ql_callback) - @cmd[:handle] = @request_handle - - err = Ext.n1ql_query(@connection.handle, pointer, @cmd) - if err != :success - error(Error.lookup(err).new('full text search not scheduled')) - end - else - error(Error.lookup(err).new('failed to build full text search command')) - end - else - error(Error.lookup(err).new('failed to build full text search query structure')) - end - else - error(Error.lookup(err).new('failed set consistency value')) - end + def perform(limit: nil, **_options, &blk) + raise 'not connected' unless @connection.handle + raise 'query already in progress' if @query_text + raise 'callback required' unless blk + + # customise the size based on the request being made + orig_limit = @n1ql.limit + begin + @n1ql.limit = limit if orig_limit && limit && (orig_limit > limit) + @query_text = @n1ql.to_s + rescue StandardError + @query_text = nil + raise + ensure + @n1ql.limit = orig_limit + end + + @reactor.schedule do + @error = nil + @callback = blk + + @cmd = Ext::CMDN1QL.new + @params = Ext.n1p_new + err = Ext.n1p_setconsistency(@params, N1P_CONSISTENCY_REQUEST) + if err == :success + err = Ext.n1p_setquery(@params, @query_text, @query_text.bytesize, N1P_QUERY_STATEMENT) + if err == :success + + err = Ext.n1p_mkcmd(@params, @cmd) + if err == :success + pointer = @cmd.to_ptr + @connection.requests[pointer.address] = self + + @cmd[:callback] = @connection.get_callback(:n1ql_callback) + @cmd[:handle] = @request_handle + + err = Ext.n1ql_query(@connection.handle, pointer, @cmd) + error(Error.lookup(err).new('full text search not scheduled')) if err != :success + else + error(Error.lookup(err).new('failed to build full text search command')) end + else + error(Error.lookup(err).new('failed to build full text search query structure')) + end + else + error(Error.lookup(err).new('failed set consistency value')) end + end + end - # Row is JSON value representing the result - def received(row) - return if @error + # Row is JSON value representing the result + def received(row) + return if @error - @callback.call(false, row) - rescue StandardError => e - @error = e - cancel - end + @callback.call(false, row) + rescue StandardError => e + @error = e + cancel + end - # Example metadata - # {:requestID=>"36162fce-ef39-4821-bf03-449e4073185d", :signature=>{:*=>"*"}, :results=>[], :status=>"success", - # :metrics=>{:elapsedTime=>"15.298243ms", :executionTime=>"15.256975ms", :resultCount=>12, :resultSize=>8964}} - def received_final(metadata) - @query_text = nil + # Example metadata + # {:requestID=>"36162fce-ef39-4821-bf03-449e4073185d", :signature=>{:*=>"*"}, :results=>[], :status=>"success", + # :metrics=>{:elapsedTime=>"15.298243ms", :executionTime=>"15.256975ms", :resultCount=>12, :resultSize=>8964}} + def received_final(metadata) + @query_text = nil - @connection.requests.delete(@cmd.to_ptr.address) - @cmd = nil + @connection.requests.delete(@cmd.to_ptr.address) + @cmd = nil - Ext.n1p_free(@params) - @params = nil + Ext.n1p_free(@params) + @params = nil - if @error - if @error == :cancelled - @callback.call(:final, metadata) - else - @callback.call(:error, @error) - end - else - @callback.call(:final, metadata) - end + if @error + if @error == :cancelled + @callback.call(:final, metadata) + else + @callback.call(:error, @error) end + else + @callback.call(:final, metadata) + end + end - def error(obj) - @error = obj - received_final(nil) - end + def error(obj) + @error = obj + received_final(nil) + end - def cancel - @error ||= :cancelled - @reactor.schedule do - if @connection.handle && @cmd - Ext.n1ql_cancel(@connection.handle, @handle_ptr.get_pointer(0)) - received_final(nil) - end - end + def cancel + @error ||= :cancelled + @reactor.schedule do + if @connection.handle && @cmd + Ext.n1ql_cancel(@connection.handle, @handle_ptr.get_pointer(0)) + received_final(nil) end + end end + end end diff --git a/lib/rails/generators/couchbase_orm/config/config_generator.rb b/lib/rails/generators/couchbase_orm/config/config_generator.rb index 2e6e4819..5b871052 100644 --- a/lib/rails/generators/couchbase_orm/config/config_generator.rb +++ b/lib/rails/generators/couchbase_orm/config/config_generator.rb @@ -1,27 +1,26 @@ -# encoding: utf-8 +# frozen_string_literal: true require 'rails/generators/couchbase_orm_generator' module CouchbaseOrm - module Generators - class ConfigGenerator < Rails::Generators::Base - desc 'Creates a Couchbase configuration file at config/couchbase.yml' - argument :bucket_name, type: :string, optional: true - argument :username, type: :string, optional: true - argument :password, type: :string, optional: true + module Generators + class ConfigGenerator < Rails::Generators::Base + desc 'Creates a Couchbase configuration file at config/couchbase.yml' + argument :bucket_name, type: :string, optional: true + argument :username, type: :string, optional: true + argument :password, type: :string, optional: true - def self.source_root - @_couchbase_source_root ||= File.expand_path('../templates', __FILE__) - end + def self.source_root + @_couchbase_source_root ||= File.expand_path('templates', __dir__) + end - def app_name - Rails::Application.subclasses.first.parent.to_s.underscore - end + def app_name + Rails::Application.subclasses.first.parent.to_s.underscore + end - def create_config_file - template 'couchbase.yml', File.join('config', 'couchbase.yml') - end - - end + def create_config_file + template 'couchbase.yml', File.join('config', 'couchbase.yml') + end end + end end diff --git a/lib/rails/generators/couchbase_orm_generator.rb b/lib/rails/generators/couchbase_orm_generator.rb index 93640c99..a6163ec3 100644 --- a/lib/rails/generators/couchbase_orm_generator.rb +++ b/lib/rails/generators/couchbase_orm_generator.rb @@ -1,4 +1,5 @@ -# encoding: utf-8 +# frozen_string_literal: true + # # Author:: Couchbase # Copyright:: 2012 Couchbase, Inc. @@ -20,23 +21,19 @@ require 'rails/generators/named_base' require 'rails/generators/active_model' -module CouchbaseOrm #:nodoc: - module Generators #:nodoc: - - class Base < ::Rails::Generators::NamedBase #:nodoc: - - def self.source_root - @_couchbase_source_root ||= - File.expand_path("../#{base_name}/#{generator_name}/templates", __FILE__) - end - - unless methods.include?(:module_namespacing) - def module_namespacing(&block) - yield if block - end - end +module CouchbaseOrm # :nodoc: + module Generators # :nodoc: + class Base < ::Rails::Generators::NamedBase # :nodoc: + def self.source_root + @_couchbase_source_root ||= + File.expand_path("../#{base_name}/#{generator_name}/templates", __FILE__) + end + unless methods.include?(:module_namespacing) + def module_namespacing(&block) + yield if block end - + end end + end end diff --git a/spec/associations_spec.rb b/spec/associations_spec.rb deleted file mode 100644 index ff45ed1d..00000000 --- a/spec/associations_spec.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - - -class Parent < CouchbaseOrm::Base - attribute :name -end - -class RandomOtherType < CouchbaseOrm::Base - attribute :name -end - -class Child < CouchbaseOrm::Base - attribute :name - - belongs_to :parent, dependent: :destroy -end - -class Assembly < CouchbaseOrm::Base - attribute :name - - has_and_belongs_to_many :parts, autosave: true -end - -class Part < CouchbaseOrm::Base - attribute :name - - has_and_belongs_to_many :assemblies, dependent: :destroy, autosave: true -end - - -describe CouchbaseOrm::Associations do - describe 'belongs_to' do - it "should work with dependent associations" do - parent = Parent.create!(name: 'joe') - child = Child.create!(name: 'bob', parent_id: parent.id) - - expect(parent.persisted?).to be(true) - expect(child.persisted?).to be(true) - id = parent.id - - child.destroy - expect(child.destroyed?).to be(true) - expect(parent.destroyed?).to be(false) - - # Ensure that parent has been destroyed - expect { Parent.find(id) }.to raise_error(Couchbase::Error::DocumentNotFound) - - expect(Parent.find_by_id(id)).to be(nil) - - expect { parent.reload }.to raise_error(Couchbase::Error::DocumentNotFound) - - # Save will always return true unless the model is changed (won't touch the database) - parent.name = 'should fail' - expect { parent.save }.to raise_error(Couchbase::Error::DocumentNotFound) - expect { parent.save! }.to raise_error(Couchbase::Error::DocumentNotFound) - end - - it "should cache associations" do - parent = Parent.create!(name: 'joe') - child = Child.create!(name: 'bob', parent_id: parent.id) - - id = child.parent.__id__ - expect(parent.__id__).not_to eq(child.parent.__id__) - expect(parent).to eq(child.parent) - expect(child.parent.__id__).to eq(id) - - child.reload - expect(parent).to eq(child.parent) - expect(child.parent.__id__).not_to eq(id) - - child.destroy - end - - it "should ignore associations when delete is used" do - parent = Parent.create!(name: 'joe') - child = Child.create!(name: 'bob', parent_id: parent.id) - - id = child.id - child.delete - - expect(Child.exists?(id)).to be(false) - expect(Parent.exists?(parent.id)).to be(true) - - id = parent.id - parent.delete - expect(Parent.exists?(id)).to be(false) - end - - it "should raise an error if an invalid type is being assigned" do - begin - parent = RandomOtherType.create!(name: 'joe') - expect { Child.create!(name: 'bob', parent: parent) }.to raise_error(ArgumentError) - ensure - parent.delete - end - end - - describe Parent do - it_behaves_like "ActiveModel" - end - - describe Child do - it_behaves_like "ActiveModel" - end - end - - describe 'has_and_belongs_to_many' do - it "should work with dependent associations" do - assembly = Assembly.create!(name: 'a1') - part = Part.create!(name: 'p1', assemblies: [assembly]) - assembly.reload - - expect(assembly.persisted?).to be(true) - expect(part.persisted?).to be(true) - - part.destroy - expect(part.destroyed?).to be(true) - expect(assembly.destroyed?).to be(true) - end - - it "should cache associations" do - assembly = Assembly.create!(name: 'a1') - part = Part.create!(name: 'p1', assembly_ids: [assembly.id]) - assembly.reload - - id = part.assemblies.first.__id__ - expect(assembly.__id__).not_to eq(part.assemblies.first.__id__) - expect(assembly).to eq(part.assemblies.first) - expect(part.assemblies.first.__id__).to eq(id) - - part.reload - expect(assembly).to eq(part.assemblies.first) - expect(part.assemblies.first.__id__).not_to eq(id) - - part.destroy - end - - it "should ignore associations when delete is used" do - assembly = Assembly.create!(name: 'a1') - part = Part.create!(name: 'p1', assembly_ids: [assembly.id]) - assembly.reload - - id = part.id - part.delete - - expect(Part.exists?(id)).to be(false) - expect(Assembly.exists?(assembly.id)).to be(true) - - id = assembly.id - assembly.delete - expect(Assembly.exists?(id)).to be(false) - end - - it "should raise an error if an invalid type is being assigned" do - begin - assembly = RandomOtherType.create!(name: 'a1') - expect { Part.create!(name: 'p1', assemblies: [assembly]) }.to raise_error(ArgumentError) - ensure - assembly.delete - end - end - - it "should add association with single" do - assembly = Assembly.create!(name: 'a1') - part = Part.create!(name: 'p1', assemblies: [assembly]) - - expect(assembly.reload.parts.map(&:id)).to match_array([part.id]) - end - - it 'should add association with multiple' do - assembly = Assembly.create!(name: 'a1') - part1 = Part.create!(name: 'p1', assemblies: [assembly]) - part2 = Part.create!(name: 'p2', assemblies: [assembly]) - - expect(assembly.reload.parts.map(&:id)).to match_array([part1.id, part2.id]) - end - - it "should remove association with single" do - assembly1 = Assembly.create!(name: 'a1') - assembly2 = Assembly.create!(name: 'a2') - part = Part.create!(name: 'p1', assemblies: [assembly1]) - part.assemblies = [assembly2] - part.save! - - expect(assembly1.reload.parts.map(&:id)).to be_empty - expect(assembly2.reload.parts.map(&:id)).to match_array([part.id]) - end - - it 'should remove association with multiple' do - assembly1 = Assembly.create!(name: 'a1') - assembly2 = Assembly.create!(name: 'a2') - part1 = Part.create!(name: 'p1', assemblies: [assembly1]) - part2 = Part.create!(name: 'p2', assemblies: [assembly2]) - - part1.assemblies = part1.assemblies + [assembly2] - part1.save! - - expect(assembly1.reload.parts.map(&:id)).to match_array([part1.id]) - expect(assembly2.reload.parts.map(&:id)).to match_array([part1.id, part2.id]) - end - - describe Assembly do - it_behaves_like "ActiveModel" - end - - describe Part do - it_behaves_like "ActiveModel" - end - end -end diff --git a/spec/base_spec.rb b/spec/base_spec.rb deleted file mode 100644 index 3745b0e6..00000000 --- a/spec/base_spec.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - -class BaseTest < CouchbaseOrm::Base - attribute :name, :string - attribute :job, :string -end - -class CompareTest < CouchbaseOrm::Base - attribute :age, :integer -end - -class TimestampTest < CouchbaseOrm::Base - attribute :created_at, :datetime -end - -describe CouchbaseOrm::Base do - it "should be comparable to other objects" do - base = BaseTest.create!(name: 'joe') - base2 = BaseTest.create!(name: 'joe') - base3 = BaseTest.create!(ActiveSupport::HashWithIndifferentAccess.new(name: 'joe')) - - expect(base).to eq(base) - expect(base).to be(base) - expect(base).not_to eq(base2) - - same_base = BaseTest.find(base.id) - expect(base).to eq(same_base) - expect(base).not_to be(same_base) - expect(base2).not_to eq(same_base) - - base.delete - base2.delete - base3.delete - end - - it "should be inspectable" do - base = BaseTest.create!(name: 'joe') - expect(base.inspect).to eq("#") - end - - it "should load database responses" do - base = BaseTest.create!(name: 'joe') - resp = BaseTest.bucket.default_collection.get(base.id) - - base_loaded = BaseTest.new(resp, id: base.id) - - expect(base_loaded.id).to eq(base.id) - expect(base_loaded).to eq(base) - expect(base_loaded).not_to be(base) - - base.destroy - end - - it "should not load objects if there is a type mismatch" do - base = BaseTest.create!(name: 'joe') - - expect { CompareTest.find_by_id(base.id) }.to raise_error(CouchbaseOrm::Error::TypeMismatchError) - - base.destroy - end - - it "should support serialisation" do - base = BaseTest.create!(name: 'joe') - - base_id = base.id - expect(base.to_json).to eq({ id: base_id, name: 'joe', job: nil }.to_json) - expect(base.to_json(only: :name)).to eq({ name: 'joe' }.to_json) - - base.destroy - end - - it "should support dirty attributes" do - begin - base = BaseTest.new - expect(base.changes.empty?).to be(true) - expect(base.previous_changes.empty?).to be(true) - - base.name = 'change' - expect(base.changes.empty?).to be(false) - - # Attributes are set by key - base = BaseTest.new - base[:name] = 'bob' - expect(base.changes.empty?).to be(false) - - # Attributes are set by initializer from hash - base = BaseTest.new({ name: 'bob' }) - expect(base.changes.empty?).to be(false) - expect(base.previous_changes.empty?).to be(true) - - # A saved model should have no changes - base = BaseTest.create!(name: 'joe') - expect(base.changes.empty?).to be(true) - expect(base.previous_changes.empty?).to be(true) - - # Attributes are copied from the existing model - base = BaseTest.new(base) - expect(base.changes.empty?).to be(false) - expect(base.previous_changes.empty?).to be(true) - ensure - base.destroy if base.id - end - end - - it "should try to load a model with nothing but an ID" do - begin - base = BaseTest.create!(name: 'joe') - obj = CouchbaseOrm.try_load(base.id) - expect(obj).to eq(base) - ensure - base.destroy - end - end - - it "should try to load a model with nothing but single-multiple ID" do - begin - bases = [BaseTest.create!(name: 'joe')] - objs = CouchbaseOrm.try_load(bases.map(&:id)) - expect(objs).to match_array(bases) - ensure - bases.each(&:destroy) - end - end - - it "should try to load a model with nothing but multiple ID" do - begin - bases = [BaseTest.create!(name: 'joe'), CompareTest.create!(age: 12)] - objs = CouchbaseOrm.try_load(bases.map(&:id)) - expect(objs).to match_array(bases) - ensure - bases.each(&:destroy) - end - end - - it "should set the attribute on creation" do - base = BaseTest.create!(name: 'joe') - expect(base.name).to eq('joe') - ensure - base.destroy - end - - it "should support getting the attribute by key" do - base = BaseTest.create!(name: 'joe') - expect(base[:name]).to eq('joe') - ensure - base.destroy - end - - if ActiveModel::VERSION::MAJOR >= 6 - it "should have timestamp attributes for create in model" do - expect(TimestampTest.timestamp_attributes_for_create_in_model).to eq(["created_at"]) - end - end - - it "should generate a timestamp on creation" do - base = TimestampTest.create!() - expect(base.created_at).to be_a(Time) - end - - describe BaseTest do - it_behaves_like "ActiveModel" - end - - describe CompareTest do - it_behaves_like "ActiveModel" - end -end diff --git a/spec/collection_proxy_spec.rb b/spec/collection_proxy_spec.rb deleted file mode 100644 index c6030968..00000000 --- a/spec/collection_proxy_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -require File.expand_path("../support", __FILE__) -require File.expand_path("../../lib/couchbase-orm/proxies/collection_proxy", __FILE__) - -class Proxyfied - def get(key, options = nil) - raise Couchbase::Error::DocumentNotFound - end - def remove(key, options = nil) - raise Couchbase::Error::DocumentNotFound - end -end - -describe CouchbaseOrm::CollectionProxy do - it "should raise an error when get is called with bang version" do - expect { CouchbaseOrm::CollectionProxy.new(Proxyfied.new).get!('key') }.to raise_error(Couchbase::Error::DocumentNotFound) - end - - it "should not raise an error when get is called with non bang version" do - expect { CouchbaseOrm::CollectionProxy.new(Proxyfied.new).get('key') }.to_not raise_error - end - - it "should raise an error when remove is called with bang version" do - expect { CouchbaseOrm::CollectionProxy.new(Proxyfied.new).remove!('key') }.to raise_error(Couchbase::Error::DocumentNotFound) - end - - it "should not raise an error when remove is called with non bang version" do - expect { CouchbaseOrm::CollectionProxy.new(Proxyfied.new).remove('key') }.to_not raise_error - end -end diff --git a/spec/couchbase_orm/associations_spec.rb b/spec/couchbase_orm/associations_spec.rb new file mode 100644 index 00000000..b8bd6e64 --- /dev/null +++ b/spec/couchbase_orm/associations_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class Parent < CouchbaseOrm::Base + attribute :name +end + +class RandomOtherType < CouchbaseOrm::Base + attribute :name +end + +class Child < CouchbaseOrm::Base + attribute :name + + belongs_to :parent, dependent: :destroy +end + +class Assembly < CouchbaseOrm::Base + attribute :name + + has_and_belongs_to_many :parts, autosave: true +end + +class Part < CouchbaseOrm::Base + attribute :name + + has_and_belongs_to_many :assemblies, dependent: :destroy, autosave: true +end + +describe CouchbaseOrm::Associations do + describe 'belongs_to' do + it 'works with dependent associations' do + parent = Parent.create!(name: 'joe') + child = Child.create!(name: 'bob', parent_id: parent.id) + + expect(parent.persisted?).to be(true) + expect(child.persisted?).to be(true) + id = parent.id + + child.destroy + expect(child.destroyed?).to be(true) + expect(parent.destroyed?).to be(false) + + # Ensure that parent has been destroyed + expect { Parent.find(id) }.to raise_error(Couchbase::Error::DocumentNotFound) + + expect(Parent.find_by_id(id)).to be_nil + + expect { parent.reload }.to raise_error(Couchbase::Error::DocumentNotFound) + + # Save will always return true unless the model is changed (won't touch the database) + parent.name = 'should fail' + expect { parent.save }.to raise_error(Couchbase::Error::DocumentNotFound) + expect { parent.save! }.to raise_error(Couchbase::Error::DocumentNotFound) + end + + it 'caches associations' do + parent = Parent.create!(name: 'joe') + child = Child.create!(name: 'bob', parent_id: parent.id) + + id = child.parent.__id__ + expect(parent.__id__).not_to eq(child.parent.__id__) + expect(parent).to eq(child.parent) + expect(child.parent.__id__).to eq(id) + + child.reload + expect(parent).to eq(child.parent) + expect(child.parent.__id__).not_to eq(id) + + child.destroy + end + + it 'ignores associations when delete is used' do + parent = Parent.create!(name: 'joe') + child = Child.create!(name: 'bob', parent_id: parent.id) + + id = child.id + child.delete + + expect(Child.exists?(id)).to be(false) + expect(Parent.exists?(parent.id)).to be(true) + + id = parent.id + parent.delete + expect(Parent.exists?(id)).to be(false) + end + + it 'raises an error if an invalid type is being assigned' do + parent = RandomOtherType.create!(name: 'joe') + expect { Child.create!(name: 'bob', parent: parent) }.to raise_error(ArgumentError) + ensure + parent.delete + end + + describe Parent do + it_behaves_like 'ActiveModel' + end + + describe Child do + it_behaves_like 'ActiveModel' + end + end + + describe 'has_and_belongs_to_many' do + it 'works with dependent associations' do + assembly = Assembly.create!(name: 'a1') + part = Part.create!(name: 'p1', assemblies: [assembly]) + assembly.reload + + expect(assembly.persisted?).to be(true) + expect(part.persisted?).to be(true) + + part.destroy + expect(part.destroyed?).to be(true) + expect(assembly.destroyed?).to be(true) + end + + it 'caches associations' do + assembly = Assembly.create!(name: 'a1') + part = Part.create!(name: 'p1', assembly_ids: [assembly.id]) + assembly.reload + + id = part.assemblies.first.__id__ + expect(assembly.__id__).not_to eq(part.assemblies.first.__id__) + expect(assembly).to eq(part.assemblies.first) + expect(part.assemblies.first.__id__).to eq(id) + + part.reload + expect(assembly).to eq(part.assemblies.first) + expect(part.assemblies.first.__id__).not_to eq(id) + + part.destroy + end + + it 'ignores associations when delete is used' do + assembly = Assembly.create!(name: 'a1') + part = Part.create!(name: 'p1', assembly_ids: [assembly.id]) + assembly.reload + + id = part.id + part.delete + + expect(Part.exists?(id)).to be(false) + expect(Assembly.exists?(assembly.id)).to be(true) + + id = assembly.id + assembly.delete + expect(Assembly.exists?(id)).to be(false) + end + + it 'raises an error if an invalid type is being assigned' do + assembly = RandomOtherType.create!(name: 'a1') + expect { Part.create!(name: 'p1', assemblies: [assembly]) }.to raise_error(ArgumentError) + ensure + assembly.delete + end + + it 'adds association with single' do + assembly = Assembly.create!(name: 'a1') + part = Part.create!(name: 'p1', assemblies: [assembly]) + + expect(assembly.reload.parts.map(&:id)).to match_array([part.id]) + end + + it 'adds association with multiple' do + assembly = Assembly.create!(name: 'a1') + part1 = Part.create!(name: 'p1', assemblies: [assembly]) + part2 = Part.create!(name: 'p2', assemblies: [assembly]) + + expect(assembly.reload.parts.map(&:id)).to match_array([part1.id, part2.id]) + end + + it 'removes association with single' do + assembly1 = Assembly.create!(name: 'a1') + assembly2 = Assembly.create!(name: 'a2') + part = Part.create!(name: 'p1', assemblies: [assembly1]) + part.assemblies = [assembly2] + part.save! + + expect(assembly1.reload.parts.map(&:id)).to be_empty + expect(assembly2.reload.parts.map(&:id)).to match_array([part.id]) + end + + it 'removes association with multiple' do + assembly1 = Assembly.create!(name: 'a1') + assembly2 = Assembly.create!(name: 'a2') + part1 = Part.create!(name: 'p1', assemblies: [assembly1]) + part2 = Part.create!(name: 'p2', assemblies: [assembly2]) + + part1.assemblies = part1.assemblies + [assembly2] + part1.save! + + expect(assembly1.reload.parts.map(&:id)).to match_array([part1.id]) + expect(assembly2.reload.parts.map(&:id)).to match_array([part1.id, part2.id]) + end + + describe Assembly do + it_behaves_like 'ActiveModel' + end + + describe Part do + it_behaves_like 'ActiveModel' + end + end +end diff --git a/spec/couchbase_orm/base_spec.rb b/spec/couchbase_orm/base_spec.rb new file mode 100644 index 00000000..f9efc3aa --- /dev/null +++ b/spec/couchbase_orm/base_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class BaseTest < CouchbaseOrm::Base + attribute :name, :string + attribute :job, :string +end + +class CompareTest < CouchbaseOrm::Base + attribute :age, :integer +end + +class TimestampTest < CouchbaseOrm::Base + attribute :created_at, :datetime +end + +describe CouchbaseOrm::Base do + it 'is comparable to other objects' do + base = BaseTest.create!(name: 'joe') + base2 = BaseTest.create!(name: 'joe') + base3 = BaseTest.create!(ActiveSupport::HashWithIndifferentAccess.new(name: 'joe')) + + expect(base).to eq(base) # rubocop:disable RSpec/IdenticalEqualityAssertion + expect(base).to be(base) # rubocop:disable RSpec/IdenticalEqualityAssertion + expect(base).not_to eq(base2) + + same_base = BaseTest.find(base.id) + expect(base).to eq(same_base) + expect(base).not_to be(same_base) + expect(base2).not_to eq(same_base) + + base.delete + base2.delete + base3.delete + end + + it 'is inspectable' do + base = BaseTest.create!(name: 'joe') + expect(base.inspect).to eq("#") + end + + it 'loads database responses' do + base = BaseTest.create!(name: 'joe') + resp = BaseTest.bucket.default_collection.get(base.id) + + base_loaded = BaseTest.new(resp, id: base.id) + + expect(base_loaded.id).to eq(base.id) + expect(base_loaded).to eq(base) + expect(base_loaded).not_to be(base) + + base.destroy + end + + it 'does not load objects if there is a type mismatch' do + base = BaseTest.create!(name: 'joe') + + expect { CompareTest.find_by_id(base.id) }.to raise_error(CouchbaseOrm::Error::TypeMismatchError) + + base.destroy + end + + it 'supports serialisation' do + base = BaseTest.create!(name: 'joe') + + base_id = base.id + expect(base.to_json).to eq({id: base_id, name: 'joe', job: nil}.to_json) + expect(base.to_json(only: :name)).to eq({name: 'joe'}.to_json) + + base.destroy + end + + it 'supports dirty attributes' do + base = BaseTest.new + expect(base.changes.empty?).to be(true) + expect(base.previous_changes.empty?).to be(true) + + base.name = 'change' + expect(base.changes.empty?).to be(false) + + # Attributes are set by key + base = BaseTest.new + base[:name] = 'bob' + expect(base.changes.empty?).to be(false) + + # Attributes are set by initializer from hash + base = BaseTest.new({name: 'bob'}) + expect(base.changes.empty?).to be(false) + expect(base.previous_changes.empty?).to be(true) + + # A saved model should have no changes + base = BaseTest.create!(name: 'joe') + expect(base.changes.empty?).to be(true) + expect(base.previous_changes.empty?).to be(true) + + # Attributes are copied from the existing model + base = BaseTest.new(base) + expect(base.changes.empty?).to be(false) + expect(base.previous_changes.empty?).to be(true) + ensure + base.destroy if base.id + end + + it 'tries to load a model with nothing but an ID' do + base = BaseTest.create!(name: 'joe') + obj = CouchbaseOrm.try_load(base.id) + expect(obj).to eq(base) + ensure + base.destroy + end + + it 'tries to load a model with nothing but single-multiple ID' do + bases = [BaseTest.create!(name: 'joe')] + objs = CouchbaseOrm.try_load(bases.map(&:id)) + expect(objs).to match_array(bases) + ensure + bases.each(&:destroy) + end + + it 'tries to load a model with nothing but multiple ID' do + bases = [BaseTest.create!(name: 'joe'), CompareTest.create!(age: 12)] + objs = CouchbaseOrm.try_load(bases.map(&:id)) + expect(objs).to match_array(bases) + ensure + bases.each(&:destroy) + end + + it 'sets the attribute on creation' do + base = BaseTest.create!(name: 'joe') + expect(base.name).to eq('joe') + ensure + base.destroy + end + + it 'supports getting the attribute by key' do + base = BaseTest.create!(name: 'joe') + expect(base[:name]).to eq('joe') + ensure + base.destroy + end + + if ActiveModel::VERSION::MAJOR >= 6 + it 'has timestamp attributes for create in model' do + expect(TimestampTest.timestamp_attributes_for_create_in_model).to eq(['created_at']) + end + end + + it 'generates a timestamp on creation' do + base = TimestampTest.create! + expect(base.created_at).to be_a(Time) + end + + describe BaseTest do + it_behaves_like 'ActiveModel' + end + + describe CompareTest do + it_behaves_like 'ActiveModel' + end +end diff --git a/spec/couchbase_orm/collection_proxy_spec.rb b/spec/couchbase_orm/collection_proxy_spec.rb new file mode 100644 index 00000000..1ef1e26e --- /dev/null +++ b/spec/couchbase_orm/collection_proxy_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require File.expand_path('support', __dir__) +require File.expand_path('../../lib/couchbase_orm/proxies/collection_proxy', __dir__) + +class Proxyfied + def get(_key, _options = nil) + raise Couchbase::Error::DocumentNotFound + end + + def remove(_key, _options = nil) + raise Couchbase::Error::DocumentNotFound + end +end + +describe CouchbaseOrm::CollectionProxy do + it 'raises an error when get is called with bang version' do + expect { described_class.new(Proxyfied.new).get!('key') }.to raise_error(Couchbase::Error::DocumentNotFound) + end + + it 'does not raise an error when get is called with non bang version' do + expect { described_class.new(Proxyfied.new).get('key') }.not_to raise_error + end + + it 'raises an error when remove is called with bang version' do + expect { described_class.new(Proxyfied.new).remove!('key') }.to raise_error(Couchbase::Error::DocumentNotFound) + end + + it 'does not raise an error when remove is called with non bang version' do + expect { described_class.new(Proxyfied.new).remove('key') }.not_to raise_error + end +end diff --git a/spec/couchbase_orm/enum_spec.rb b/spec/couchbase_orm/enum_spec.rb new file mode 100644 index 00000000..f57a340d --- /dev/null +++ b/spec/couchbase_orm/enum_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class EnumTest < CouchbaseOrm::Base + enum rating: [:awesome, :good, :okay, :bad], default: :okay + enum color: [:red, :green, :blue] +end + +describe CouchbaseOrm::Enum do + it 'creates an attribute' do + base = EnumTest.create!(rating: :good, color: :red) + expect(base.attribute_names).to eq(%w[id rating color]) + end + + it 'sets the attribute' do + base = EnumTest.create!(rating: :good, color: :red) + expect(base.rating).not_to be_nil + expect(base.color).not_to be_nil + end + + it 'converts it to an int' do + base = EnumTest.create!(rating: :good, color: :red) + expect(base.rating).to eq 2 + expect(base.color).to eq 1 + end + + it 'uses default value' do + base = EnumTest.create! + expect(base.rating).to eq 3 + expect(base.color).to eq 1 + end +end diff --git a/spec/couchbase_orm/has_many_spec.rb b/spec/couchbase_orm/has_many_spec.rb new file mode 100644 index 00000000..06d5cd76 --- /dev/null +++ b/spec/couchbase_orm/has_many_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +shared_examples 'has_many example' do |parameter| + before :all do + @context = parameter[:context].to_s + @rating_test_class = Kernel.const_get("Rating#{@context.camelize}Test".classify) + @object_test_class = Kernel.const_get("Object#{@context.camelize}Test".classify) + @object_rating_test_class = Kernel.const_get("ObjectRating#{@context.camelize}Test".classify) + + @rating_test_class.ensure_design_document! + @object_test_class.ensure_design_document! + @object_rating_test_class.ensure_design_document! + + @rating_test_class.delete_all + @object_test_class.delete_all + @object_rating_test_class.delete_all + end + + after do + @rating_test_class.delete_all + @object_test_class.delete_all + @object_rating_test_class.delete_all + end + + it 'returns matching results' do + first = @object_test_class.create! name: :bob + second = @object_test_class.create! name: :jane + + rate = @rating_test_class.create! rating: :awesome, "object_#{@context}_test": first + @rating_test_class.create! rating: :bad, "object_#{@context}_test": second + @rating_test_class.create! rating: :good, "object_#{@context}_test": first + + expect(rate.try("object_#{@context}_test_id")).to eq(first.id) + expect(@rating_test_class.respond_to?(:"find_by_object_#{@context}_test_id")).to be(true) + expect(first.respond_to?(:"rating_#{@context}_tests")).to be(true) + + docs = first.try(:"rating_#{@context}_tests").collect(&:rating) + + expect(docs).to match_array([1, 2]) + + first.destroy + expect { @rating_test_class.find rate.id }.to raise_error(Couchbase::Error::DocumentNotFound) + expect(@rating_test_class.send(:"#{@context}_all").count).to be(1) + end + + it 'works through a join model' do + first = @object_test_class.create! name: :bob + second = @object_test_class.create! name: :jane + + rate1 = @rating_test_class.create! rating: :awesome, "object_#{@context}_test": first + _rate2 = @rating_test_class.create! rating: :bad, "object_#{@context}_test": second + _rate3 = @rating_test_class.create! rating: :good, "object_#{@context}_test": first + + ort = @object_rating_test_class.create! "object_#{@context}_test": first, "rating_#{@context}_test": rate1 + @object_rating_test_class.create! "object_#{@context}_test": second, "rating_#{@context}_test": rate1 + + expect(ort.try(:"rating_#{@context}_test_id".to_sym)).to eq(rate1.id) + expect(rate1.respond_to?(:"object_#{@context}_tests")).to be(true) + docs = rate1.try(:"object_#{@context}_tests").collect(&:name) + + expect(docs).to match_array(%w[bob jane]) + end + + it 'works with new objects not yet saved' do + existing_object = @object_test_class.create! name: :bob + expect(existing_object.send(:"rating_#{@context}_tests")).to be_empty + + @rating_test_class.create! rating: :good, "object_#{@context}_test": existing_object + + new_object = @object_test_class.new name: :jane + expect(new_object.send(:"rating_#{@context}_tests")).to be_empty + end +end + +describe CouchbaseOrm::HasMany do + context 'with view' do + class ObjectRatingViewTest < CouchbaseOrm::Base + join :object_view_test, :rating_view_test + view :view_all + end + + class RatingViewTest < CouchbaseOrm::Base + enum rating: [:awesome, :good, :okay, :bad], default: :okay + belongs_to :object_view_test + + has_many :object_view_tests, through: :object_rating_view_test + view :view_all + end + + class ObjectViewTest < CouchbaseOrm::Base + attribute :name, type: String + has_many :rating_view_tests, dependent: :destroy + + view :view_all + end + + include_examples('has_many example', context: :view) + end + + context 'with n1ql' do + class ObjectRatingN1qlTest < CouchbaseOrm::Base + join :object_n1ql_test, :rating_n1ql_test + + n1ql :n1ql_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 + + n1ql :n1ql_all + end + + class ObjectN1qlTest < CouchbaseOrm::Base + attribute :name, type: String + + has_many :rating_n1ql_tests, dependent: :destroy, type: :n1ql + + n1ql :n1ql_all + end + + include_examples('has_many example', context: :n1ql) + end +end diff --git a/spec/couchbase_orm/id_generator_spec.rb b/spec/couchbase_orm/id_generator_spec.rb new file mode 100644 index 00000000..5cddf4a8 --- /dev/null +++ b/spec/couchbase_orm/id_generator_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'couchbase_orm' +class IdTestModel < CouchbaseOrm::Base; end + +describe CouchbaseOrm::IdGenerator do + it 'does not generate ID clashes' do + model = IdTestModel.new + + ids1 = [] + thread1 = Thread.new do + 10_000.times do + ids1 << described_class.next(model) + end + end + + ids2 = [] + thread2 = Thread.new do + 10_000.times do + ids2 << described_class.next(model) + end + end + + ids3 = [] + thread3 = Thread.new do + 10_000.times do + ids3 << described_class.next(model) + end + end + + ids4 = [] + thread4 = Thread.new do + 10_000.times do + ids4 << described_class.next(model) + end + end + + thread1.join + thread2.join + thread3.join + thread4.join + + results = [ids1, ids2, ids3, ids4].flatten + expect(results.uniq).to eq(results) + end +end diff --git a/spec/couchbase_orm/index_spec.rb b/spec/couchbase_orm/index_spec.rb new file mode 100644 index 00000000..658200af --- /dev/null +++ b/spec/couchbase_orm/index_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class IndexTest < CouchbaseOrm::Base + attribute :email, type: String + attribute :name, type: String, default: :joe + ensure_unique :email, presence: false +end + +class NoUniqueIndexTest < CouchbaseOrm::Base + attribute :email, type: String + attribute :name, type: String, default: :joe + + index :email, presence: false +end + +class IndexEnumTest < CouchbaseOrm::Base + enum visibility: [:group, :authority, :public], default: :authority + enum color: [:red, :green, :blue] +end + +describe CouchbaseOrm::Index do + after do + IndexTest.all.map(&:destroy) + end + + it 'prevents models being created if they should have unique keys' do + joe = IndexTest.create!(email: 'joe@aca.com') + expect { IndexTest.create!(email: 'joe@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) + + joe.email = 'other@aca.com' + joe.save + other = IndexTest.new(email: 'joe@aca.com') + expect(other.save).to be(true) + + expect { IndexTest.create!(email: 'joe@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) + expect { IndexTest.create!(email: 'other@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) + + joe.destroy + other.destroy + + again = IndexTest.new(email: 'joe@aca.com') + expect(again.save).to be(true) + + again.destroy + end + + it 'provides helper methods for looking up the model' do + joe = IndexTest.create!(email: 'joe@aca.com') + + joe_again = IndexTest.find_by_email('joe@aca.com') + expect(joe).to eq(joe_again) + + joe.destroy + end + + it 'cleans up itself if dangling keys are left' do + joe = IndexTest.create!(email: 'joe@aca.com') + joe.delete # no callbacks are executed + + again = IndexTest.new(email: 'joe@aca.com') + expect(again.save).to be(true) + + again.destroy + end + + it 'works with nil values' do + joe = IndexTest.create! + expect(IndexTest.find_by_email(nil)).to eq(joe) + + joe.email = 'joe@aca.com' + joe.save! + expect(IndexTest.find_by_email('joe@aca.com')).to eq(joe) + + joe.email = nil + joe.save! + expect(IndexTest.find_by_email('joe@aca.com')).to be_nil + expect(IndexTest.find_by_email(nil)).to eq(joe) + + joe.destroy + end + + it 'works with enumerators' do + # Test symbol + enum = IndexEnumTest.create!(visibility: :public) + expect(enum.visibility).to eq(3) + enum.destroy + + # Test number + enum = IndexEnumTest.create!(visibility: 2) + expect(enum.visibility).to eq(2) + enum.destroy + + # Test default + enum = IndexEnumTest.create! + expect(enum.visibility).to eq(2) + enum.destroy + + # Test default default + enum = IndexEnumTest.create! + expect(enum.color).to eq(1) + end + + it "does not overwrite index's that do not belong to the current model" do + joe = NoUniqueIndexTest.create! + expect(NoUniqueIndexTest.find_by_email(nil)).to eq(joe) + + joe.email = 'joe@aca.com' + joe.save! + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) + + joe2 = NoUniqueIndexTest.create! + joe2.email = 'joe@aca.com' # joe here is deliberate + joe2.save! + + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe2) + + # Joe's indexing should not remove joe2 index + joe.email = nil + joe.save! + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe2) + + # Test destroy + joe.email = 'joe@aca.com' + joe.save! + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) + + # Index should not be updated + joe2.destroy + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) + + # index should be updated + joe.email = nil + joe.save! + expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to be_nil + + joe.destroy + end +end diff --git a/spec/couchbase_orm/n1ql_spec.rb b/spec/couchbase_orm/n1ql_spec.rb new file mode 100644 index 00000000..23fc16cc --- /dev/null +++ b/spec/couchbase_orm/n1ql_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class N1QLTest < CouchbaseOrm::Base + attribute :name, type: String + attribute :lastname, type: String + enum rating: [:awesome, :good, :okay, :bad], default: :okay + + n1ql :by_custom_rating, emit_key: [:rating], query_fn: proc { |bucket, _values, options| + cluster.query("SELECT raw meta().id FROM `#{bucket.name}` WHERE type = 'n1_ql_test' AND rating IN [1, 2] ORDER BY name ASC", options) + } + n1ql :by_name, emit_key: [:name] + n1ql :by_lastname, emit_key: [:lastname] + n1ql :by_rating_emit, emit_key: :rating + + n1ql :by_custom_rating_values, emit_key: [:rating], query_fn: proc { |bucket, values, options| + cluster.query("SELECT raw meta().id FROM `#{bucket.name}` where type = 'n1_ql_test' AND rating IN #{quote(values[0])} ORDER BY name ASC", options) + } + n1ql :by_rating_reverse, emit_key: :rating, custom_order: 'name DESC' + n1ql :by_rating_without_docs, emit_key: :rating, include_docs: false + + # This generates both: + # view :by_rating, emit_key: :rating + # def self.find_by_rating(rating); end # also provide this helper function + index_n1ql :rating +end + +describe CouchbaseOrm::N1ql do + before do + N1QLTest.delete_all + end + + after(:all) do + N1QLTest.delete_all + end + + it 'does not allow n1ql to override existing methods' do + expect { N1QLTest.n1ql :all }.to raise_error(ArgumentError) + end + + it 'performs a query and return the n1ql' do + N1QLTest.create! name: :bob + docs = N1QLTest.all.collect(&:name) + expect(docs).to eq(%w[bob]) + end + + it 'queries by non-nil value' do + _anonymous = N1QLTest.create! + bob = N1QLTest.create! name: :bob + + expect(N1QLTest.by_name(key: 'bob').to_a).to eq [bob] + end + + it 'queries by nil value' do + anonymous = N1QLTest.create! lastname: 'Anonymous' + anonymous_no_property = N1QLTest.create! lastname: 'Anonymous without name property' + + CouchbaseOrm::Connection.bucket.default_collection.mutate_in(anonymous_no_property.id, [ + Couchbase::MutateInSpec.remove('name'), + ]) + + anonymous_no_property.reload + + _bob = N1QLTest.create! name: :bob + + expect(N1QLTest.by_name(key: nil).to_a).to match_array [anonymous, anonymous_no_property] + end + + it 'queries all when key is not set' do + anonymous = N1QLTest.create! + bob = N1QLTest.create! name: :bob + + expect(N1QLTest.by_name.to_a).to eq [anonymous, bob] + end + + it 'works with other keys' do + N1QLTest.create! name: :bob, rating: :good + N1QLTest.create! name: :jane, rating: :awesome + N1QLTest.create! name: :greg, rating: :bad + + docs = N1QLTest.by_name(descending: true).collect(&:name) + expect(docs).to eq(%w[jane greg bob]) + + docs = N1QLTest.by_rating(descending: true).collect(&:rating) + expect(docs).to eq([4, 2, 1]) + end + + it 'returns matching results' do + N1QLTest.create! name: :bob, rating: :awesome + N1QLTest.create! name: :jane, rating: :awesome + N1QLTest.create! name: :greg, rating: :bad + N1QLTest.create! name: :mel, rating: :good + + docs = N1QLTest.find_by_rating(1).collect(&:name) + + expect(Set.new(docs)).to eq(Set.new(%w[bob jane])) + + docs = N1QLTest.by_custom_rating.collect(&:name) + + expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) + end + + it 'returns matching results with reverse order' do + N1QLTest.create! name: :bob, rating: :awesome + N1QLTest.create! name: :jane, rating: :awesome + N1QLTest.create! name: :greg, rating: :bad + N1QLTest.create! name: :mel, rating: :good + + docs = N1QLTest.by_rating_reverse(key: 1).collect(&:name) + + expect(docs).to eq(%w[jane bob]) + end + + it 'returns matching results without full documents' do + inst_bob = N1QLTest.create! name: :bob, rating: :awesome + inst_jane = N1QLTest.create! name: :jane, rating: :awesome + N1QLTest.create! name: :greg, rating: :bad + N1QLTest.create! name: :mel, rating: :good + + docs = N1QLTest.by_rating_without_docs(key: 1) + + expect(Set.new(docs)).to eq(Set.new([inst_bob.id, inst_jane.id])) + end + + it 'returns matching results with nil usage' do + N1QLTest.create! name: :bob, lastname: nil + N1QLTest.create! name: :jane, lastname: 'dupond' + + docs = N1QLTest.by_lastname(key: [nil]).collect(&:name) + expect(docs).to eq(%w[bob]) + end + + it 'returns matching results with custom n1ql query' do + N1QLTest.create! name: :bob, rating: :awesome + N1QLTest.create! name: :jane, rating: :awesome + N1QLTest.create! name: :greg, rating: :bad + N1QLTest.create! name: :mel, rating: :good + + docs = N1QLTest.by_custom_rating.collect(&:name) + + expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) + + docs = N1QLTest.by_custom_rating_values(key: [[1, 2]]).collect(&:name) + + expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) + end +end diff --git a/spec/couchbase_orm/persistence_spec.rb b/spec/couchbase_orm/persistence_spec.rb new file mode 100644 index 00000000..d1a271b3 --- /dev/null +++ b/spec/couchbase_orm/persistence_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class BasicModel < CouchbaseOrm::Base + attribute :name + attribute :address + attribute :age +end + +class ModelWithDefaults < CouchbaseOrm::Base + attribute :name, default: proc { 'bob' } + attribute :address + attribute :age, default: 23 +end + +class ModelWithCallbacks < CouchbaseOrm::Base + attribute :name + attribute :address + attribute :age + + before_create :update_name + before_save :set_address + before_update :set_age + after_initialize do + self.age = 10 + end + before_destroy do + self.name = 'joe' + end + + def update_name + self.name = 'bob' + end + + def set_address + self.address = '23' + end + + def set_age + self.age = 30 + end +end + +class ModelWithValidations < CouchbaseOrm::Base + attribute :name, type: String + attribute :address, type: String + attribute :age, type: :Integer + + validates :name, presence: true + validates :age, numericality: {only_integer: true} +end + +describe CouchbaseOrm::Persistence do + it 'saves a model' do + model = BasicModel.new + + expect(model.new_record?).to be(true) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(false) + + model.name = 'bob' + expect(model.name).to eq('bob') + + model.address = 'somewhere' + model.age = 34 + + expect(model.new_record?).to be(true) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(false) + + result = model.save + expect(result).to be(true) + + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(true) + + model.destroy + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(true) + expect(model.persisted?).to be(false) + end + + it 'saves a model with defaults' do + model = ModelWithDefaults.new + + expect(model.name).to eq('bob') + expect(model.age).to be(23) + expect(model.address).to be_nil + + expect(model.new_record?).to be(true) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(false) + + result = model.save + expect(result).to be(true) + + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(true) + + model.destroy + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(true) + expect(model.persisted?).to be(false) + end + + it 'executes callbacks' do + model = ModelWithCallbacks.new + + # Test initialize + expect(model.name).to be_nil + expect(model.age).to be(10) + expect(model.address).to be_nil + + expect(model.new_record?).to be(true) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(false) + + # Test create + result = model.save + expect(result).to be(true) + + expect(model.name).to eq('bob') + expect(model.age).to be(10) + expect(model.address).to eq('23') + + # Test Update + model.address = 'other' + expect(model.address).to eq('other') + model.save + + expect(model.name).to eq('bob') + expect(model.age).to be(30) + expect(model.address).to eq('23') + + # Test destroy + model.destroy + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(true) + expect(model.persisted?).to be(false) + + expect(model.name).to eq('joe') + end + + it 'skips callbacks when updating columns' do + model = ModelWithCallbacks.new + + # Test initialize + expect(model.name).to be_nil + expect(model.age).to be(10) + expect(model.address).to be_nil + + expect(model.new_record?).to be(true) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(false) + + # Test create + result = model.save + expect(result).to be(true) + + expect(model.name).to eq('bob') + expect(model.age).to be(10) + expect(model.address).to eq('23') + + # Test Update + model.update_columns(address: 'other') + expect(model.address).to eq('other') + loaded = ModelWithCallbacks.find model.id + expect(loaded.address).to eq('other') + + # Test delete skipping callbacks + model.delete + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(true) + expect(model.persisted?).to be(false) + + expect(model.name).to eq('bob') + end + + it 'performs validations' do + model = ModelWithValidations.new + + expect(model.valid?).to be(false) + + # Test create + result = model.save + expect(result).to be(false) + expect(model.errors.count).to be(2) + + begin + model.save! + rescue ::CouchbaseOrm::Error::RecordInvalid => e + expect(e.record).to be(model) + end + + model.name = 'bob' + model.age = 23 + expect(model.valid?).to be(true) + expect(model.save).to be(true) + + # Test update + model.name = nil + expect(model.valid?).to be(false) + expect(model.save).to be(false) + begin + model.save! + rescue ::CouchbaseOrm::Error::RecordInvalid => e + expect(e.record).to be(model) + end + + model.age = '23' # This value will be coerced + model.name = 'joe' + expect(model.valid?).to be(true) + expect(model.save!).to be(model) + + # coercion will fail here + model.age = 'a23' + expect { model.save! }.to raise_error(CouchbaseOrm::Error::RecordInvalid) + + model.destroy + end + + it 'reloads a model' do + model = BasicModel.new + + model.name = 'bob' + model.address = 'somewhere' + model.age = 34 + + expect(model.save).to be(true) + id = model.id + model.name = nil + expect(model.changed?).to be(true) + + model.reload + expect(model.changed?).to be(false) + expect(model.id).to eq(id) + + model.destroy + expect(model.destroyed?).to be(true) + end + + it 'updates attributes' do + model = BasicModel.new + + model.update_attributes({ + name: 'bob', + age: 34, + }) + + expect(model.new_record?).to be(false) + expect(model.destroyed?).to be(false) + expect(model.persisted?).to be(true) + + expect(model.name).to eq('bob') + expect(model.age).to be(34) + expect(model.address).to be_nil + + model.destroy + expect(model.destroyed?).to be(true) + end + + describe BasicModel do + it_behaves_like 'ActiveModel' + end + + describe ModelWithDefaults do + it_behaves_like 'ActiveModel' + end + + describe ModelWithCallbacks do + it_behaves_like 'ActiveModel' + end + + describe ModelWithValidations do + it_behaves_like 'ActiveModel' + end +end diff --git a/spec/couchbase_orm/relation_spec.rb b/spec/couchbase_orm/relation_spec.rb new file mode 100644 index 00000000..ed8c4fdd --- /dev/null +++ b/spec/couchbase_orm/relation_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +class RelationModel < CouchbaseOrm::Base + attribute :name, :string + attribute :last_name, :string + attribute :active, :boolean + attribute :age, :integer +end + +describe CouchbaseOrm::Relation do + before do + RelationModel.delete_all + CouchbaseOrm.logger.debug 'Cleaned before tests' + end + + after(:all) do + CouchbaseOrm.logger.debug 'Cleanup after all tests' + RelationModel.delete_all + end + + it 'returns a relation' do + expect(RelationModel.all).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) + end + + it 'queries with conditions' do + RelationModel.create! name: :bob, active: true, age: 10 + RelationModel.create! name: :alice, active: true, age: 20 + RelationModel.create! name: :john, active: false, age: 30 + expect(RelationModel.where(active: true).count).to eq(2) + + expect(RelationModel.where(active: true).to_a.map(&:name)).to match_array(%w[bob alice]) + expect(RelationModel.where(active: true).where(age: 10).to_a.map(&:name)).to match_array(%w[bob]) + end + + it 'queries with merged conditions' do + RelationModel.create! name: :bob, active: true, age: 10 + RelationModel.create! name: :bob, active: false, age: 10 + RelationModel.create! name: :alice, active: true, age: 20 + RelationModel.create! name: :john, active: false, age: 30 + + expect(RelationModel.where(active: true).where(name: 'bob').count).to eq(1) + end + + it 'counts without loading models' do + RelationModel.create! name: :bob, active: true, age: 10 + RelationModel.create! name: :alice, active: false, age: 20 + + expect(RelationModel).not_to receive(:find) + + expect(RelationModel.where(active: true).count).to eq(1) + end + + it 'delete_alls' do + RelationModel.create! + RelationModel.create! + RelationModel.delete_all + expect(RelationModel.ids).to match_array([]) + end + + it 'delete_alls with conditions' do + RelationModel.create! + jane = RelationModel.create! name: 'Jane' + RelationModel.where(name: nil).delete_all + expect(RelationModel.ids).to match_array([jane.id]) + end + + it 'queries ids' do + expect(RelationModel.ids).to match_array([]) + m1 = RelationModel.create! + m2 = RelationModel.create! + expect(RelationModel.ids).to match_array([m1.id, m2.id]) + end + + it 'queries ids with conditions' do + m1 = RelationModel.create!(active: true, name: 'Jane') + _m2 = RelationModel.create!(active: false, name: 'Bob') + _m3 = RelationModel.create!(active: false, name: 'Jane') + expect(RelationModel.where(active: true, name: 'Jane').ids).to match_array([m1.id]) + end + + it 'queries ids with conditions and limit' do + RelationModel.create!(active: true, name: 'Jane', age: 2) + RelationModel.create!(active: false, name: 'Bob', age: 3) + m = RelationModel.create!(active: true, name: 'Jane', age: 1) + RelationModel.create!(active: false, name: 'Jane', age: 0) + + expect(RelationModel.where(active: true, name: 'Jane').order(:age).limit(1).ids).to match_array([m.id]) + expect(RelationModel.limit(1).where(active: true, name: 'Jane').order(:age).ids).to match_array([m.id]) + end + + it 'queries ids with order' do + m1 = RelationModel.create!(age: 10, name: 'b') + m2 = RelationModel.create!(age: 20, name: 'a') + expect(RelationModel.order(age: :desc).ids).to match_array([m2.id, m1.id]) + expect(RelationModel.order(age: :asc).ids).to match_array([m1.id, m2.id]) + expect(RelationModel.order(name: :desc).ids).to match_array([m1.id, m2.id]) + expect(RelationModel.order(name: :asc).ids).to match_array([m2.id, m1.id]) + expect(RelationModel.order(:name).ids).to match_array([m2.id, m1.id]) + expect(RelationModel.order(:age).ids).to match_array([m1.id, m2.id]) + end + + it 'queries with list order' do + m1 = RelationModel.create!(age: 20, name: 'b') + m2 = RelationModel.create!(age: 5, name: 'a') + m3 = RelationModel.create!(age: 20, name: 'a') + expect(RelationModel.order(:age, :name).ids).to match_array([m2.id, m3.id, m1.id]) + end + + it 'queries with chained order' do + m1 = RelationModel.create!(age: 10, name: 'b') + m2 = RelationModel.create!(age: 20, name: 'a') + m3 = RelationModel.create!(age: 20, name: 'c') + expect(RelationModel.order(age: :desc).order(name: :asc).ids).to match_array([m2.id, m3.id, m1.id]) + end + + it 'queries with order chained with list' do + m1 = RelationModel.create!(age: 20, name: 'b') + m2 = RelationModel.create!(age: 5, name: 'a') + m3 = RelationModel.create!(age: 20, name: 'a', last_name: 'c') + m4 = RelationModel.create!(age: 20, name: 'a', last_name: 'a') + expect(RelationModel.order(:age, :name).order(:last_name).ids).to match_array([m2.id, m4.id, m3.id, m1.id]) + end + + it 'queries all' do + m1 = RelationModel.create!(active: true) + m2 = RelationModel.create!(active: false) + expect(RelationModel.all).to match_array([m1, m2]) + end + + it 'queries all with condition and order' do + m1 = RelationModel.create!(active: true, age: 10) + m2 = RelationModel.create!(active: true, age: 20) + _m3 = RelationModel.create!(active: false, age: 30) + expect(RelationModel.where(active: true).order(age: :desc).all).to match_array([m2, m1]) + expect(RelationModel.all.where(active: true).order(age: :asc)).to match_array([m1, m2]) + end + + it 'returns a relation when using not' do + expect(RelationModel.not(active: true)).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) + expect(RelationModel.all.not(active: true)).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) + end + + it 'has a to_ary method' do + expect(RelationModel.not(active: true)).to respond_to(:to_ary) + expect(RelationModel.all.not(active: true)).to respond_to(:to_ary) + end + + it 'has a each method' do + expect(RelationModel.not(active: true)).to respond_to(:each) + expect(RelationModel.all.not(active: true)).to respond_to(:each) + end + + it 'queries true boolean' do + m1 = RelationModel.create!(active: true) + _m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.where(active: true)).to match_array([m1]) + end + + it 'does not query true boolean' do + _m1 = RelationModel.create!(active: true) + m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.not(active: true)).to match_array([m2]) # keep ActiveRecord compatibility by not returning _m3 + end + + it 'queries false boolean' do + _m1 = RelationModel.create!(active: true) + m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.where(active: false)).to match_array([m2]) + end + + it 'does not query false boolean' do + m1 = RelationModel.create!(active: true) + _m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.not(active: false)).to match_array([m1]) # keep ActiveRecord compatibility by not returning _m3 + end + + it 'queries nil boolean' do + _m1 = RelationModel.create!(active: true) + _m2 = RelationModel.create!(active: false) + m3 = RelationModel.create!(active: nil) + expect(RelationModel.where(active: nil)).to match_array([m3]) + end + + it 'does not query nil boolean' do + m1 = RelationModel.create!(active: true) + m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.not(active: nil)).to match_array([m1, m2]) + end + + it 'queries nil and false boolean' do + _m1 = RelationModel.create!(active: true) + m2 = RelationModel.create!(active: false) + m3 = RelationModel.create!(active: nil) + expect(RelationModel.where(active: [false, nil])).to match_array([m2, m3]) + end + + it 'does not query nil and false boolean' do + m1 = RelationModel.create!(active: true) + _m2 = RelationModel.create!(active: false) + _m3 = RelationModel.create!(active: nil) + expect(RelationModel.not(active: [false, nil])).to match_array([m1]) + end +end diff --git a/spec/couchbase_orm/support.rb b/spec/couchbase_orm/support.rb new file mode 100644 index 00000000..835b00e8 --- /dev/null +++ b/spec/couchbase_orm/support.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'simplecov' +require 'couchbase_orm' +require 'minitest/assertions' +require 'active_model/lint' +require 'pry' +require 'pry-stack_explorer' + +SimpleCov.start do + add_group 'Core', [/lib\/couchbase_orm\/(?!(proxies|utilities))/, 'lib/couchbase_orm.rb'] + add_group 'Proxies', 'lib/couchbase_orm/proxies' + add_group 'Utilities', 'lib/couchbase_orm/utilities' + add_group 'Specs', 'spec' + minimum_coverage 94 +end + +if ENV['COUCHBASE_FLUSH'] + CouchbaseOrm.logger.warn "Flushing Couchbase bucket '#{CouchbaseOrm::Connection.bucket.name}'" + CouchbaseOrm::Connection.cluster.buckets.flush_bucket(CouchbaseOrm::Connection.bucket.name) + raise 'BucketFlushed' +end + +shared_examples_for 'ActiveModel' do + include Minitest::Assertions + include ActiveModel::Lint::Tests + + def assertions + @__assertions__ ||= 0 + end + + def assertions=(val) + @__assertions__ = val + end + + ActiveModel::Lint::Tests.public_instance_methods.map(&:to_s).grep(/^test/).each do |method| + example(method.tr('_', ' ')) { send method } # rubocop:disable RSpec/NoExpectationExample + end + + before do + @model = subject + end +end diff --git a/spec/couchbase_orm/type_spec.rb b/spec/couchbase_orm/type_spec.rb new file mode 100644 index 00000000..25709c09 --- /dev/null +++ b/spec/couchbase_orm/type_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require File.expand_path('support', __dir__) + +require 'active_model' +require 'couchbase_orm/types' + +class DateTimeWith3Decimal < CouchbaseOrm::Types::DateTime + def serialize(value) + value&.iso8601(3) + end +end + +ActiveModel::Type.register(:datetime3decimal, DateTimeWith3Decimal) + +class TypeTest < CouchbaseOrm::Base + attribute :name, :string + attribute :age, :integer + attribute :size, :float + attribute :renewal_date, :date + attribute :subscribed_at, :datetime + attribute :some_time, :timestamp + attribute :precision_time, :datetime3decimal + attribute :active, :boolean + + index :age, presence: false + index :renewal_date, presence: false + index :some_time, presence: false + index :precision_time, presence: false +end + +class N1qlTypeTest < CouchbaseOrm::Base + attribute :name, :string + attribute :age, :integer + attribute :size, :float + attribute :renewal_date, :date + attribute :subscribed_at, :datetime + attribute :some_time, :timestamp + attribute :precision_time, :datetime3decimal + attribute :active, :boolean + + index_n1ql :name, validate: false + index_n1ql :age, validate: false + index_n1ql :size, validate: false + index_n1ql :active, validate: false + index_n1ql :renewal_date, validate: false + index_n1ql :some_time, validate: false + index_n1ql :subscribed_at, validate: false + index_n1ql :precision_time, validate: false + n1ql :by_both_dates, emit_key: [:renewal_date, :subscribed_at], presence: false +end + +TypeTest.ensure_design_document! +N1qlTypeTest.ensure_design_document! + +describe CouchbaseOrm::Types::Timestamp do + it 'casts an integer to time' do + t = Time.at(Time.now.to_i) + expect(described_class.new.cast(t.to_i)).to eq(t) + end + + it 'casts an integer string to time' do + t = Time.at(Time.now.to_i) + expect(described_class.new.cast(t.to_s)).to eq(t) + end +end + +describe CouchbaseOrm::Types::Date do + it 'casts an string to date' do + d = Date.today + expect(described_class.new.cast(d.to_s)).to eq(d) + end + + it 'serializes date to string' do + d = Date.today + expect(described_class.new.serialize(d)).to eq(d.to_s) + end + + it 'gets the type from the registry' do + expect(ActiveModel::Type.lookup(:date)).to eq(described_class.new) + end +end + +describe CouchbaseOrm::Base do + before do + TypeTest.delete_all + N1qlTypeTest.delete_all + end + + it 'is createable' do + t = TypeTest.create! + expect(t).to be_a(TypeTest) + end + + it 'is able to set attributes' do + t = TypeTest.new + t.name = 'joe' + t.age = 20 + t.size = 1.5 + t.renewal_date = Date.today + t.subscribed_at = Time.now + t.active = true + t.save! + + expect(t.name).to eq('joe') + expect(t.age).to eq(20) + expect(t.size).to eq(1.5) + expect(t.renewal_date).to eq(Date.today) + expect(t.subscribed_at).to be_a(Time) + expect(t.active).to be(true) + end + + it 'is able to set attributes with a hash' do + t = TypeTest.new(name: 'joe', age: 20, size: 1.5, renewal_date: Date.today, subscribed_at: Time.now, active: true) + t.save! + + expect(t.name).to eq('joe') + expect(t.age).to eq(20) + expect(t.size).to eq(1.5) + expect(t.renewal_date).to eq(Date.today) + expect(t.subscribed_at).to be_a(Time) + expect(t.active).to be(true) + end + + it 'is able to be stored and retrieved' do + now = Time.now + t = TypeTest.create!(name: 'joe', age: 20, size: 1.5, renewal_date: Date.today, subscribed_at: now, active: true) + t2 = TypeTest.find(t.id) + + expect(t2.name).to eq('joe') + expect(t2.age).to eq(20) + expect(t2.size).to eq(1.5) + expect(t2.renewal_date).to eq(Date.today) + expect(t2.subscribed_at).to eq(now.utc.change(usec: 0)) + expect(t2.active).to be(true) + end + + it 'is able to query by age' do + t = TypeTest.create!(age: 20) + _t2 = TypeTest.create!(age: 40) + expect(TypeTest.find_by_age(20)).to eq t + end + + it 'is able to query by age and type cast' do + t = TypeTest.create!(age: '20') + expect(TypeTest.find_by_age(20)).to eq t + expect(TypeTest.find_by_age('20')).to eq t + end + + it 'is able to query by date' do + t = TypeTest.create!(renewal_date: Date.today) + _t2 = TypeTest.create!(renewal_date: Date.today + 1) + expect(TypeTest.find_by_renewal_date(Date.today)).to eq t + end + + it 'is able to query by date and type cast' do + t = TypeTest.create!(renewal_date: Date.today.to_s) + expect(TypeTest.find_by_renewal_date(Date.today)).to eq t + expect(TypeTest.find_by_renewal_date(Date.today.to_s)).to eq t + end + + it 'is able to query by time' do + now = Time.now + t = TypeTest.create!(name: 't', some_time: now) + _t2 = TypeTest.create!(name: 't2', some_time: now + 1) + expect(TypeTest.find_by_some_time(now)).to eq t + end + + it 'is able to query by time and type cast' do + now = Time.now + now_s = now.to_i.to_s + t = TypeTest.create!(some_time: now_s) + expect(TypeTest.find_by_some_time(now)).to eq t + expect(TypeTest.find_by_some_time(now_s)).to eq t + end + + it 'is able to query by custom type' do + now = Time.now + t = TypeTest.create!(precision_time: now) + _t2 = TypeTest.create!(precision_time: now + 1) + expect(TypeTest.find_by_precision_time(now)).to eq t + end + + it 'is able to query by custom type and type cast' do + now = Time.now + now_s = now.utc.iso8601(3) + t = TypeTest.create!(precision_time: now_s) + expect(TypeTest.find_by_precision_time(now)).to eq t + expect(TypeTest.find_by_precision_time(now_s)).to eq t + end + + it 'is able to set attributes with a hash with indifferent access' do + t = TypeTest.new(ActiveSupport::HashWithIndifferentAccess.new(name: 'joe', age: 20, size: 1.5, renewal_date: Date.today, + subscribed_at: Time.now, active: true)) + t.save! + + expect(t.name).to eq('joe') + expect(t.age).to eq(20) + expect(t.size).to eq(1.5) + expect(t.renewal_date).to eq(Date.today) + expect(t.subscribed_at).to be_a(Time) + expect(t.active).to be(true) + end + + it 'is able to type cast attributes' do + t = TypeTest.new(name: 'joe', age: '20', size: '1.5', renewal_date: Date.today.to_s, subscribed_at: Time.now.to_s, active: 'true') + t.save! + + expect(t.name).to eq('joe') + expect(t.age).to eq(20) + expect(t.size).to eq(1.5) + expect(t.renewal_date).to eq(Date.today) + expect(t.subscribed_at).to be_a(Time) + expect(t.active).to be(true) + end + + it 'is consistent with active record on failed cast' do + t = TypeTest.new(name: 'joe', age: 'joe', size: 'joe', renewal_date: 'joe', subscribed_at: 'joe', active: 'true') + t.save! + + expect(t.age).to eq 0 + expect(t.size).to eq 0.0 + expect(t.renewal_date).to be_nil + expect(t.subscribed_at).to be_nil + expect(t.active).to be true + end + + it 'is able to query by name' do + t = N1qlTypeTest.create!(name: 'joe') + _t2 = N1qlTypeTest.create!(name: 'john') + expect(N1qlTypeTest.find_by_name('joe').to_a).to eq [t] + end + + it 'is able to query by nil value' do + t = N1qlTypeTest.create! + _t2 = N1qlTypeTest.create!(name: 'john') + expect(N1qlTypeTest.find_by_name(nil).to_a).to eq [t] + end + + it 'is able to query by array value' do + t = N1qlTypeTest.create!(name: 'laura') + t2 = N1qlTypeTest.create!(name: 'joe') + _t3 = N1qlTypeTest.create!(name: 'john') + expect(N1qlTypeTest.find_by_name(%w[laura joe]).to_a).to match_array [t, t2] + end + + it 'is able to query by integer' do + t = N1qlTypeTest.create!(age: 20) + t2 = N1qlTypeTest.create!(age: 20) + _t3 = N1qlTypeTest.create!(age: 40) + expect(N1qlTypeTest.find_by_age(20).to_a).to match_array [t, t2] + end + + it 'is able to query by integer and type cast' do + t = N1qlTypeTest.create!(age: '20') + expect(N1qlTypeTest.find_by_age(20).to_a).to eq [t] + expect(N1qlTypeTest.find_by_age('20').to_a).to eq [t] + end + + it 'is able to query by date (n1ql)' do + t = N1qlTypeTest.create!(renewal_date: Date.today) + _t2 = N1qlTypeTest.create!(renewal_date: Date.today + 1) + expect(N1qlTypeTest.find_by_renewal_date(Date.today).to_a).to eq [t] + end + + it 'is able to query by datetime' do + now = Time.now + t = N1qlTypeTest.create!(subscribed_at: now) + _t2 = N1qlTypeTest.create!(subscribed_at: now + 1) + expect(N1qlTypeTest.find_by_subscribed_at(now).to_a).to eq [t] + end + + it 'is able to query by timestamp' do + now = Time.now + t = N1qlTypeTest.create!(some_time: now) + _t2 = N1qlTypeTest.create!(some_time: now + 1) + expect(N1qlTypeTest.find_by_some_time(now).to_a).to eq [t] + end + + it 'is able to query by custom type (n1ql)' do + now = Time.now + t = N1qlTypeTest.create!(precision_time: now) + _t2 = N1qlTypeTest.create!(precision_time: now + 1) + expect(N1qlTypeTest.find_by_precision_time(now).to_a).to eq [t] + end + + it 'is able to query by boolean' do + t = N1qlTypeTest.create!(active: true) + _t2 = N1qlTypeTest.create!(active: false) + expect(N1qlTypeTest.find_by_active(true).to_a).to eq [t] + end + + it 'is able to query by float' do + t = N1qlTypeTest.create!(size: 1.5) + _t2 = N1qlTypeTest.create!(size: 2.5) + expect(N1qlTypeTest.find_by_size(1.5).to_a).to eq [t] + end +end diff --git a/spec/couchbase_orm/views_spec.rb b/spec/couchbase_orm/views_spec.rb new file mode 100644 index 00000000..46a65261 --- /dev/null +++ b/spec/couchbase_orm/views_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT +# frozen_string_literal: true + +require File.expand_path('support', __dir__) +require 'set' + +class ViewTest < CouchbaseOrm::Base + attribute :name, type: String + enum rating: [:awesome, :good, :okay, :bad], default: :okay + + view :vall + + # This generates both: + # view :by_rating, emit_key: :rating + # def self.find_by_rating(rating); end # also provide this helper function + index_view :rating +end + +describe CouchbaseOrm::Views do + before do + ViewTest.delete_all + rescue Couchbase::Error::DesignDocumentNotFound + # ignore (FIXME: check before merge) mainly because if there is nothing in all we should not have an error + end + + after do + ViewTest.delete_all + rescue Couchbase::Error::InternalServerFailure + # ignore (FIXME: check before merge) + rescue Couchbase::Error::DesignDocumentNotFound + # ignore (FIXME: check before merge) (7.1) + end + + it 'does not allow n1ql to override existing methods' do + expect { ViewTest.view :all }.to raise_error(ArgumentError) + end + + it 'saves a new design document' do + begin + ViewTest.bucket.view_indexes.drop_design_document(ViewTest.design_document, :production) + rescue Couchbase::Error::InternalServerFailure + # ignore if design document does not exist + rescue Couchbase::Error::DesignDocumentNotFound + # ignore if design document does not exist (7.1) + end + expect(ViewTest.ensure_design_document!).to be(true) + end + + it 'does not re-save a design doc if nothing has changed' do + expect(ViewTest.ensure_design_document!).to be(false) + end + + it 'returns an empty array when there is no objects' do + expect(ViewTest.vall).to eq([]) + end + + it 'performs a map-reduce and return the view' do + ViewTest.ensure_design_document! + ViewTest.create! name: :bob, rating: :good + + docs = ViewTest.vall.collect do |ob| + ob.destroy + ob.name + end + expect(docs).to eq(['bob']) + end + + it 'works with other keys' do + ViewTest.ensure_design_document! + ViewTest.create! name: :bob, rating: :good + ViewTest.create! name: :jane, rating: :awesome + ViewTest.create! name: :greg, rating: :bad + + docs = ViewTest.by_rating(order: :descending).collect do |ob| + ob.destroy + ob.name + end + expect(docs).to eq(%w[greg bob jane]) + end + + it 'returns matching results' do + ViewTest.ensure_design_document! + ViewTest.create! name: :bob, rating: :awesome + ViewTest.create! name: :jane, rating: :awesome + ViewTest.create! name: :greg, rating: :bad + ViewTest.create! name: :mel, rating: :good + + docs = ViewTest.find_by_rating(1).collect(&:name) + + expect(Set.new(docs)).to eq(Set.new(%w[bob jane])) + end +end diff --git a/spec/enum_spec.rb b/spec/enum_spec.rb deleted file mode 100644 index 76036183..00000000 --- a/spec/enum_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - -class EnumTest < CouchbaseOrm::Base - enum rating: [:awesome, :good, :okay, :bad], default: :okay - enum color: [:red, :green, :blue] -end - -describe CouchbaseOrm::Base do - it "should create an attribute" do - base = EnumTest.create!(rating: :good, color: :red) - expect(base.attribute_names).to eq(["id", "rating", "color"]) - end - - it "should set the attribute" do - base = EnumTest.create!(rating: :good, color: :red) - expect(base.rating).to_not be_nil - expect(base.color).to_not be_nil - end - - it "should convert it to an int" do - base = EnumTest.create!(rating: :good, color: :red) - expect(base.rating).to eq 2 - expect(base.color).to eq 1 - end - - it "should use default value" do - base = EnumTest.create! - expect(base.rating).to eq 3 - expect(base.color).to eq 1 - end -end - diff --git a/spec/has_many_spec.rb b/spec/has_many_spec.rb deleted file mode 100644 index 68dbc417..00000000 --- a/spec/has_many_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - -shared_examples "has_many example" do |parameter| - before :all do - @context = parameter[:context].to_s - @rating_test_class = Kernel.const_get("Rating#{@context.camelize}Test".classify) - @object_test_class = Kernel.const_get("Object#{@context.camelize}Test".classify) - @object_rating_test_class = Kernel.const_get("ObjectRating#{@context.camelize}Test".classify) - - @rating_test_class.ensure_design_document! - @object_test_class.ensure_design_document! - @object_rating_test_class.ensure_design_document! - - @rating_test_class.delete_all - @object_test_class.delete_all - @object_rating_test_class.delete_all - end - - after :each do - @rating_test_class.delete_all - @object_test_class.delete_all - @object_rating_test_class.delete_all - end - - it "should return matching results" do - first = @object_test_class.create! name: :bob - second = @object_test_class.create! name: :jane - - rate = @rating_test_class.create! rating: :awesome, "object_#{@context}_test": first - @rating_test_class.create! rating: :bad, "object_#{@context}_test": second - @rating_test_class.create! rating: :good, "object_#{@context}_test": first - - expect(rate.try("object_#{@context}_test_id")).to eq(first.id) - expect(@rating_test_class.respond_to?(:"find_by_object_#{@context}_test_id")).to be(true) - expect(first.respond_to?(:"rating_#{@context}_tests")).to be(true) - - docs = first.try(:"rating_#{@context}_tests").collect(&:rating) - - expect(docs).to match_array([1, 2]) - - first.destroy - expect { @rating_test_class.find rate.id }.to raise_error(Couchbase::Error::DocumentNotFound) - expect(@rating_test_class.send(:"#{@context}_all").count).to be(1) - end - - it "should work through a join model" do - first = @object_test_class.create! name: :bob - second = @object_test_class.create! name: :jane - - rate1 = @rating_test_class.create! rating: :awesome, "object_#{@context}_test": first - _rate2 = @rating_test_class.create! rating: :bad, "object_#{@context}_test": second - _rate3 = @rating_test_class.create! rating: :good, "object_#{@context}_test": first - - ort = @object_rating_test_class.create! "object_#{@context}_test": first, "rating_#{@context}_test": rate1 - @object_rating_test_class.create! "object_#{@context}_test": second, "rating_#{@context}_test": rate1 - - expect(ort.try(:"rating_#{@context}_test_id".to_sym)).to eq(rate1.id) - expect(rate1.respond_to?(:"object_#{@context}_tests")).to be(true) - docs = rate1.try(:"object_#{@context}_tests").collect(&:name) - - expect(docs).to match_array(['bob', 'jane']) - end - - it "should work with new objects not yet saved" do - existing_object = @object_test_class.create! name: :bob - expect(existing_object.send(:"rating_#{@context}_tests")).to be_empty - - @rating_test_class.create! rating: :good, "object_#{@context}_test": existing_object - - new_object = @object_test_class.new name: :jane - expect(new_object.send(:"rating_#{@context}_tests")).to be_empty - end - -end - -describe CouchbaseOrm::HasMany do - context 'with view' do - class ObjectRatingViewTest < CouchbaseOrm::Base - join :object_view_test, :rating_view_test - view :view_all - end - - class RatingViewTest < CouchbaseOrm::Base - enum rating: [:awesome, :good, :okay, :bad], default: :okay - belongs_to :object_view_test - - has_many :object_view_tests, through: :object_rating_view_test - view :view_all - end - - class ObjectViewTest < CouchbaseOrm::Base - attribute :name, type: String - has_many :rating_view_tests, dependent: :destroy - - view :view_all - end - - include_examples("has_many example", context: :view) - end - - context 'with n1ql' do - class ObjectRatingN1qlTest < CouchbaseOrm::Base - join :object_n1ql_test, :rating_n1ql_test - - n1ql :n1ql_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 - - n1ql :n1ql_all - end - - class ObjectN1qlTest < CouchbaseOrm::Base - attribute :name, type: String - - has_many :rating_n1ql_tests, dependent: :destroy, type: :n1ql - - n1ql :n1ql_all - end - - include_examples("has_many example", context: :n1ql) - end -end diff --git a/spec/id_generator_spec.rb b/spec/id_generator_spec.rb deleted file mode 100644 index f1594d6e..00000000 --- a/spec/id_generator_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require 'couchbase-orm' -require 'thread' - -class IdTestModel < CouchbaseOrm::Base; end - -describe CouchbaseOrm::IdGenerator do - it "should not generate ID clashes" do - model = IdTestModel.new - - ids1 = [] - thread1 = Thread.new do - (1..10000).each { - ids1 << CouchbaseOrm::IdGenerator.next(model) - } - end - - ids2 = [] - thread2 = Thread.new do - (1..10000).each { - ids2 << CouchbaseOrm::IdGenerator.next(model) - } - end - - ids3 = [] - thread3 = Thread.new do - (1..10000).each { - ids3 << CouchbaseOrm::IdGenerator.next(model) - } - end - - ids4 = [] - thread4 = Thread.new do - (1..10000).each { - ids4 << CouchbaseOrm::IdGenerator.next(model) - } - end - - thread1.join - thread2.join - thread3.join - thread4.join - - results = [ids1, ids2, ids3, ids4].flatten - expect(results.uniq).to eq(results) - end -end diff --git a/spec/index_spec.rb b/spec/index_spec.rb deleted file mode 100644 index c9d0a26a..00000000 --- a/spec/index_spec.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - - -class IndexTest < CouchbaseOrm::Base - attribute :email, type: String - attribute :name, type: String, default: :joe - ensure_unique :email, presence: false -end - -class NoUniqueIndexTest < CouchbaseOrm::Base - attribute :email, type: String - attribute :name, type: String, default: :joe - - index :email, presence: false -end - -class IndexEnumTest < CouchbaseOrm::Base - enum visibility: [:group, :authority, :public], default: :authority - enum color: [:red, :green, :blue] -end - - -describe CouchbaseOrm::Index do - after :each do - IndexTest.all.map(&:destroy) - end - - it "should prevent models being created if they should have unique keys" do - joe = IndexTest.create!(email: 'joe@aca.com') - expect { IndexTest.create!(email: 'joe@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) - - joe.email = 'other@aca.com' - joe.save - other = IndexTest.new(email: 'joe@aca.com') - expect(other.save).to be(true) - - expect { IndexTest.create!(email: 'joe@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) - expect { IndexTest.create!(email: 'other@aca.com') }.to raise_error(CouchbaseOrm::Error::RecordInvalid) - - joe.destroy - other.destroy - - again = IndexTest.new(email: 'joe@aca.com') - expect(again.save).to be(true) - - again.destroy - end - - it "should provide helper methods for looking up the model" do - joe = IndexTest.create!(email: 'joe@aca.com') - - joe_again = IndexTest.find_by_email('joe@aca.com') - expect(joe).to eq(joe_again) - - joe.destroy - end - - it "should clean up itself if dangling keys are left" do - joe = IndexTest.create!(email: 'joe@aca.com') - joe.delete # no callbacks are executed - - again = IndexTest.new(email: 'joe@aca.com') - expect(again.save).to be(true) - - again.destroy - end - - it "should work with nil values" do - joe = IndexTest.create! - expect(IndexTest.find_by_email(nil)).to eq(joe) - - joe.email = 'joe@aca.com' - joe.save! - expect(IndexTest.find_by_email('joe@aca.com')).to eq(joe) - - joe.email = nil - joe.save! - expect(IndexTest.find_by_email('joe@aca.com')).to eq(nil) - expect(IndexTest.find_by_email(nil)).to eq(joe) - - joe.destroy - end - - it "should work with enumerators" do - # Test symbol - enum = IndexEnumTest.create!(visibility: :public) - expect(enum.visibility).to eq(3) - enum.destroy - - # Test number - enum = IndexEnumTest.create!(visibility: 2) - expect(enum.visibility).to eq(2) - enum.destroy - - # Test default - enum = IndexEnumTest.create! - expect(enum.visibility).to eq(2) - enum.destroy - - # Test default default - enum = IndexEnumTest.create! - expect(enum.color).to eq(1) - end - - it "should not overwrite index's that do not belong to the current model" do - joe = NoUniqueIndexTest.create! - expect(NoUniqueIndexTest.find_by_email(nil)).to eq(joe) - - joe.email = 'joe@aca.com' - joe.save! - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) - - joe2 = NoUniqueIndexTest.create! - joe2.email = 'joe@aca.com' # joe here is deliberate - joe2.save! - - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe2) - - # Joe's indexing should not remove joe2 index - joe.email = nil - joe.save! - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe2) - - # Test destroy - joe.email = 'joe@aca.com' - joe.save! - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) - - # Index should not be updated - joe2.destroy - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(joe) - - # index should be updated - joe.email = nil - joe.save! - expect(NoUniqueIndexTest.find_by_email('joe@aca.com')).to eq(nil) - - joe.destroy - end -end diff --git a/spec/n1ql_spec.rb b/spec/n1ql_spec.rb deleted file mode 100644 index f5ba9971..00000000 --- a/spec/n1ql_spec.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -require File.expand_path("../support", __FILE__) - -class N1QLTest < CouchbaseOrm::Base - attribute :name, type: String - attribute :lastname, type: String - enum rating: [:awesome, :good, :okay, :bad], default: :okay - - n1ql :by_custom_rating, emit_key: [:rating], query_fn: proc { |bucket, _values, options| - cluster.query("SELECT raw meta().id FROM `#{bucket.name}` WHERE type = 'n1_ql_test' AND rating IN [1, 2] ORDER BY name ASC", options) - } - n1ql :by_name, emit_key: [:name] - n1ql :by_lastname, emit_key: [:lastname] - n1ql :by_rating_emit, emit_key: :rating - - n1ql :by_custom_rating_values, emit_key: [:rating], query_fn: proc { |bucket, values, options| - cluster.query("SELECT raw meta().id FROM `#{bucket.name}` where type = 'n1_ql_test' AND rating IN #{quote(values[0])} ORDER BY name ASC", options) - } - n1ql :by_rating_reverse, emit_key: :rating, custom_order: "name DESC" - n1ql :by_rating_without_docs, emit_key: :rating, include_docs: false - - # This generates both: - # view :by_rating, emit_key: :rating - # def self.find_by_rating(rating); end # also provide this helper function - index_n1ql :rating -end - -describe CouchbaseOrm::N1ql do - before(:each) do - N1QLTest.delete_all - end - - it "should not allow n1ql to override existing methods" do - expect { N1QLTest.n1ql :all }.to raise_error(ArgumentError) - end - - it "should perform a query and return the n1ql" do - N1QLTest.create! name: :bob - docs = N1QLTest.all.collect { |ob| - ob.name - } - expect(docs).to eq(%w[bob]) - end - - it "should query by non-nil value" do - _anonymous = N1QLTest.create! - bob = N1QLTest.create! name: :bob - - expect(N1QLTest.by_name(key: 'bob').to_a).to eq [bob] - end - - it "should query by nil value" do - anonymous = N1QLTest.create! lastname: "Anonymous" - anonymous_no_property = N1QLTest.create! lastname: "Anonymous without name property" - - CouchbaseOrm::Connection.bucket.default_collection.mutate_in(anonymous_no_property.id, [ - Couchbase::MutateInSpec.remove("name"), - ]) - - anonymous_no_property.reload - - _bob = N1QLTest.create! name: :bob - - expect(N1QLTest.by_name(key: nil).to_a).to match_array [anonymous, anonymous_no_property] - end - - it "should query all when key is not set" do - anonymous = N1QLTest.create! - bob = N1QLTest.create! name: :bob - - expect(N1QLTest.by_name.to_a).to eq [anonymous, bob] - end - - - it "should work with other keys" do - N1QLTest.create! name: :bob, rating: :good - N1QLTest.create! name: :jane, rating: :awesome - N1QLTest.create! name: :greg, rating: :bad - - docs = N1QLTest.by_name(descending: true).collect { |ob| - ob.name - } - expect(docs).to eq(%w[jane greg bob]) - - docs = N1QLTest.by_rating(descending: true).collect { |ob| - ob.rating - } - expect(docs).to eq([4, 2, 1]) - end - - it "should return matching results" do - N1QLTest.create! name: :bob, rating: :awesome - N1QLTest.create! name: :jane, rating: :awesome - N1QLTest.create! name: :greg, rating: :bad - N1QLTest.create! name: :mel, rating: :good - - docs = N1QLTest.find_by_rating(1).collect { |ob| - ob.name - } - - expect(Set.new(docs)).to eq(Set.new(%w[bob jane])) - - docs = N1QLTest.by_custom_rating().collect { |ob| - ob.name - } - - expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) - end - - it "should return matching results with reverse order" do - N1QLTest.create! name: :bob, rating: :awesome - N1QLTest.create! name: :jane, rating: :awesome - N1QLTest.create! name: :greg, rating: :bad - N1QLTest.create! name: :mel, rating: :good - - docs = N1QLTest.by_rating_reverse(key: 1).collect { |ob| - ob.name - } - - expect(docs).to eq(%w[jane bob]) - end - - it "should return matching results without full documents" do - inst_bob = N1QLTest.create! name: :bob, rating: :awesome - inst_jane = N1QLTest.create! name: :jane, rating: :awesome - N1QLTest.create! name: :greg, rating: :bad - N1QLTest.create! name: :mel, rating: :good - - docs = N1QLTest.by_rating_without_docs(key: 1) - - expect(Set.new(docs)).to eq(Set.new([inst_bob.id, inst_jane.id])) - end - - it "should return matching results with nil usage" do - N1QLTest.create! name: :bob, lastname: nil - N1QLTest.create! name: :jane, lastname: "dupond" - - docs = N1QLTest.by_lastname(key: [nil]).collect { |ob| - ob.name - } - expect(docs).to eq(%w[bob]) - end - - it "should return matching results with custom n1ql query" do - N1QLTest.create! name: :bob, rating: :awesome - N1QLTest.create! name: :jane, rating: :awesome - N1QLTest.create! name: :greg, rating: :bad - N1QLTest.create! name: :mel, rating: :good - - - docs = N1QLTest.by_custom_rating().collect { |ob| - ob.name - } - - expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) - - docs = N1QLTest.by_custom_rating_values(key: [[1, 2]]).collect { |ob| - ob.name - } - - expect(Set.new(docs)).to eq(Set.new(%w[bob jane mel])) - end - - after(:all) do - N1QLTest.delete_all - end -end diff --git a/spec/persistence_spec.rb b/spec/persistence_spec.rb deleted file mode 100644 index 6c83212a..00000000 --- a/spec/persistence_spec.rb +++ /dev/null @@ -1,274 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - - -class BasicModel < CouchbaseOrm::Base - attribute :name - attribute :address - attribute :age -end - -class ModelWithDefaults < CouchbaseOrm::Base - attribute :name, default: proc { 'bob' } - attribute :address - attribute :age, default: 23 -end - -class ModelWithCallbacks < CouchbaseOrm::Base - attribute :name - attribute :address - attribute :age - - before_create :update_name - before_save :set_address - before_update :set_age - after_initialize do - self.age = 10 - end - before_destroy do - self.name = 'joe' - end - - def update_name; self.name = 'bob'; end - def set_address; self.address = '23'; end - def set_age; self.age = 30; end -end - -class ModelWithValidations < CouchbaseOrm::Base - attribute :name, type: String - attribute :address, type: String - attribute :age, type: :Integer - - validates :name, presence: true - validates :age, numericality: { only_integer: true } -end - - -describe CouchbaseOrm::Persistence do - it "should save a model" do - model = BasicModel.new - - expect(model.new_record?).to be(true) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(false) - - model.name = 'bob' - expect(model.name).to eq('bob') - - model.address = 'somewhere' - model.age = 34 - - expect(model.new_record?).to be(true) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(false) - - result = model.save - expect(result).to be(true) - - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(true) - - model.destroy - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(true) - expect(model.persisted?).to be(false) - end - - it "should save a model with defaults" do - model = ModelWithDefaults.new - - expect(model.name).to eq('bob') - expect(model.age).to be(23) - expect(model.address).to be(nil) - - expect(model.new_record?).to be(true) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(false) - - result = model.save - expect(result).to be(true) - - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(true) - - model.destroy - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(true) - expect(model.persisted?).to be(false) - end - - it "should execute callbacks" do - model = ModelWithCallbacks.new - - # Test initialize - expect(model.name).to be(nil) - expect(model.age).to be(10) - expect(model.address).to be(nil) - - expect(model.new_record?).to be(true) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(false) - - # Test create - result = model.save - expect(result).to be(true) - - expect(model.name).to eq('bob') - expect(model.age).to be(10) - expect(model.address).to eq('23') - - # Test Update - model.address = 'other' - expect(model.address).to eq('other') - model.save - - expect(model.name).to eq('bob') - expect(model.age).to be(30) - expect(model.address).to eq('23') - - # Test destroy - model.destroy - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(true) - expect(model.persisted?).to be(false) - - expect(model.name).to eq('joe') - end - - it "should skip callbacks when updating columns" do - model = ModelWithCallbacks.new - - # Test initialize - expect(model.name).to be(nil) - expect(model.age).to be(10) - expect(model.address).to be(nil) - - expect(model.new_record?).to be(true) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(false) - - # Test create - result = model.save - expect(result).to be(true) - - expect(model.name).to eq('bob') - expect(model.age).to be(10) - expect(model.address).to eq('23') - - # Test Update - model.update_columns(address: 'other') - expect(model.address).to eq('other') - loaded = ModelWithCallbacks.find model.id - expect(loaded.address).to eq('other') - - # Test delete skipping callbacks - model.delete - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(true) - expect(model.persisted?).to be(false) - - expect(model.name).to eq('bob') - end - - it "should perform validations" do - model = ModelWithValidations.new - - expect(model.valid?).to be(false) - - # Test create - result = model.save - expect(result).to be(false) - expect(model.errors.count).to be(2) - - begin - model.save! - rescue ::CouchbaseOrm::Error::RecordInvalid => e - expect(e.record).to be(model) - end - - model.name = 'bob' - model.age = 23 - expect(model.valid?).to be(true) - expect(model.save).to be(true) - - # Test update - model.name = nil - expect(model.valid?).to be(false) - expect(model.save).to be(false) - begin - model.save! - rescue ::CouchbaseOrm::Error::RecordInvalid => e - expect(e.record).to be(model) - end - - model.age = '23' # This value will be coerced - model.name = 'joe' - expect(model.valid?).to be(true) - expect(model.save!).to be(model) - - # coercion will fail here - model.age = "a23" - expect{ model.save! }.to raise_error(CouchbaseOrm::Error::RecordInvalid) - - model.destroy - end - - it "should reload a model" do - model = BasicModel.new - - model.name = 'bob' - model.address = 'somewhere' - model.age = 34 - - expect(model.save).to be(true) - id = model.id - model.name = nil - expect(model.changed?).to be(true) - - model.reload - expect(model.changed?).to be(false) - expect(model.id).to eq(id) - - model.destroy - expect(model.destroyed?).to be(true) - end - - it "should update attributes" do - model = BasicModel.new - - model.update_attributes({ - name: 'bob', - age: 34 - }) - - expect(model.new_record?).to be(false) - expect(model.destroyed?).to be(false) - expect(model.persisted?).to be(true) - - expect(model.name).to eq('bob') - expect(model.age).to be(34) - expect(model.address).to be(nil) - - model.destroy - expect(model.destroyed?).to be(true) - end - - describe BasicModel do - it_behaves_like "ActiveModel" - end - - describe ModelWithDefaults do - it_behaves_like "ActiveModel" - end - - describe ModelWithCallbacks do - it_behaves_like "ActiveModel" - end - - describe ModelWithValidations do - it_behaves_like "ActiveModel" - end -end diff --git a/spec/relation_spec.rb b/spec/relation_spec.rb deleted file mode 100644 index 8efbe5a9..00000000 --- a/spec/relation_spec.rb +++ /dev/null @@ -1,213 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) - - -class RelationModel < CouchbaseOrm::Base - attribute :name, :string - attribute :last_name, :string - attribute :active, :boolean - attribute :age, :integer -end - -describe CouchbaseOrm::Relation do - before(:each) do - RelationModel.delete_all - CouchbaseOrm.logger.debug "Cleaned before tests" - end - - after(:all) do - CouchbaseOrm.logger.debug "Cleanup after all tests" - RelationModel.delete_all - end - - it "should return a relation" do - expect(RelationModel.all).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) - end - - it "should query with conditions" do - RelationModel.create! name: :bob, active: true, age: 10 - RelationModel.create! name: :alice, active: true, age: 20 - RelationModel.create! name: :john, active: false, age: 30 - expect(RelationModel.where(active: true).count).to eq(2) - - expect(RelationModel.where(active: true).to_a.map(&:name)).to match_array(%w[bob alice]) - expect(RelationModel.where(active: true).where(age: 10).to_a.map(&:name)).to match_array(%w[bob]) - end - - it "should query with merged conditions" do - RelationModel.create! name: :bob, active: true, age: 10 - RelationModel.create! name: :bob, active: false, age: 10 - RelationModel.create! name: :alice, active: true, age: 20 - RelationModel.create! name: :john, active: false, age: 30 - - expect(RelationModel.where(active: true).where(name: 'bob').count).to eq(1) - end - - - it "should count without loading models" do - RelationModel.create! name: :bob, active: true, age: 10 - RelationModel.create! name: :alice, active: false, age: 20 - - expect(RelationModel).not_to receive(:find) - - expect(RelationModel.where(active: true).count).to eq(1) - end - - it "Should delete_all" do - RelationModel.create! - RelationModel.create! - RelationModel.delete_all - expect(RelationModel.ids).to match_array([]) - end - - it "Should delete_all with conditions" do - RelationModel.create! - jane = RelationModel.create! name: "Jane" - RelationModel.where(name: nil).delete_all - expect(RelationModel.ids).to match_array([jane.id]) - end - - it "Should query ids" do - expect(RelationModel.ids).to match_array([]) - m1 = RelationModel.create! - m2 = RelationModel.create! - expect(RelationModel.ids).to match_array([m1.id, m2.id]) - end - - it "Should query ids with conditions" do - m1 = RelationModel.create!(active: true, name: "Jane") - _m2 = RelationModel.create!(active: false, name: "Bob" ) - _m3 = RelationModel.create!(active: false, name: "Jane") - expect(RelationModel.where(active: true, name: "Jane").ids).to match_array([m1.id]) - end - - it "Should query ids with conditions and limit" do - RelationModel.create!(active: true, name: "Jane", age: 2) - RelationModel.create!(active: false, name: "Bob", age: 3) - m = RelationModel.create!(active: true, name: "Jane", age: 1) - RelationModel.create!(active: false, name: "Jane", age: 0) - - expect(RelationModel.where(active: true, name: "Jane").order(:age).limit(1).ids).to match_array([m.id]) - expect(RelationModel.limit(1).where(active: true, name: "Jane").order(:age).ids).to match_array([m.id]) - end - - it "Should query ids with order" do - m1 = RelationModel.create!(age: 10, name: 'b') - m2 = RelationModel.create!(age: 20, name: 'a') - expect(RelationModel.order(age: :desc).ids).to match_array([m2.id, m1.id]) - expect(RelationModel.order(age: :asc).ids).to match_array([m1.id, m2.id]) - expect(RelationModel.order(name: :desc).ids).to match_array([m1.id, m2.id]) - expect(RelationModel.order(name: :asc).ids).to match_array([m2.id, m1.id]) - expect(RelationModel.order(:name).ids).to match_array([m2.id, m1.id]) - expect(RelationModel.order(:age).ids).to match_array([m1.id, m2.id]) - end - - it "Should query with list order" do - m1 = RelationModel.create!(age: 20, name: 'b') - m2 = RelationModel.create!(age: 5, name: 'a') - m3 = RelationModel.create!(age: 20, name: 'a') - expect(RelationModel.order(:age, :name).ids).to match_array([m2.id, m3.id, m1.id]) - end - - it "Should query with chained order" do - m1 = RelationModel.create!(age: 10, name: 'b') - m2 = RelationModel.create!(age: 20, name: 'a') - m3 = RelationModel.create!(age: 20, name: 'c') - expect(RelationModel.order(age: :desc).order(name: :asc).ids).to match_array([m2.id, m3.id, m1.id]) - end - - it "Should query with order chained with list" do - m1 = RelationModel.create!(age: 20, name: 'b') - m2 = RelationModel.create!(age: 5, name: 'a') - m3 = RelationModel.create!(age: 20, name: 'a', last_name: 'c') - m4 = RelationModel.create!(age: 20, name: 'a', last_name: 'a') - expect(RelationModel.order(:age, :name).order(:last_name).ids).to match_array([m2.id, m4.id, m3.id, m1.id]) - end - - it "Should query all" do - m1 = RelationModel.create!(active: true) - m2 = RelationModel.create!(active: false) - expect(RelationModel.all).to match_array([m1, m2]) - end - - it "should query all with condition and order" do - m1 = RelationModel.create!(active: true, age: 10) - m2 = RelationModel.create!(active: true, age: 20) - _m3 = RelationModel.create!(active: false, age: 30) - expect(RelationModel.where(active: true).order(age: :desc).all).to match_array([m2, m1]) - expect(RelationModel.all.where(active: true).order(age: :asc)).to match_array([m1, m2]) - end - - it "should return a relation when using not" do - expect(RelationModel.not(active: true)).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) - expect(RelationModel.all.not(active: true)).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) - end - - it "should have a to_ary method" do - expect(RelationModel.not(active: true)).to respond_to(:to_ary) - expect(RelationModel.all.not(active: true)).to respond_to(:to_ary) - end - - it "should have a each method" do - expect(RelationModel.not(active: true)).to respond_to(:each) - expect(RelationModel.all.not(active: true)).to respond_to(:each) - end - - it "should query true boolean" do - m1 = RelationModel.create!(active: true) - _m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.where(active: true)).to match_array([m1]) - end - - it "should not query true boolean" do - _m1 = RelationModel.create!(active: true) - m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.not(active: true)).to match_array([m2]) # keep ActiveRecord compatibility by not returning _m3 - end - - it "should query false boolean" do - _m1 = RelationModel.create!(active: true) - m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.where(active: false)).to match_array([m2]) - end - - it "should not query false boolean" do - m1 = RelationModel.create!(active: true) - _m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.not(active: false)).to match_array([m1]) # keep ActiveRecord compatibility by not returning _m3 - end - - it "should query nil boolean" do - _m1 = RelationModel.create!(active: true) - _m2 = RelationModel.create!(active: false) - m3 = RelationModel.create!(active: nil) - expect(RelationModel.where(active: nil)).to match_array([m3]) - end - - it "should not query nil boolean" do - m1 = RelationModel.create!(active: true) - m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.not(active: nil)).to match_array([m1, m2]) - end - - it "should query nil and false boolean" do - _m1 = RelationModel.create!(active: true) - m2 = RelationModel.create!(active: false) - m3 = RelationModel.create!(active: nil) - expect(RelationModel.where(active: [false, nil])).to match_array([m2, m3]) - end - - it "should not query nil and false boolean" do - m1 = RelationModel.create!(active: true) - _m2 = RelationModel.create!(active: false) - _m3 = RelationModel.create!(active: nil) - expect(RelationModel.not(active: [false, nil])).to match_array([m1]) - end -end - diff --git a/spec/support.rb b/spec/support.rb deleted file mode 100644 index 8a73443a..00000000 --- a/spec/support.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT -require 'simplecov' -require 'couchbase-orm' -require 'minitest/assertions' -require 'active_model/lint' -require 'pry' -require 'pry-stack_explorer' - -SimpleCov.start do - add_group 'Core', [/lib\/couchbase-orm\/(?!(proxies|utilities))/, 'lib/couchbase-orm.rb'] - add_group 'Proxies', 'lib/couchbase-orm/proxies' - add_group 'Utilities', 'lib/couchbase-orm/utilities' - add_group 'Specs', 'spec' - minimum_coverage 94 -end - -if ENV["COUCHBASE_FLUSH"] - CouchbaseOrm.logger.warn "Flushing Couchbase bucket '#{CouchbaseOrm::Connection.bucket.name}'" - CouchbaseOrm::Connection.cluster.buckets.flush_bucket(CouchbaseOrm::Connection.bucket.name) - raise "BucketFlushed" -end - -shared_examples_for "ActiveModel" do - include Minitest::Assertions - include ActiveModel::Lint::Tests - - def assertions - @__assertions__ ||= 0 - end - - def assertions=(val) - @__assertions__ = val - end - - ActiveModel::Lint::Tests.public_instance_methods.map { |method| method.to_s }.grep(/^test/).each do |method| - example(method.gsub('_', ' ')) { send method } - end - - before do - @model = subject - end -end diff --git a/spec/type_spec.rb b/spec/type_spec.rb deleted file mode 100644 index 935095ee..00000000 --- a/spec/type_spec.rb +++ /dev/null @@ -1,294 +0,0 @@ -require File.expand_path("../support", __FILE__) - -require "active_model" -require "couchbase-orm/types" - -class DateTimeWith3Decimal < CouchbaseOrm::Types::DateTime - def serialize(value) - value&.iso8601(3) - end -end - -ActiveModel::Type.register(:datetime3decimal, DateTimeWith3Decimal) - -class TypeTest < CouchbaseOrm::Base - attribute :name, :string - attribute :age, :integer - attribute :size, :float - attribute :renewal_date, :date - attribute :subscribed_at, :datetime - attribute :some_time, :timestamp - attribute :precision_time, :datetime3decimal - attribute :active, :boolean - - index :age, presence: false - index :renewal_date, presence: false - index :some_time, presence: false - index :precision_time, presence: false -end - -class N1qlTypeTest < CouchbaseOrm::Base - attribute :name, :string - attribute :age, :integer - attribute :size, :float - attribute :renewal_date, :date - attribute :subscribed_at, :datetime - attribute :some_time, :timestamp - attribute :precision_time, :datetime3decimal - attribute :active, :boolean - - index_n1ql :name, validate: false - index_n1ql :age, validate: false - index_n1ql :size, validate: false - index_n1ql :active, validate: false - index_n1ql :renewal_date, validate: false - index_n1ql :some_time, validate: false - index_n1ql :subscribed_at, validate: false - index_n1ql :precision_time, validate: false - n1ql :by_both_dates, emit_key: [:renewal_date, :subscribed_at], presence: false -end - -TypeTest.ensure_design_document! -N1qlTypeTest.ensure_design_document! - -describe CouchbaseOrm::Types::Timestamp do - it "should cast an integer to time" do - t = Time.at(Time.now.to_i) - expect(CouchbaseOrm::Types::Timestamp.new.cast(t.to_i)).to eq(t) - end - it "should cast an integer string to time" do - t = Time.at(Time.now.to_i) - expect(CouchbaseOrm::Types::Timestamp.new.cast(t.to_s)).to eq(t) - end -end - -describe CouchbaseOrm::Types::Date do - it "should cast an string to date" do - d = Date.today - expect(CouchbaseOrm::Types::Date.new.cast(d.to_s)).to eq(d) - end - - it "should serialize date to string" do - d = Date.today - expect(CouchbaseOrm::Types::Date.new.serialize(d)).to eq(d.to_s) - end - - it "should get the type from the registry" do - expect(ActiveModel::Type.lookup(:date)).to eq(CouchbaseOrm::Types::Date.new) - end -end - -describe CouchbaseOrm::Base do - before(:each) do - TypeTest.delete_all - N1qlTypeTest.delete_all - end - - it "should be createable" do - t = TypeTest.create! - expect(t).to be_a(TypeTest) - end - - it "should be able to set attributes" do - t = TypeTest.new - t.name = "joe" - t.age = 20 - t.size = 1.5 - t.renewal_date = Date.today - t.subscribed_at = Time.now - t.active = true - t.save! - - expect(t.name).to eq("joe") - expect(t.age).to eq(20) - expect(t.size).to eq(1.5) - expect(t.renewal_date).to eq(Date.today) - expect(t.subscribed_at).to be_a(Time) - expect(t.active).to eq(true) - end - - it "should be able to set attributes with a hash" do - t = TypeTest.new(name: "joe", age: 20, size: 1.5, renewal_date: Date.today, subscribed_at: Time.now, active: true) - t.save! - - expect(t.name).to eq("joe") - expect(t.age).to eq(20) - expect(t.size).to eq(1.5) - expect(t.renewal_date).to eq(Date.today) - expect(t.subscribed_at).to be_a(Time) - expect(t.active).to eq(true) - end - - it "should be able to be stored and retrieved" do - now = Time.now - t = TypeTest.create!(name: "joe", age: 20, size: 1.5, renewal_date: Date.today, subscribed_at: now, active: true) - t2 = TypeTest.find(t.id) - - expect(t2.name).to eq("joe") - expect(t2.age).to eq(20) - expect(t2.size).to eq(1.5) - expect(t2.renewal_date).to eq(Date.today) - expect(t2.subscribed_at).to eq(now.utc.change(usec: 0)) - expect(t2.active).to eq(true) - end - - it "should be able to query by age" do - t = TypeTest.create!(age: 20) - _t2 = TypeTest.create!(age: 40) - expect(TypeTest.find_by_age(20)).to eq t - end - - it "should be able to query by age and type cast" do - t = TypeTest.create!(age: "20") - expect(TypeTest.find_by_age(20)).to eq t - expect(TypeTest.find_by_age("20")).to eq t - end - - it "should be able to query by date" do - t = TypeTest.create!(renewal_date: Date.today) - _t2 = TypeTest.create!(renewal_date: Date.today + 1) - expect(TypeTest.find_by_renewal_date(Date.today)).to eq t - end - - it "should be able to query by date and type cast" do - t = TypeTest.create!(renewal_date: Date.today.to_s) - expect(TypeTest.find_by_renewal_date(Date.today)).to eq t - expect(TypeTest.find_by_renewal_date(Date.today.to_s)).to eq t - end - - it "should be able to query by time" do - now = Time.now - t = TypeTest.create!(name: "t", some_time: now) - _t2 = TypeTest.create!(name: "t2", some_time: now + 1) - expect(TypeTest.find_by_some_time(now)).to eq t - end - - it "should be able to query by time and type cast" do - now = Time.now - now_s = now.to_i.to_s - t = TypeTest.create!(some_time: now_s) - expect(TypeTest.find_by_some_time(now)).to eq t - expect(TypeTest.find_by_some_time(now_s)).to eq t - end - - it "should be able to query by custom type" do - now = Time.now - t = TypeTest.create!(precision_time: now) - _t2 = TypeTest.create!(precision_time: now + 1) - expect(TypeTest.find_by_precision_time(now)).to eq t - end - - it "should be able to query by custom type and type cast" do - now = Time.now - now_s = now.utc.iso8601(3) - t = TypeTest.create!(precision_time: now_s) - expect(TypeTest.find_by_precision_time(now)).to eq t - expect(TypeTest.find_by_precision_time(now_s)).to eq t - end - - it "should be able to set attributes with a hash with indifferent access" do - t = TypeTest.new(ActiveSupport::HashWithIndifferentAccess.new(name: "joe", age: 20, size: 1.5, renewal_date: Date.today, subscribed_at: Time.now, active: true)) - t.save! - - expect(t.name).to eq("joe") - expect(t.age).to eq(20) - expect(t.size).to eq(1.5) - expect(t.renewal_date).to eq(Date.today) - expect(t.subscribed_at).to be_a(Time) - expect(t.active).to eq(true) - end - - it "should be able to type cast attributes" do - t = TypeTest.new(name: "joe", age: "20", size: "1.5", renewal_date: Date.today.to_s, subscribed_at: Time.now.to_s, active: "true") - t.save! - - expect(t.name).to eq("joe") - expect(t.age).to eq(20) - expect(t.size).to eq(1.5) - expect(t.renewal_date).to eq(Date.today) - expect(t.subscribed_at).to be_a(Time) - expect(t.active).to eq(true) - end - - it "should be consistent with active record on failed cast" do - t = TypeTest.new(name: "joe", age: "joe", size: "joe", renewal_date: "joe", subscribed_at: "joe", active: "true") - t.save! - - expect(t.age).to eq 0 - expect(t.size).to eq 0.0 - expect(t.renewal_date).to eq nil - expect(t.subscribed_at).to eq nil - expect(t.active).to eq true - end - - it "should be able to query by name" do - t = N1qlTypeTest.create!(name: "joe") - _t2 = N1qlTypeTest.create!(name: "john") - expect(N1qlTypeTest.find_by_name("joe").to_a).to eq [t] - end - - it "should be able to query by nil value" do - t = N1qlTypeTest.create!() - _t2 = N1qlTypeTest.create!(name: "john") - expect(N1qlTypeTest.find_by_name(nil).to_a).to eq [t] - end - - it "should be able to query by array value" do - t = N1qlTypeTest.create!(name: "laura") - t2 = N1qlTypeTest.create!(name: "joe") - _t3 = N1qlTypeTest.create!(name: "john") - expect(N1qlTypeTest.find_by_name(["laura", "joe"]).to_a).to match_array [t, t2] - end - - it "should be able to query by integer" do - t = N1qlTypeTest.create!(age: 20) - t2 = N1qlTypeTest.create!(age: 20) - _t3 = N1qlTypeTest.create!(age: 40) - expect(N1qlTypeTest.find_by_age(20).to_a).to match_array [t, t2] - end - - it "should be able to query by integer and type cast" do - t = N1qlTypeTest.create!(age: "20") - expect(N1qlTypeTest.find_by_age(20).to_a).to eq [t] - expect(N1qlTypeTest.find_by_age("20").to_a).to eq [t] - end - - it "should be able to query by date" do - t = N1qlTypeTest.create!(renewal_date: Date.today) - _t2 = N1qlTypeTest.create!(renewal_date: Date.today + 1) - expect(N1qlTypeTest.find_by_renewal_date(Date.today).to_a).to eq [t] - end - - it "should be able to query by datetime" do - now = Time.now - t = N1qlTypeTest.create!(subscribed_at: now) - _t2 = N1qlTypeTest.create!(subscribed_at: now + 1) - expect(N1qlTypeTest.find_by_subscribed_at(now).to_a).to eq [t] - end - - it "should be able to query by timestamp" do - now = Time.now - t = N1qlTypeTest.create!(some_time: now) - _t2 = N1qlTypeTest.create!(some_time: now + 1) - expect(N1qlTypeTest.find_by_some_time(now).to_a).to eq [t] - end - - it "should be able to query by custom type" do - now = Time.now - t = N1qlTypeTest.create!(precision_time: now) - _t2 = N1qlTypeTest.create!(precision_time: now + 1) - expect(N1qlTypeTest.find_by_precision_time(now).to_a).to eq [t] - end - - it "should be able to query by boolean" do - t = N1qlTypeTest.create!(active: true) - _t2 = N1qlTypeTest.create!(active: false) - expect(N1qlTypeTest.find_by_active(true).to_a).to eq [t] - end - - it "should be able to query by float" do - t = N1qlTypeTest.create!(size: 1.5) - _t2 = N1qlTypeTest.create!(size: 2.5) - expect(N1qlTypeTest.find_by_size(1.5).to_a).to eq [t] - end -end diff --git a/spec/views_spec.rb b/spec/views_spec.rb deleted file mode 100644 index 3ae3f865..00000000 --- a/spec/views_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true, encoding: ASCII-8BIT - -require File.expand_path("../support", __FILE__) -require 'set' - -class ViewTest < CouchbaseOrm::Base - attribute :name, type: String - enum rating: [:awesome, :good, :okay, :bad], default: :okay - - view :vall - - # This generates both: - # view :by_rating, emit_key: :rating - # def self.find_by_rating(rating); end # also provide this helper function - index_view :rating -end - - -describe CouchbaseOrm::Views do - before(:each) do - ViewTest.delete_all - rescue Couchbase::Error::DesignDocumentNotFound - # ignore (FIXME: check before merge) mainly because if there is nothing in all we should not have an error - end - - after(:each) do - ViewTest.delete_all - rescue Couchbase::Error::InternalServerFailure - # ignore (FIXME: check before merge) - rescue Couchbase::Error::DesignDocumentNotFound - # ignore (FIXME: check before merge) (7.1) - end - - it "should not allow n1ql to override existing methods" do - expect { ViewTest.view :all }.to raise_error(ArgumentError) - end - - it "should save a new design document" do - begin - ViewTest.bucket.view_indexes.drop_design_document(ViewTest.design_document, :production) - rescue Couchbase::Error::InternalServerFailure - # ignore if design document does not exist - rescue Couchbase::Error::DesignDocumentNotFound - # ignore if design document does not exist (7.1) - end - expect(ViewTest.ensure_design_document!).to be(true) - end - - it "should not re-save a design doc if nothing has changed" do - expect(ViewTest.ensure_design_document!).to be(false) - end - - it "should return an empty array when there is no objects" do - expect(ViewTest.vall).to eq([]) - end - - it "should perform a map-reduce and return the view" do - ViewTest.ensure_design_document! - ViewTest.create! name: :bob, rating: :good - - docs = ViewTest.vall.collect { |ob| - ob.destroy - ob.name - } - expect(docs).to eq(['bob']) - end - - it "should work with other keys" do - ViewTest.ensure_design_document! - ViewTest.create! name: :bob, rating: :good - ViewTest.create! name: :jane, rating: :awesome - ViewTest.create! name: :greg, rating: :bad - - docs = ViewTest.by_rating(order: :descending).collect { |ob| - ob.destroy - ob.name - } - expect(docs).to eq(['greg', 'bob', 'jane']) - end - - it "should return matching results" do - ViewTest.ensure_design_document! - ViewTest.create! name: :bob, rating: :awesome - ViewTest.create! name: :jane, rating: :awesome - ViewTest.create! name: :greg, rating: :bad - ViewTest.create! name: :mel, rating: :good - - docs = ViewTest.find_by_rating(1).collect { |ob| - ob.name - } - - expect(Set.new(docs)).to eq(Set.new(['bob', 'jane'])) - end -end