couchbase-orm 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/test.yml +45 -0
  3. data/.gitignore +2 -0
  4. data/.travis.yml +5 -4
  5. data/CODEOWNERS +1 -0
  6. data/Gemfile +5 -3
  7. data/README.md +237 -31
  8. data/ci/run_couchbase.sh +22 -0
  9. data/couchbase-orm.gemspec +26 -20
  10. data/lib/couchbase-orm/active_record_compat.rb +92 -0
  11. data/lib/couchbase-orm/associations.rb +119 -0
  12. data/lib/couchbase-orm/base.rb +143 -166
  13. data/lib/couchbase-orm/changeable.rb +512 -0
  14. data/lib/couchbase-orm/connection.rb +28 -8
  15. data/lib/couchbase-orm/encrypt.rb +48 -0
  16. data/lib/couchbase-orm/error.rb +17 -2
  17. data/lib/couchbase-orm/inspectable.rb +37 -0
  18. data/lib/couchbase-orm/json_schema/json_validation_error.rb +13 -0
  19. data/lib/couchbase-orm/json_schema/loader.rb +47 -0
  20. data/lib/couchbase-orm/json_schema/validation.rb +18 -0
  21. data/lib/couchbase-orm/json_schema/validator.rb +45 -0
  22. data/lib/couchbase-orm/json_schema.rb +9 -0
  23. data/lib/couchbase-orm/json_transcoder.rb +27 -0
  24. data/lib/couchbase-orm/locale/en.yml +5 -0
  25. data/lib/couchbase-orm/n1ql.rb +133 -0
  26. data/lib/couchbase-orm/persistence.rb +67 -50
  27. data/lib/couchbase-orm/proxies/bucket_proxy.rb +36 -0
  28. data/lib/couchbase-orm/proxies/collection_proxy.rb +52 -0
  29. data/lib/couchbase-orm/proxies/n1ql_proxy.rb +40 -0
  30. data/lib/couchbase-orm/proxies/results_proxy.rb +23 -0
  31. data/lib/couchbase-orm/railtie.rb +6 -17
  32. data/lib/couchbase-orm/relation.rb +249 -0
  33. data/lib/couchbase-orm/strict_loading.rb +21 -0
  34. data/lib/couchbase-orm/timestamps/created.rb +20 -0
  35. data/lib/couchbase-orm/timestamps/updated.rb +21 -0
  36. data/lib/couchbase-orm/timestamps.rb +15 -0
  37. data/lib/couchbase-orm/types/array.rb +32 -0
  38. data/lib/couchbase-orm/types/date.rb +9 -0
  39. data/lib/couchbase-orm/types/date_time.rb +14 -0
  40. data/lib/couchbase-orm/types/encrypted.rb +17 -0
  41. data/lib/couchbase-orm/types/nested.rb +43 -0
  42. data/lib/couchbase-orm/types/timestamp.rb +18 -0
  43. data/lib/couchbase-orm/types.rb +20 -0
  44. data/lib/couchbase-orm/utilities/enum.rb +13 -1
  45. data/lib/couchbase-orm/utilities/has_many.rb +72 -36
  46. data/lib/couchbase-orm/utilities/ignored_properties.rb +15 -0
  47. data/lib/couchbase-orm/utilities/index.rb +23 -8
  48. data/lib/couchbase-orm/utilities/properties_always_exists_in_document.rb +16 -0
  49. data/lib/couchbase-orm/utilities/query_helper.rb +148 -0
  50. data/lib/couchbase-orm/utils.rb +25 -0
  51. data/lib/couchbase-orm/version.rb +1 -1
  52. data/lib/couchbase-orm/views.rb +38 -41
  53. data/lib/couchbase-orm.rb +44 -9
  54. data/lib/ext/query_n1ql.rb +124 -0
  55. data/lib/rails/generators/couchbase_orm/config/templates/couchbase.yml +3 -2
  56. data/spec/associations_spec.rb +219 -50
  57. data/spec/base_spec.rb +296 -14
  58. data/spec/collection_proxy_spec.rb +29 -0
  59. data/spec/connection_spec.rb +27 -0
  60. data/spec/couchbase-orm/active_record_compat_spec.rb +24 -0
  61. data/spec/couchbase-orm/changeable_spec.rb +16 -0
  62. data/spec/couchbase-orm/json_schema/validation_spec.rb +23 -0
  63. data/spec/couchbase-orm/json_schema/validator_spec.rb +13 -0
  64. data/spec/couchbase-orm/timestamps_spec.rb +85 -0
  65. data/spec/couchbase-orm/timestamps_spec_models.rb +36 -0
  66. data/spec/empty-json-schema/.gitkeep +0 -0
  67. data/spec/enum_spec.rb +34 -0
  68. data/spec/has_many_spec.rb +101 -54
  69. data/spec/index_spec.rb +55 -8
  70. data/spec/json-schema/JsonSchemaBaseTest.json +19 -0
  71. data/spec/json-schema/entity_snakecase.json +20 -0
  72. data/spec/json-schema/loader_spec.rb +42 -0
  73. data/spec/json-schema/specific_path.json +20 -0
  74. data/spec/json_schema_spec.rb +178 -0
  75. data/spec/n1ql_spec.rb +193 -0
  76. data/spec/persistence_spec.rb +49 -9
  77. data/spec/relation_nested_spec.rb +88 -0
  78. data/spec/relation_spec.rb +430 -0
  79. data/spec/support.rb +16 -8
  80. data/spec/type_array_spec.rb +52 -0
  81. data/spec/type_encrypted_spec.rb +114 -0
  82. data/spec/type_nested_spec.rb +191 -0
  83. data/spec/type_spec.rb +317 -0
  84. data/spec/utilities/ignored_properties_spec.rb +20 -0
  85. data/spec/utilities/properties_always_exists_in_document_spec.rb +24 -0
  86. data/spec/views_spec.rb +34 -13
  87. metadata +192 -29
