diff --git a/README.md b/README.md index cc3a050d..6e6fa7d3 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ This works fine in production however by default in development models are lazy require 'couchbase-orm' class Post < CouchbaseOrm::Base - attribute :title, type: String - attribute :body, type: String - attribute :draft, type: Boolean + attribute :title, :string + attribute :body, :string + attribute :draft, :boolean end p = Post.new(id: 'hello-world', @@ -87,9 +87,9 @@ You can define connection options on per model basis: ```ruby class Post < CouchbaseOrm::Base - attribute :title, type: String - attribute :body, type: String - attribute :draft, type: Boolean + attribute :title, :string + attribute :body, :string + attribute :draft, :boolean connect bucket: 'blog', password: ENV['BLOG_BUCKET_PASSWORD'] end @@ -103,7 +103,8 @@ context of rails application. You can also enforce types using ruby ```ruby class Comment < Couchbase::Model - attribute :author, :body, type: String + attribute :author, :string + attribute :body, :string validates_presence_of :author, :body end @@ -116,7 +117,8 @@ can then be used for filtering results or ordering. ```ruby class Comment < CouchbaseOrm::Base - attribute :author, :body, type: String + attribute :author :string + attribute :body, :string view :all # => emits :id and will return all comments view :by_author, emit_key: :author @@ -159,7 +161,8 @@ Like views, it's possible to use N1QL to process some requests used for filterin ```ruby class Comment < CouchbaseOrm::Base - attribute :author, :body, type: String + attribute :author, :string + attribute :body, :string n1ql :by_author, emit_key: :author # Generates two functions: @@ -196,7 +199,7 @@ There are common active record helpers available for use `belongs_to` and `has_m has_many :comments, dependent: :destroy # You can ensure an attribute is unique for this model - attribute :email, type: String + attribute :email, :string ensure_unique :email end ``` @@ -213,6 +216,41 @@ By default, `has_many` uses a view for association, but you can define a `type` end ``` +## Nested + +Attributes can be of type nested, they must specify a type of NestedDocument. The NestedValidation triggers nested validation on parent validation. + +```ruby + class Address < CouchbaseOrm::NestedDocument + attribute :road, :string + attribute :city, :string + validates :road, :city, presence: true + end + + class Author < CouchbaseOrm::Base + attribute :address, :nested, type: Address + validates :address, nested: true + end +``` + +## Array + +Attributes can be of type array, they must contain something that can be serialized and deserialized to/from JSON. You can enforce the type of array elements. The type can be a NestedDocument + +```ruby + class Book < CouchbaseOrm::NestedDocument + attribute :name, :string + validates :name, presence: true + end + + class Author < CouchbaseOrm::Base + attribute things, :array + attribute flags, :array, type: :string + attribute books, :array, type: Book + + validates :books, nested: true + end +``` ## Performance Comparison with Couchbase-Ruby-Model diff --git a/lib/couchbase-orm.rb b/lib/couchbase-orm.rb index c91f87ec..b7dbe0d4 100644 --- a/lib/couchbase-orm.rb +++ b/lib/couchbase-orm.rb @@ -11,6 +11,8 @@ module CouchbaseOrm autoload :Connection, 'couchbase-orm/connection' autoload :IdGenerator, 'couchbase-orm/id_generator' autoload :Base, 'couchbase-orm/base' + autoload :Document, 'couchbase-orm/base' + autoload :NestedDocument, 'couchbase-orm/base' autoload :HasMany, 'couchbase-orm/utilities/has_many' def self.logger diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index 91e6ed29..6ebdc1df 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -59,6 +59,14 @@ def connected? def table_exists? true end + + def _reflect_on_association(attribute) + false + end + + def type_for_attribute(attribute) + attribute_types[attribute] + end if ActiveModel::VERSION::MAJOR < 6 def attribute_names @@ -97,7 +105,7 @@ def _write_attribute(attr_name, value) end end - class Base + class Document include ::ActiveModel::Model include ::ActiveModel::Dirty include ::ActiveModel::Attributes @@ -109,12 +117,80 @@ class Base include ::ActiveRecord::Core include ActiveRecordCompat + extend Enum + define_model_callbacks :initialize, :only => :after define_model_callbacks :create, :destroy, :save, :update + Metadata = Struct.new(:cas) + + 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 + + def [](key) + send(key) + end + + def []=(key, value) + send(:"#{key}=", value) + end + + protected + + def serialized_attributes + attributes.map { |k, v| + [k, self.class.attribute_types[k].serialize(v)] + }.to_h + end + end + + class NestedDocument < Document + + end + + class Base < Document + include ::ActiveRecord::Validations include Persistence include ::ActiveRecord::AttributeMethods::Dirty include ::ActiveRecord::Timestamp # must be included after Persistence + include Associations include Views include QueryHelper @@ -127,10 +203,6 @@ class Base extend HasMany extend Index - - Metadata = Struct.new(:key, :cas) - - class << self def connect(**options) @bucket = BucketProxy.new(::MTLibcouchbase::Bucket.new(**options)) @@ -190,64 +262,10 @@ def exists?(id) 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 + raise RuntimeError, '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) + _write_attribute("id", value) end # Public: Allows for access to ActiveModel functionality. diff --git a/lib/couchbase-orm/persistence.rb b/lib/couchbase-orm/persistence.rb index 29f72552..8584470a 100644 --- a/lib/couchbase-orm/persistence.rb +++ b/lib/couchbase-orm/persistence.rb @@ -186,7 +186,7 @@ def update_columns(with_cas: false, **hash) 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) + self.class.collection.replace(id, attributes.except("id").merge(type: self.class.design_document), **options) end # Ensure the model is up to date @@ -221,13 +221,6 @@ def touch(**options) 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) @@ -237,7 +230,7 @@ def _update_record(*_args, with_cas: false, **options) 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)) + 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 @@ -254,8 +247,7 @@ def _create_record(*_args, **options) 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)) + 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 diff --git a/lib/couchbase-orm/types.rb b/lib/couchbase-orm/types.rb index e6674c0d..95300033 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" +require "couchbase-orm/types/array" +require "couchbase-orm/types/nested" if ActiveModel::VERSION::MAJOR < 6 # In Rails 5, the type system cannot allow overriding the default types @@ -12,3 +14,5 @@ ActiveModel::Type.register(:date, CouchbaseOrm::Types::Date) ActiveModel::Type.register(:datetime, CouchbaseOrm::Types::DateTime) ActiveModel::Type.register(:timestamp, CouchbaseOrm::Types::Timestamp) +ActiveModel::Type.register(:array, CouchbaseOrm::Types::Array) +ActiveModel::Type.register(:nested, CouchbaseOrm::Types::Nested) diff --git a/lib/couchbase-orm/types/array.rb b/lib/couchbase-orm/types/array.rb new file mode 100644 index 00000000..a8dd7fe5 --- /dev/null +++ b/lib/couchbase-orm/types/array.rb @@ -0,0 +1,32 @@ +module CouchbaseOrm + module Types + class Array < ActiveModel::Type::Value + attr_reader :type_class + attr_reader :model_class + + def initialize(type: nil) + if type.is_a?(Class) && type < CouchbaseOrm::NestedDocument + @model_class = type + @type_class = CouchbaseOrm::Types::Nested.new(type: @model_class) + else + @type_class = ActiveModel::Type.registry.lookup(type) + end + super() + end + + def cast(values) + return [] if values.nil? + + raise ArgumentError, "#{values.inspect} must be an array" unless values.is_a?(::Array) + + values.map(&@type_class.method(:cast)) + end + + def serialize(values) + return [] if values.nil? + + values.map(&@type_class.method(:serialize)) + end + end + end +end diff --git a/lib/couchbase-orm/types/nested.rb b/lib/couchbase-orm/types/nested.rb new file mode 100644 index 00000000..c0f113d0 --- /dev/null +++ b/lib/couchbase-orm/types/nested.rb @@ -0,0 +1,42 @@ +class NestedValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if value.is_a?(Array) + record.errors.add attribute, (options[:message] || "is invalid") unless value.map(&:valid?).all? + else + record.errors.add attribute, (options[:message] || "is invalid") unless + value.nil? || value.valid? + end + + end +end + +module CouchbaseOrm + module Types + class Nested < ActiveModel::Type::Value + attr_reader :model_class + + def initialize(type:) + raise ArgumentError, "type is nil" if type.nil? + raise ArgumentError, "type is not a class : #{type.inspect}" unless type.is_a?(Class) + + @model_class = type + super() + end + + def cast(value) + return nil if value.nil? + return value if value.is_a?(@model_class) + return @model_class.new(value) if value.is_a?(Hash) + + raise ArgumentError, "Nested: #{value.inspect} is not supported for cast" + end + + def serialize(value) + return nil if value.nil? + return value.send(:serialized_attributes).except("id") if value.is_a?(@model_class) + + raise ArgumentError, "Nested: #{value.inspect} is not supported for serialization" + end + end + end +end diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 3745b0e6..c69aa09a 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -148,6 +148,12 @@ class TimestampTest < CouchbaseOrm::Base base.destroy end + it "cannot change the id of a loaded object" do + base = BaseTest.create!(name: 'joe') + expect(base.id).to_not be_nil + expect{base.id = "foo"}.to raise_error(RuntimeError, 'ID cannot be changed') + 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"]) diff --git a/spec/type_array_spec.rb b/spec/type_array_spec.rb new file mode 100644 index 00000000..f6bc8c6b --- /dev/null +++ b/spec/type_array_spec.rb @@ -0,0 +1,52 @@ +require File.expand_path("../support", __FILE__) + +require "active_model" + +class TypeArrayTest < CouchbaseOrm::Base + attribute :name + attribute :tags, :array, type: :string + attribute :milestones, :array, type: :date + attribute :flags, :array, type: :boolean + attribute :things +end + +describe CouchbaseOrm::Base do + it "should be able to store and retrieve an array of strings" do + obj = TypeArrayTest.new + obj.tags = ["foo", "bar"] + obj.save! + + obj = TypeArrayTest.find(obj.id) + expect(obj.tags).to eq ["foo", "bar"] + end + + it "should be able to store and retrieve an array of date" do + dates = [Date.today, Date.today + 1] + obj = TypeArrayTest.new + obj.milestones = dates + obj.save! + + obj = TypeArrayTest.find(obj.id) + expect(obj.milestones).to eq dates + end + + it "should be able to store and retrieve an array of boolean" do + flags = [true, false] + obj = TypeArrayTest.new + obj.flags = flags + obj.save! + + obj = TypeArrayTest.find(obj.id) + expect(obj.flags).to eq flags + end + + it "should be able to store and retrieve an array of basic objects" do + things = [1, "1234", {"key" => 4}] + obj = TypeArrayTest.new + obj.things = things + obj.save! + + obj = TypeArrayTest.find(obj.id) + expect(obj.things).to eq things + end +end diff --git a/spec/type_nested_spec.rb b/spec/type_nested_spec.rb new file mode 100644 index 00000000..b7be1fd9 --- /dev/null +++ b/spec/type_nested_spec.rb @@ -0,0 +1,154 @@ +require File.expand_path("../support", __FILE__) + +require "active_model" + +class SubTypeTest < CouchbaseOrm::NestedDocument + attribute :name, :string + attribute :tags, :array, type: :string + attribute :milestones, :array, type: :date + attribute :flags, :array, type: :boolean + attribute :things + attribute :child, :nested, type: SubTypeTest +end + +class TypeNestedTest < CouchbaseOrm::Base + attribute :main, :nested, type: SubTypeTest + attribute :others, :array, type: SubTypeTest +end + +describe CouchbaseOrm::Types::Nested do + it "should be able to store and retrieve a nested object" do + obj = TypeNestedTest.new + obj.main = SubTypeTest.new + obj.main.name = "foo" + obj.main.tags = ["foo", "bar"] + obj.main.child = SubTypeTest.new(name: "bar") + obj.save! + + obj = TypeNestedTest.find(obj.id) + expect(obj.main.name).to eq "foo" + expect(obj.main.tags).to eq ["foo", "bar"] + expect(obj.main.child.name).to eq "bar" + end + + it "should be able to store and retrieve an array of nested objects" do + obj = TypeNestedTest.new + obj.others = [SubTypeTest.new, SubTypeTest.new] + obj.others[0].name = "foo" + obj.others[0].tags = ["foo", "bar"] + obj.others[1].name = "bar" + obj.others[1].tags = ["bar", "baz"] + obj.others[1].child = SubTypeTest.new(name: "baz") + obj.save! + + obj = TypeNestedTest.find(obj.id) + expect(obj.others[0].name).to eq "foo" + expect(obj.others[0].tags).to eq ["foo", "bar"] + expect(obj.others[1].name).to eq "bar" + expect(obj.others[1].tags).to eq ["bar", "baz"] + expect(obj.others[1].child.name).to eq "baz" + end + + it "should serialize to JSON" do + obj = TypeNestedTest.new + obj.others = [SubTypeTest.new, SubTypeTest.new] + obj.others[0].name = "foo" + obj.others[0].tags = ["foo", "bar"] + obj.others[1].name = "bar" + obj.others[1].tags = ["bar", "baz"] + obj.others[1].child = SubTypeTest.new(name: "baz") + obj.save! + + obj = TypeNestedTest.find(obj.id) + expect(obj.send(:serialized_attributes)).to eq ({ + "id" => obj.id, + "main" => nil, + "others" => [ + { + "name" => "foo", + "tags" => ["foo", "bar"], + "milestones" => [], + "flags" => [], + "things" => nil, + "child" => nil + }, + { + "name" => "bar", + "tags" => ["bar", "baz"], + "milestones" => [], + "flags" => [], + "things" => nil, + "child" => { + "name" => "baz", + "tags" => [], + "milestones" => [], + "flags" => [], + "things" => nil, + "child" => nil + } + } + ] + }) + end + + it "should not have a save method" do + expect(SubTypeTest.new).to_not respond_to(:save) + end + + it "should not cast a list" do + expect{CouchbaseOrm::Types::Nested.new(type: SubTypeTest).cast([1,2,3])}.to raise_error(ArgumentError) + end + + it "should not serialize a list" do + expect{CouchbaseOrm::Types::Nested.new(type: SubTypeTest).serialize([1,2,3])}.to raise_error(ArgumentError) + end + + describe "Validations" do + + + class SubWithValidation < CouchbaseOrm::NestedDocument + attribute :name + attribute :label + attribute :child, :nested, type: SubWithValidation + validates :name, presence: true + validates :child, nested: true + end + + class WithValidationParent < CouchbaseOrm::Base + attribute :child, :nested, type: SubWithValidation + attribute :children, :array, type: SubWithValidation + validates :child, :children, nested: true + end + + it "should validate the nested object" do + obj = WithValidationParent.new + obj.child = SubWithValidation.new + expect(obj).to_not be_valid + expect(obj.errors[:child]).to eq ["is invalid"] + expect(obj.child.errors[:name]).to eq ["can't be blank"] + + end + + it "should validate the nested objects in an array" do + obj = WithValidationParent.new + obj.children = [SubWithValidation.new(name: "foo"), SubWithValidation.new] + expect(obj).to_not be_valid + expect(obj.errors[:children]).to eq ["is invalid"] + expect(obj.children[1].errors[:name]).to eq ["can't be blank"] + end + + it "should validate the nested in the nested object" do + obj = WithValidationParent.new + obj.child = SubWithValidation.new name: "foo", label: "parent" + obj.child.child = SubWithValidation.new label: "child" + + expect(obj).to_not be_valid + expect(obj.child).to_not be_valid + expect(obj.child.child).to_not be_valid + + expect(obj.errors[:child]).to eq ["is invalid"] + expect(obj.child.errors[:child]).to eq ["is invalid"] + expect(obj.child.child.errors[:name]).to eq ["can't be blank"] + end + end +end diff --git a/spec/type_spec.rb b/spec/type_spec.rb index 935095ee..87c42d38 100644 --- a/spec/type_spec.rb +++ b/spec/type_spec.rb @@ -84,6 +84,10 @@ class N1qlTypeTest < CouchbaseOrm::Base N1qlTypeTest.delete_all end + it "should be typed" do + expect(N1qlTypeTest.attribute_types["name"]).to be_a(ActiveModel::Type::String) + end + it "should be createable" do t = TypeTest.create! expect(t).to be_a(TypeTest)