couchbase-orm 1.1.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +45 -0
  3. data/.gitignore +2 -0
  4. data/.travis.yml +3 -2
  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 +61 -52
  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 +18 -20
  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 +13 -9
  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 +32 -11
  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