@@ -32,6 +32,8 @@ module CouchbaseOrm
32
32
  val = if options[:polymorphic]
33
33
  ::CouchbaseOrm.try_load(self.send(ref))
34
34
  else
35
+ raise CouchbaseOrm::StrictLoadingViolationError, "#{self.class} is marked as strict_loading and #{assoc} cannot be lazily loaded." if strict_loading?
36
+
35
37
  assoc.constantize.find(self.send(ref), quiet: true)
36
38
  end
37
39
  instance_variable_set(instance_var, val)
@@ -55,11 +57,126 @@ module CouchbaseOrm
55
57
  end
56
58
  end
57
59
 
60
+ def has_and_belongs_to_many(name, **options)
61
+ @associations ||= []
62
+ @associations << [name.to_sym, options[:dependent]]
63
+
64
+ ref = options[:foreign_key] || :"#{name.to_s.singularize}_ids"
65
+ ref_ass = :"#{ref}="
66
+ instance_var = :"@__assoc_#{name}"
67
+
68
+ # Class reference
69
+ assoc = (options[:class_name] || name.to_s.singularize.camelize).to_s
70
+
71
+ # Create the local setter / getter
72
+ attribute(ref) { |value|
73
+ remove_instance_variable(instance_var) if instance_variable_defined?(instance_var)
74
+ value
75
+ }
76
+
77
+ # Define reader
78
+ define_method(name) do
79
+ return instance_variable_get(instance_var) if instance_variable_defined?(instance_var)
80
+ ref_value = self.send(ref)
81
+ ref_value = nil if ref_value.respond_to?(:empty?) && ref_value.empty?
82
+
83
+ val = if options[:polymorphic]
84
+ ::CouchbaseOrm.try_load(ref_value) if ref_value
85
+ else
86
+ raise CouchbaseOrm::StrictLoadingViolationError, "#{self.class} is marked as strict_loading and #{assoc} cannot be lazily loaded." if strict_loading?
87
+
88
+ assoc.constantize.find(ref_value) if ref_value
89
+ end
90
+ val = Array.wrap(val || [])
91
+ instance_variable_set(instance_var, val)
92
+ val
93
+ end
94
+
95
+ # Define writer
96
+ attr_writer name
97
+ define_method(:"#{name}=") do |value|
98
+ if value
99
+ if !options[:polymorphic]
100
+ klass = assoc.constantize
101
+ value.each do |v|
102
+ raise ArgumentError, "type mismatch on association: #{klass.design_document} != #{v.class.design_document}" if klass.design_document != v.class.design_document
103
+ end
104
+ end
105
+ self.send(ref_ass, value.map(&:id))
106
+ else
107
+ self.send(ref_ass, nil)
108
+ end
109
+
110
+ instance_variable_set(instance_var, value)
111
+ end
112
+
113
+ return unless options[:autosave]
114
+
115
+ save_method = :"autosave_associated_records_for_#{name}"
116
+
117
+ define_non_cyclic_method(save_method) do
118
+ old, new = previous_changes[ref]
119
+ adds = (new || []) - (old || [])
120
+ subs = (old || []) - (new || [])
121
+ update_has_and_belongs_to_many_reverse_association(assoc, adds, true, **options) if adds.any?
122
+ update_has_and_belongs_to_many_reverse_association(assoc, subs, false, **options) if subs.any?
123
+ end
124
+
125
+ after_create save_method
126
+ after_update save_method
127
+ end
128
+
58
129
  def associations
