diff --git a/lib/couchbase-orm.rb b/lib/couchbase-orm.rb index 0a85ce06..849a9e8d 100644 --- a/lib/couchbase-orm.rb +++ b/lib/couchbase-orm.rb @@ -15,6 +15,7 @@ module CouchbaseOrm autoload :Document, 'couchbase-orm/base' autoload :NestedDocument, 'couchbase-orm/base' autoload :HasMany, 'couchbase-orm/utilities/has_many' + autoload :AttributesDynamic, 'couchbase-orm/attributes/dynamic' def self.logger @@logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT).tap { |l| l.level = Logger::INFO unless ENV["COUCHBASE_ORM_DEBUG"] } diff --git a/lib/couchbase-orm/attributes/dynamic.rb b/lib/couchbase-orm/attributes/dynamic.rb new file mode 100644 index 00000000..ca0a1711 --- /dev/null +++ b/lib/couchbase-orm/attributes/dynamic.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT + +module CouchbaseOrm + module AttributesDynamic + extend ActiveSupport::Concern + + # Override respond_to? so it responds properly for dynamic attributes. + # + # @example Does this object respond to the method? + # person.respond_to?(:title) + # + # @param [ Array ] name The name of the method. + # @param [ true | false ] include_private + # + # @return [ true | false ] True if it does, false if not. + def respond_to?(name, include_private = false) + super || attributes&.key?(name.to_s.reader) + end + + private + + # Override private _assign_attribute to accept dynamic attribute + # + # @param [ String ] name of attribute + # @param [ Object ] value of attribute + # + # @return [ Object ] value of attribute + def _assign_attribute(name, value) + responds = name.reader == 'id' || respond_to?(name.writer) + if responds + public_send(name.writer, value) + else + type = value.class.to_s.underscore.to_sym + type = :hash if type == :"active_support/hash_with_indifferent_access" + type = ActiveModel::Type.lookup(type) + @attributes[name] = ActiveModel::Attribute.from_database(name, value, type) + end + end + + # Define a reader method for a dynamic attribute. + # + # @example Define a reader method. + # model.define_dynamic_reader(:field) + # + # @param [ String ] name The name of the field. + def define_dynamic_reader(name) + return unless name.valid_method_name? + + instance_eval do + define_singleton_method(name) do + @attributes[getter].value + end + end + end + + # Define a writer method for a dynamic attribute. + # + # @example Define a writer method. + # model.define_dynamic_writer(:field) + # + # @param [ String ] name The name of the field. + def define_dynamic_writer(name) + return unless name.valid_method_name? + + instance_eval do + define_singleton_method("#{name}=") do |value| + @attributes.write_from_user(name, value) + value + end + end + end + + # Used for allowing accessor methods for dynamic attributes. + # + # @api private + # + # @example Call through method_missing. + # document.method_missing(:test) + # + # @param [ String | Symbol ] name The name of the method. + # @param [ Object... ] *args The arguments to the method. + # + # @return [ Object ] The result of the method call. + def method_missing(name, *args) + attr = name.to_s + return super unless attr.reader != 'id' && attributes.key?(attr.reader) + + getter = attr.reader + if attr.writer? + define_dynamic_writer(getter) + @attributes.write_from_user(getter, args.first) + args.first + else + define_dynamic_reader(getter) + @attributes[getter].value + end + end + end +end \ No newline at end of file diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index 1279db5d..dd741359 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -10,6 +10,7 @@ end require 'active_support/hash_with_indifferent_access' require 'couchbase' +require 'couchbase-orm/extensions/string' require 'couchbase-orm/error' require 'couchbase-orm/views' require 'couchbase-orm/n1ql' @@ -86,7 +87,17 @@ def attribute_for_inspect(attr_name) value.inspect end - if ActiveModel::VERSION::MAJOR < 6 + if ActiveModel::VERSION::MAJOR <= 6 + def format_for_inspect(value) + if value.is_a?(String) && value.length > 50 + "#{value[0, 50]}...".inspect + elsif value.is_a?(Date) || value.is_a?(Time) + %("#{value.to_s(:db)}") + else + value.inspect + end + end + def attribute_names self.class.attribute_names end @@ -104,6 +115,14 @@ def _write_attribute(attr_name, value) @attributes.write_from_user(attr_name.to_s, value) value end + + def read_attribute(attr_name, &block) + name = attr_name.to_s + name = self.class.attribute_aliases[name] || name + + name = @primary_key if name == "id" && @primary_key + @attributes.fetch_value(name, &block) + end end end diff --git a/lib/couchbase-orm/extensions/string.rb b/lib/couchbase-orm/extensions/string.rb new file mode 100644 index 00000000..d9395b78 --- /dev/null +++ b/lib/couchbase-orm/extensions/string.rb @@ -0,0 +1,27 @@ +module CouchbaseOrm + module Extensions + module String + def reader + delete("=").sub(/\_before\_type\_cast\z/, '') + end + + def writer + sub(/\_before\_type\_cast\z/, '') + "=" + end + + def writer? + include?("=") + end + + def before_type_cast? + ends_with?("_before_type_cast") + end + + def valid_method_name? + /[@$"-]/ !~ self + end + end + end +end + +::String.__send__(:include, CouchbaseOrm::Extensions::String) diff --git a/lib/couchbase-orm/types.rb b/lib/couchbase-orm/types.rb index b6341023..f88aa146 100644 --- a/lib/couchbase-orm/types.rb +++ b/lib/couchbase-orm/types.rb @@ -5,7 +5,7 @@ require "couchbase-orm/types/nested" require "couchbase-orm/types/encrypted" -if ActiveModel::VERSION::MAJOR < 6 +if ActiveModel::VERSION::MAJOR <= 6 # In Rails 5, the type system cannot allow overriding the default types ActiveModel::Type.registry.instance_variable_get(:@registrations).delete_if do |k| k.matches?(:date) || k.matches?(:datetime) || k.matches?(:timestamp) diff --git a/spec/attribute_dynamic_spec.rb b/spec/attribute_dynamic_spec.rb new file mode 100644 index 00000000..9fa2a438 --- /dev/null +++ b/spec/attribute_dynamic_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true, encoding: ASCII-8BIT + +require File.expand_path("../support", __FILE__) + +class AttributeDynamicTest < CouchbaseOrm::Base + include CouchbaseOrm::AttributesDynamic + + attribute :name, :string + attribute :job, :string +end + +describe CouchbaseOrm::AttributesDynamic do + context 'from initialize' do + it 'should accept unknown attribute from initialize' do + dynamic = AttributeDynamicTest.new(name: 'joe', new_attribute: 1) + expect(dynamic.new_attribute).to eq(1) + end + end + + context 'from Couchbase' do + it 'should accept unknown attribute from Couchbase' do + dynamic = AttributeDynamicTest.create!(name: 'joe', new_attribute: 2) + expect(AttributeDynamicTest.find_by_id(dynamic.id)).to have_attributes(new_attribute: 2) + dynamic.destroy + end + end +end \ No newline at end of file