Skip to content

Commit

Permalink
Merge pull request #52 from doctolib/nested_and_array
Browse files Browse the repository at this point in the history
[Breaking changes] Array, Nested, Array of Nested, Nested of Nested
  • Loading branch information
giallon authored Oct 11, 2022
2 parents 2a8aaee + 8786898 commit a1fbeb2
Show file tree
Hide file tree
Showing 11 changed files with 426 additions and 82 deletions.
58 changes: 48 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
```
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions lib/couchbase-orm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 79 additions & 61 deletions lib/couchbase-orm/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 3 additions & 11 deletions lib/couchbase-orm/persistence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/couchbase-orm/types.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
32 changes: 32 additions & 0 deletions lib/couchbase-orm/types/array.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit a1fbeb2

Please sign in to comment.