59
130
  @associations || []
60
131
  end
132
+
133
+ def define_non_cyclic_method(name, &block)
134
+ return if method_defined?(name)
135
+
136
+ define_method(name) do |*args|
137
+ result = true; @_already_called ||= {}
138
+ # Loop prevention for validation of associations
139
+ unless @_already_called[name]
140
+ begin
141
+ @_already_called[name] = true
142
+ result = instance_eval(&block)
143
+ ensure
144
+ @_already_called[name] = false
145
+ end
146
+ end
147
+ result
148
+ end
149
+ end
61
150
  end
62
151
 
152
+ def update_has_and_belongs_to_many_reverse_association(assoc, keys, is_add, **options)
153
+ remote_method = options[:inverse_of] || self.class.to_s.pluralize.underscore.to_sym
154
+ return if keys.empty?
155
+
156
+ models = if options[:polymorphic]
157
+ ::CouchbaseOrm.try_load(keys)
158
+ else
159
+ assoc.constantize.find(keys, quiet: true)
160
+ end
161
+ models = Array.wrap(models)
162
+ models.each do |v|
163
+ next unless v.respond_to?(remote_method)
164
+
165
+ tab = v.__send__(remote_method) || []
166
+ index = tab.find_index(self)
167
+ if is_add && !index
168
+ tab = tab.dup
169
+ tab.push(self)
170
+ elsif !is_add && index
171
+ tab = tab.dup
172
+ tab.delete_at(index)
173
+ else
174
+ next
175
+ end
176
+ v[remote_method] = tab
177
+ v.save!
178
+ end
179
+ end
63
180
 
64
181
  def destroy_associations!
65
182
  assoc = self.class.associations
@@ -72,6 +189,8 @@ module CouchbaseOrm
72
189
  when :destroy, :delete
73
190
  if model.respond_to?(:stream)
74
191
  model.stream { |mod| mod.__send__(dependent) }
192
+ elsif model.is_a?(Array) || model.is_a?(CouchbaseOrm::ResultsProxy)
193
+ model.each { |m| m.__send__(dependent) }
75
194
  else
76
195
  model.__send__(dependent)
77
196
  end
@@ -3,110 +3,198 @@
3
3
 
4
4
  require 'active_model'
5
5
  require 'active_support/hash_with_indifferent_access'
6
+ require 'couchbase'
7
+ require 'couchbase-orm/changeable'
8
+ require 'couchbase-orm/inspectable'
6
9
  require 'couchbase-orm/error'
7
10
  require 'couchbase-orm/views'
11
+ require 'couchbase-orm/n1ql'
8
12
  require 'couchbase-orm/persistence'
9
13
  require 'couchbase-orm/associations'
14
+ require 'couchbase-orm/types'
15
+ require 'couchbase-orm/relation'
16
+ require 'couchbase-orm/proxies/bucket_proxy'
17
+ require 'couchbase-orm/proxies/collection_proxy'
10
18
  require 'couchbase-orm/utilities/join'
11
19
  require 'couchbase-orm/utilities/enum'
12
20
  require 'couchbase-orm/utilities/index'
13
21
  require 'couchbase-orm/utilities/has_many'
14
22
  require 'couchbase-orm/utilities/ensure_unique'
15
-
23
+ require 'couchbase-orm/utilities/query_helper'
24
+ require 'couchbase-orm/utilities/ignored_properties'
25
+ require 'couchbase-orm/json_transcoder'
26
+ require 'couchbase-orm/timestamps'
27
+ require 'couchbase-orm/active_record_compat'
28
+ require 'couchbase-orm/strict_loading'
29
+ require 'couchbase-orm/json_schema/validation'
30
+ require 'couchbase-orm/utilities/properties_always_exists_in_document'
16
31
 
17
32
  module CouchbaseOrm
18
- class Base
33
+ class Document
34
+ include Inspectable
19
35
  include ::ActiveModel::Model
20
36
  include ::ActiveModel::Dirty
37
+ include Changeable # override some methods from ActiveModel::Dirty (keep it included after)
38
+ include ::ActiveModel::Attributes
21
39
  include ::ActiveModel::Serializers::JSON
22
40
 
23
41
  include ::ActiveModel::Validations
24
42
  include ::ActiveModel::Validations::Callbacks
43
+
44
+ include ActiveRecordCompat
45
+ include StrictLoading
46
+ include Encrypt
47
+
48
+ extend Enum
49
+
25
50
  define_model_callbacks :initialize, :only => :after
26
- define_model_callbacks :create, :destroy, :save, :update
27
51
 
52
+ Metadata = Struct.new(:cas)
53
+
54
+ class MismatchTypeError < RuntimeError; end
55
+
56
+ def initialize(model = nil, ignore_doc_type: false, **attributes)
57
+ CouchbaseOrm.logger.debug { "Initialize model #{model} with #{attributes.to_s.truncate(200)}" }
58
+ @__metadata__ = Metadata.new
59
+
60
+ super()
61
+
62
+ if model
63
+ case model
64
+ when Couchbase::Collection::GetResult
65
+ doc = HashWithIndifferentAccess.new(model.content) || raise('empty response provided')
66
+ type = doc.delete(:type)
67
+ doc.delete(:id)
68
+
69
+ if type && !ignore_doc_type && type.to_s != self.class.design_document
70
+ raise CouchbaseOrm::Error::TypeMismatchError.new("document type mismatch, #{type} != #{self.class.design_document}", self)
71
+ end
72
+
73
+ self.id = attributes[:id] if attributes[:id].present?
74
+ @__metadata__.cas = model.cas
75
+
76
+ assign_attributes(decode_encrypted_attributes(doc))
77
+ when CouchbaseOrm::Base
78
+ clear_changes_information
79
+ super(model.attributes.except(:id, 'type'))
80
+ else
81
+ clear_changes_information
82
+ assign_attributes(decode_encrypted_attributes(**attributes.merge(Hash(model)).symbolize_keys))
83
+ end
84
+ else
85
+ clear_changes_information
86
+ super(attributes)
87
+ end
88
+
89
+ yield self if block_given?
90
+
91
+ init_strict_loading
92
+ run_callbacks :initialize
93
+ end
94
+
95
+ def [](key)
96
+ send(key)
97
+ end
98
+
99
+ def []=(key, value)
100
+ send(:"#{key}=", value)
101
+ end
102
+
103
+ protected
104
+
105
+ def serialized_attributes
106
+ encode_encrypted_attributes.map { |k, v|
107
+ [k, self.class.attribute_types[k].serialize(v)]
108
+ }.to_h
109
+ end
110
+ end
111
+
112
+ class NestedDocument < Document
113
+ def initialize(*args, **kwargs)
114
+ super
115
+ if respond_to?(:id) && id.nil?
116
+ assign_attributes(id: SecureRandom.hex)
117
+ end
118
+ end
119
+ end
120
+
121
+ class Base < Document
122
+ define_model_callbacks :create, :destroy, :save, :update
28
123
  include Persistence
124
+
29
125
  include Associations
30
126
  include Views
127
+ include QueryHelper
128
+ include N1ql
129
+ include Relation
130
+ include Timestamps
31
131
 
32
132
  extend Join
33
133
  extend Enum
34
134
  extend EnsureUnique
35
135
  extend HasMany
36
136
  extend Index
137
+ extend IgnoredProperties
138
+ extend JsonSchema::Validation
139
+ extend PropertiesAlwaysExistsInDocument
37
140
 
38
141
 
39
- Metadata = Struct.new(:key, :cas)
142
+ class << self
40
143
 
144
+ def attribute(name, ...)
145
+ super
146
+ create_dirty_methods(name, name)
147
+ create_setters(name)
148
+ end
41
149
 
42
- class << self
43
150
  def connect(**options)
44
- @bucket = ::Libcouchbase::Bucket.new(**options)
151
+ @bucket = BucketProxy.new(::MTLibcouchbase::Bucket.new(**options))
45
152
  end
46
153
 
47
154
  def bucket=(bucket)
48
- @bucket = bucket
155
+ @bucket = bucket.is_a?(BucketProxy) ? bucket : BucketProxy.new(bucket)
49
156
  end
50
157
 
51
158
  def bucket
52
- @bucket ||= Connection.bucket
159
+ @bucket ||= BucketProxy.new(Connection.bucket)
53
160
  end
54
161
 
55
- def uuid_generator
56
- @uuid_generator ||= IdGenerator
162
+ def cluster
163
+ Connection.cluster
57
164
  end
58
165
 
59
- def uuid_generator=(generator)
60
- @uuid_generator = generator
166
+ def collection
167
+ CollectionProxy.new(bucket.default_collection)
61
168
  end
62
169
 
63
- def attribute(*names, **options)
64
- @attributes ||= {}
65
- names.each do |name|
66
- name = name.to_sym
67
-
68
- @attributes[name] = options
69
-
70
- unless self.instance_methods.include?(name)
71
- define_method(name) do
72
- read_attribute(name)
73
- end
74
- end
75
-
76
- eq_meth = :"#{name}="
77
- unless self.instance_methods.include?(eq_meth)
78
- define_method(eq_meth) do |value|
79
- value = yield(value) if block_given?
80
- write_attribute(name, value)
81
- end
82
- end
83
- end
170
+ def uuid_generator
171
+ @uuid_generator ||= IdGenerator
84
172
  end
85
173
 
86
- def attributes
87
- @attributes ||= {}
174
+ def uuid_generator=(generator)
175
+ @uuid_generator = generator
88
176
  end
89
177
 
90
- def find(*ids, **options)
91
- options[:extended] = true
92
- options[:quiet] ||= false
178
+ def find(*ids, quiet: false, with_strict_loading: false)
179
+ CouchbaseOrm.logger.debug { "Base.find(l##{ids.length}) #{ids}" }
93
180
 
94
181
  ids = ids.flatten.select { |id| id.present? }
95
182
  if ids.empty?
96
- return nil if options[:quiet]
97
- raise Libcouchbase::Error::EmptyKey, 'no id(s) provided'
183
+ raise CouchbaseOrm::Error::EmptyNotAllowed, 'no id(s) provided'
98
184
  end
99
185
 
100
- record = bucket.get(*ids, **options)
101
- records = record.is_a?(Array) ? record : [record]
102
- records.map! { |record|
103
- if record
104
- self.new(record)
105
- else
106
- false
107
- end
108
- }
109
- records.select! { |rec| rec }
186
+ transcoder = CouchbaseOrm::JsonTranscoder.new(ignored_properties: ignored_properties)
187
+ records = quiet ? collection.get_multi(ids, transcoder: transcoder) : collection.get_multi!(ids, transcoder: transcoder)
188
+ CouchbaseOrm.logger.debug { "Base.find found(#{records})" }
189
+ records = records.zip(ids).map { |record, id|
190
+ next unless record
191
+ next if record.error
192
+ new(record, id: id).tap do |instance|
193
+ if with_strict_loading
194
+ instance.strict_loading!
195
+ end
196
+ end.tap(&:reset_object!)
197
+ }.compact
110
198
  ids.length > 1 ? records : records[0]
111
199
  end
112
200
 
@@ -117,124 +205,18 @@ module CouchbaseOrm
117
205
  alias_method :[], :find_by_id
118
206
 
119
207
  def exists?(id)
120
- !bucket.get(id, quiet: true).nil?
208
+ CouchbaseOrm.logger.debug { "Data - Exists? #{id}" }
209
+ collection.exists(id).exists
121
210
  end
122
211
  alias_method :has_key?, :exists?
123
212
  end
124
213
 
125
-
126
- # Add support for libcouchbase response objects
127
- def initialize(model = nil, ignore_doc_type: false, **attributes)
128
- @__metadata__ = Metadata.new
129
-
130
- # Assign default values
131
- @__attributes__ = ::ActiveSupport::HashWithIndifferentAccess.new({type: self.class.design_document})
132
- self.class.attributes.each do |key, options|
133
- default = options[:default]
134
- if default.respond_to?(:call)
135
- write_attribute key, default.call
136
- else
137
- write_attribute key, default
138
- end
139
- end
140
-
141
- if model
142
- case model
143
- when ::Libcouchbase::Response
144
- doc = model.value || raise('empty response provided')
145
- type = doc.delete(:type)
146
- doc.delete(:id)
147
-
148
- if type && !ignore_doc_type && type.to_s != self.class.design_document
149
- raise "document type mismatch, #{type} != #{self.class.design_document}"
150
- end
151
-
152
- @__metadata__.key = model.key
153
- @__metadata__.cas = model.cas
154
-
155
- # This ensures that defaults are applied
156
- @__attributes__.merge! doc
157
- clear_changes_information
158
- when CouchbaseOrm::Base
159
- clear_changes_information
160
- attributes = model.attributes
161
- attributes.delete(:id)
162
- super(attributes)
163
- else
164
- clear_changes_information
165
- super(attributes.merge(Hash(model)))
166
- end
167
- else
168
- clear_changes_information
169
- super(attributes)
170
- end
171
-
172
- yield self if block_given?
173
-
174
- run_callbacks :initialize
175
- end
176
-
177
-
178
- # Document ID is a special case as it is not stored in the document
179
- def id
180
- @__metadata__.key || @id
181
- end
182
-
183
214
  def id=(value)
184
- raise 'ID cannot be changed' if @__metadata__.cas
215
+ raise RuntimeError, 'ID cannot be changed' if @__metadata__.cas && value
185
216
  attribute_will_change!(:id)
186
- @id = value.to_s
187
- end
188
-
189
- def read_attribute(attr_name)
190
- @__attributes__[attr_name]
191
- end
192
- alias_method :[], :read_attribute
193
-
194
- def write_attribute(attr_name, value)
195
- unless value.nil?
196
- coerce = self.class.attributes[attr_name][:type]
197
- value = Kernel.send(coerce.to_s, value) if coerce
198
- end
199
- attribute_will_change!(attr_name) unless @__attributes__[attr_name] == value
200
- @__attributes__[attr_name] = value
201
- end
202
- alias_method :[]=, :write_attribute
203
-
204
- #
205
- # Add support for Serialization:
206
- # http://guides.rubyonrails.org/active_model_basics.html#serialization
207
- #
208
-
209
- def attributes
210
- copy = @__attributes__.merge({id: id})
211
- copy.delete(:type)
212
- copy
213
- end
214
-
215
- def attributes=(attributes)
216
- attributes.each do |key, value|
217
- setter = :"#{key}="
218
- send(setter, value) if respond_to?(setter)
219
- end
220
- end
221
-
222
- ID_LOOKUP = ['id', :id].freeze
223
- def attribute(name)
224
- return self.id if ID_LOOKUP.include?(name)
225
- @__attributes__[name]
226
- end
227
- alias_method :read_attribute_for_serialization, :attribute
228
-
229
- def attribute=(name, value)
230
- __send__(:"#{name}=", value)
217
+ _write_attribute("id", value)
231
218
  end
232
219
 
233
-
234
- #
235
- # Add support for comparisons
236
- #
237
-
238
220
  # Public: Allows for access to ActiveModel functionality.
239
221
  #
240
222
  # Returns self.
@@ -267,12 +249,7 @@ module CouchbaseOrm
267
249
  #
268
250
  # Returns a boolean.
269
251
  def ==(other)
270
- case other
271
- when self.class
272
- hash == other.hash
273
- else
274
- false
275
- end
252
+ super || other.instance_of?(self.class) && !id.nil? && other.id == id
276
253
  end
277
254
  end
278
255
  end