couchbase-orm 0.0.1

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.
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+
4
+ require 'active_model'
5
+ require 'active_support/hash_with_indifferent_access'
6
+ require 'couchbase-orm/error'
7
+ require 'couchbase-orm/views'
8
+ require 'couchbase-orm/persistence'
9
+ require 'couchbase-orm/associations'
10
+ require 'couchbase-orm/utilities/join'
11
+ require 'couchbase-orm/utilities/enum'
12
+ require 'couchbase-orm/utilities/index'
13
+ require 'couchbase-orm/utilities/has_many'
14
+ require 'couchbase-orm/utilities/ensure_unique'
15
+
16
+
17
+ module CouchbaseOrm
18
+ class Base
19
+ include ::ActiveModel::Model
20
+ include ::ActiveModel::Dirty
21
+ include ::ActiveModel::Serializers::JSON
22
+
23
+ extend ::ActiveModel::Callbacks
24
+ define_model_callbacks :initialize, :only => :after
25
+ define_model_callbacks :create, :destroy, :save, :update
26
+
27
+ include Persistence
28
+ include Associations
29
+ include Views
30
+
31
+ extend Join
32
+ extend Enum
33
+ extend EnsureUnique
34
+ extend HasMany
35
+ extend Index
36
+
37
+
38
+ Metadata = Struct.new(:key, :cas)
39
+
40
+
41
+ class << self
42
+ def connect(**options)
43
+ @bucket = ::Libcouchbase::Bucket.new(**options)
44
+ end
45
+
46
+ def bucket=(bucket)
47
+ @bucket = bucket
48
+ end
49
+
50
+ def bucket
51
+ @bucket ||= Connection.bucket
52
+ end
53
+
54
+ at_exit do
55
+ # This will disconnect the database connection
56
+ @bucket = nil
57
+ end
58
+
59
+ def uuid_generator
60
+ @uuid_generator ||= IdGenerator
61
+ end
62
+
63
+ def uuid_generator=(generator)
64
+ @uuid_generator = generator
65
+ end
66
+
67
+ def attribute(*names, **options)
68
+ @attributes ||= {}
69
+ names.each do |name|
70
+ name = name.to_sym
71
+
72
+ @attributes[name] = options
73
+ next if self.instance_methods.include?(name)
74
+
75
+ define_method(name) do
76
+ read_attribute(name)
77
+ end
78
+
79
+ define_method(:"#{name}=") do |value|
80
+ value = yield(value) if block_given?
81
+ write_attribute(name, value)
82
+ end
83
+ end
84
+ end
85
+
86
+ def attributes
87
+ @attributes ||= {}
88
+ end
89
+
90
+ def find(*ids, **options)
91
+ options[:extended] = true
92
+ options[:quiet] ||= false
93
+
94
+ ids = ids.flatten
95
+ records = bucket.get(*ids, **options)
96
+
97
+ records = records.is_a?(Array) ? records : [records]
98
+ records.map! { |record|
99
+ if record
100
+ self.new(record)
101
+ else
102
+ false
103
+ end
104
+ }
105
+ records.select! { |rec| rec }
106
+ ids.length > 1 ? records : records[0]
107
+ end
108
+
109
+ def find_by_id(*ids, **options)
110
+ options[:quiet] = true
111
+ find *ids, **options
112
+ end
113
+ alias_method :[], :find_by_id
114
+
115
+ def exists?(id)
116
+ !bucket.get(id, quiet: true).nil?
117
+ end
118
+ alias_method :has_key?, :exists?
119
+ end
120
+
121
+
122
+ # Add support for libcouchbase response objects
123
+ def initialize(model = nil, ignore_doc_type: false, **attributes)
124
+ @__metadata__ = Metadata.new
125
+
126
+ # Assign default values
127
+ @__attributes__ = ::ActiveSupport::HashWithIndifferentAccess.new({type: self.class.design_document})
128
+ self.class.attributes.each do |key, options|
129
+ default = options[:default]
130
+ if default.respond_to?(:call)
131
+ @__attributes__[key] = default.call
132
+ else
133
+ @__attributes__[key] = default
134
+ end
135
+ end
136
+
137
+ if model
138
+ case model
139
+ when ::Libcouchbase::Response
140
+ doc = model.value || raise('empty response provided')
141
+ type = doc.delete(:type)
142
+ doc.delete(:id)
143
+
144
+ if type && !ignore_doc_type && type.to_s != self.class.design_document
145
+ raise "document type mismatch, #{type} != #{self.class.design_document}"
146
+ end
147
+
148
+ @__metadata__.key = model.key
149
+ @__metadata__.cas = model.cas
150
+
151
+ # This ensures that defaults are applied
152
+ super(**doc)
153
+ when CouchbaseOrm::Base
154
+ attributes = model.attributes
155
+ attributes.delete(:id)
156
+ super(**attributes)
157
+ else
158
+ super(**attributes.merge(Hash(model)))
159
+ end
160
+ else
161
+ super(**attributes)
162
+ end
163
+
164
+ yield self if block_given?
165
+
166
+ run_callbacks :initialize
167
+ end
168
+
169
+
170
+ # Document ID is a special case as it is not stored in the document
171
+ def id
172
+ @__metadata__.key || @id
173
+ end
174
+
175
+ def id=(value)
176
+ raise 'ID cannot be changed' if @__metadata__.cas
177
+ attribute_will_change!(:id)
178
+ @id = value.to_s
179
+ end
180
+
181
+ def read_attribute(attr_name)
182
+ @__attributes__[attr_name]
183
+ end
184
+ alias_method :[], :read_attribute
185
+
186
+ def write_attribute(attr_name, value)
187
+ unless value.nil?
188
+ coerce = self.class.attributes[attr_name][:type]
189
+ value = Kernel.send(coerce.to_s, value) if coerce
190
+ end
191
+ attribute_will_change!(attr_name) unless @__attributes__[attr_name] == value
192
+ @__attributes__[attr_name] = value
193
+ end
194
+ alias_method :[]=, :write_attribute
195
+
196
+ #
197
+ # Add support for Serialization:
198
+ # http://guides.rubyonrails.org/active_model_basics.html#serialization
199
+ #
200
+
201
+ def attributes
202
+ copy = @__attributes__.merge({id: id})
203
+ copy.delete(:type)
204
+ copy
205
+ end
206
+
207
+ def attributes=(attributes)
208
+ attributes.each do |key, value|
209
+ setter = :"#{key}="
210
+ send(setter, value) if respond_to?(setter)
211
+ end
212
+ end
213
+
214
+
215
+ #
216
+ # Add support for comparisons
217
+ #
218
+
219
+ # Public: Allows for access to ActiveModel functionality.
220
+ #
221
+ # Returns self.
222
+ def to_model
223
+ self
224
+ end
225
+
226
+ # Public: Hashes identifying properties of the instance
227
+ #
228
+ # Ruby normally hashes an object to be used in comparisons. In our case
229
+ # we may have two techincally different objects referencing the same entity id.
230
+ #
231
+ # Returns a string representing the unique key.
232
+ def hash
233
+ "#{self.class.name}-#{self.id}-#{@__metadata__.cas}-#{@__attributes__.hash}".hash
234
+ end
235
+
236
+ # Public: Overrides eql? to use == in the comparison.
237
+ #
238
+ # other - Another object to compare to
239
+ #
240
+ # Returns a boolean.
241
+ def eql?(other)
242
+ self == other
243
+ end
244
+
245
+ # Public: Overrides == to compare via class and entity id.
246
+ #
247
+ # other - Another object to compare to
248
+ #
249
+ # Returns a boolean.
250
+ def ==(other)
251
+ hash == other.hash
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ require 'libcouchbase'
4
+
5
+ module CouchbaseOrm
6
+ class Connection
7
+ @options = {}
8
+ class << self
9
+ attr_accessor :options
10
+ end
11
+
12
+ def self.bucket
13
+ @bucket ||= ::Libcouchbase::Bucket.new(**@options)
14
+ end
15
+
16
+ # This will disconnect the database connection,
17
+ # allowing the application to exit
18
+ at_exit do
19
+ @bucket = nil
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ module CouchbaseOrm
4
+ class Error < ::StandardError
5
+ attr_reader :record
6
+
7
+ def initialize(message = nil, record = nil)
8
+ @record = record
9
+ super(message)
10
+ end
11
+
12
+ class RecordInvalid < Error; end
13
+ class RecordExists < Error; end
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ require 'radix/base'
4
+
5
+ module CouchbaseOrm
6
+ class IdGenerator
7
+ # Using base 65 as a form of compression (reduced length of ID string)
8
+ # No escape characters are required to display these in a URL
9
+ B65 = ::Radix::Base.new(::Radix::BASE::B62 + ['-', '_', '~'])
10
+ B10 = ::Radix::Base.new(10)
11
+
12
+ # We don't really care about dates before this library was created
13
+ # This reduces the length of the ID significantly
14
+ Skip46Years = 1451649600 # 46.years.to_i
15
+
16
+ # Generate a unique, orderable, ID using minimal bytes
17
+ def self.next(model)
18
+ # We are unlikely to see a clash here
19
+ now = Time.now
20
+ time = (now.to_i - Skip46Years) * 1_000_000 + now.usec
21
+
22
+ # This makes it very very improbable that there will ever be an ID clash
23
+ # Distributed system safe!
24
+ prefix = time.to_s
25
+ tail = (rand(9999) + 1).to_s.rjust(4, '0')
26
+
27
+ "#{model.class.design_document}-#{Radix.convert("#{prefix}#{tail}", B10, B65)}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ require 'active_model'
4
+ require 'active_support/hash_with_indifferent_access'
5
+
6
+ module CouchbaseOrm
7
+ module Persistence
8
+ extend ActiveSupport::Concern
9
+
10
+
11
+ module ClassMethods
12
+ def create(attributes = nil, &block)
13
+ if attributes.is_a?(Array)
14
+ attributes.collect { |attr| create(attr, &block) }
15
+ else
16
+ instance = new(attributes, &block)
17
+ instance.save
18
+ instance
19
+ end
20
+ end
21
+
22
+ def create!(attributes = nil, &block)
23
+ if attributes.is_a?(Array)
24
+ attributes.collect { |attr| create!(attr, &block) }
25
+ else
26
+ instance = new(attributes, &block)
27
+ instance.save!
28
+ instance
29
+ end
30
+ end
31
+
32
+ # Raise an error if validation failed.
33
+ def fail_validate!(document)
34
+ raise Error::RecordInvalid.new("Failed to save the record", document)
35
+ end
36
+
37
+ # Allow classes to overwrite the default document name
38
+ # extend ActiveModel::Naming (included by ActiveModel::Model)
39
+ def design_document(name = nil)
40
+ return @design_document unless name
41
+ @design_document = name.to_s
42
+ end
43
+
44
+ # Set a default design document
45
+ def inherited(child)
46
+ super
47
+ child.instance_eval do
48
+ @design_document = child.name.underscore
49
+ end
50
+ end
51
+ end
52
+
53
+
54
+ # Returns true if this object hasn't been saved yet -- that is, a record
55
+ # for the object doesn't exist in the database yet; otherwise, returns false.
56
+ def new_record?
57
+ @__metadata__.cas.nil? && @__metadata__.key.nil?
58
+ end
59
+ alias_method :new?, :new_record?
60
+
61
+ # Returns true if this object has been destroyed, otherwise returns false.
62
+ def destroyed?
63
+ !!(@__metadata__.cas && @__metadata__.key.nil?)
64
+ end
65
+
66
+ # Returns true if the record is persisted, i.e. it's not a new record and it was
67
+ # not destroyed, otherwise returns false.
68
+ def persisted?
69
+ # Changed? is provided by ActiveModel::Dirty
70
+ !!@__metadata__.key
71
+ end
72
+ alias_method :exists?, :persisted?
73
+
74
+ # Saves the model.
75
+ #
76
+ # If the model is new, a record gets created in the database, otherwise
77
+ # the existing record gets updated.
78
+ def save(**options)
79
+ raise "Cannot save a destroyed document!" if destroyed?
80
+ self.new_record? ? _create_record(**options) : _update_record(**options)
81
+ end
82
+
83
+ # Saves the model.
84
+ #
85
+ # If the model is new, a record gets created in the database, otherwise
86
+ # the existing record gets updated.
87
+ #
88
+ # By default, #save! always runs validations. If any of them fail
89
+ # CouchbaseOrm::Error::RecordInvalid gets raised, and the record won't be saved.
90
+ def save!
91
+ self.class.fail_validate!(self) unless self.save
92
+ self
93
+ end
94
+
95
+ # Deletes the record in the database and freezes this instance to
96
+ # reflect that no changes should be made (since they can't be
97
+ # persisted). Returns the frozen instance.
98
+ #
99
+ # The record is simply removed, no callbacks are executed.
100
+ def delete(with_cas: false, **options)
101
+ options[:cas] = @__metadata__.cas if with_cas
102
+ self.class.bucket.delete(@__metadata__.key, options)
103
+
104
+ @__metadata__.key = nil
105
+ @id = nil
106
+
107
+ clear_changes_information
108
+ self.freeze
109
+ self
110
+ end
111
+
112
+ # Deletes the record in the database and freezes this instance to reflect
113
+ # that no changes should be made (since they can't be persisted).
114
+ #
115
+ # There's a series of callbacks associated with #destroy.
116
+ def destroy(with_cas: false, **options)
117
+ return self if destroyed?
118
+ raise 'model not persisted' unless persisted?
119
+
120
+ run_callbacks :destroy do
121
+ destroy_associations!
122
+
123
+ options[:cas] = @__metadata__.cas if with_cas
124
+ self.class.bucket.delete(@__metadata__.key, options)
125
+
126
+ @__metadata__.key = nil
127
+ @id = nil
128
+
129
+ clear_changes_information
130
+ freeze
131
+ end
132
+ end
133
+ alias_method :destroy!, :destroy
134
+
135
+ # Updates a single attribute and saves the record.
136
+ # This is especially useful for boolean flags on existing records. Also note that
137
+ #
138
+ # * Validation is skipped.
139
+ # * \Callbacks are invoked.
140
+ def update_attribute(name, value)
141
+ public_send(:"#{name}=", value)
142
+ changed? ? save(validate: false) : true
143
+ end
144
+
145
+ # Updates the attributes of the model from the passed-in hash and saves the
146
+ # record. If the object is invalid, the saving will fail and false will be returned.
147
+ def update(hash)
148
+ assign_attributes(hash)
149
+ save
150
+ end
151
+ alias_method :update_attributes, :update
152
+
153
+ # Updates its receiver just like #update but calls #save! instead
154
+ # of +save+, so an exception is raised if the record is invalid and saving will fail.
155
+ def update!(hash)
156
+ assign_attributes(hash) # Assign attributes is provided by ActiveModel::AttributeAssignment
157
+ save!
158
+ end
159
+ alias_method :update_attributes!, :update!
160
+
161
+ # Reloads the record from the database.
162
+ #
163
+ # This method finds record by its key and modifies the receiver in-place:
164
+ def reload
165
+ key = @__metadata__.key
166
+ raise "unable to reload, model not persisted" unless key
167
+
168
+ resp = self.class.bucket.get(key, quiet: false, extended: true)
169
+ @__attributes__ = ::ActiveSupport::HashWithIndifferentAccess.new(resp.value)
170
+ @__metadata__.key = resp.key
171
+ @__metadata__.cas = resp.cas
172
+
173
+ reset_associations
174
+ clear_changes_information
175
+ self
176
+ end
177
+
178
+ # Updates the TTL of the document
179
+ def touch(**options)
180
+ res = self.class.bucket.touch(@__metadata__.key, async: false, **options)
181
+ @__metadata__.cas = resp.cas
182
+ self
183
+ end
184
+
185
+
186
+ protected
187
+
188
+
189
+ def _update_record(with_cas: false, **options)
190
+ return false unless perform_validations(options)
191
+ return true unless changed?
192
+
193
+ run_callbacks :update do
194
+ run_callbacks :save do
195
+ # Ensure the type is set
196
+ @__attributes__[:type] = self.class.design_document
197
+ @__attributes__.delete(:id)
198
+
199
+ _id = @__metadata__.key
200
+ options[:cas] = @__metadata__.cas if with_cas
201
+ resp = self.class.bucket.replace(_id, @__attributes__, **options)
202
+
203
+ # Ensure the model is up to date
204
+ @__metadata__.key = resp.key
205
+ @__metadata__.cas = resp.cas
206
+
207
+ clear_changes_information
208
+ true
209
+ end
210
+ end
211
+ end
212
+
213
+ def _create_record(**options)
214
+ return false unless perform_validations(options)
215
+
216
+ run_callbacks :create do
217
+ run_callbacks :save do
218
+ # Ensure the type is set
219
+ @__attributes__[:type] = self.class.design_document
220
+ @__attributes__.delete(:id)
221
+
222
+ _id = @id || self.class.uuid_generator.next(self)
223
+ resp = self.class.bucket.add(_id, @__attributes__, **options)
224
+
225
+ # Ensure the model is up to date
226
+ @__metadata__.key = resp.key
227
+ @__metadata__.cas = resp.cas
228
+
229
+ clear_changes_information
230
+ true
231
+ end
232
+ end
233
+ end
234
+
235
+ def perform_validations(options = {})
236
+ return valid? if options[:validate] != false
237
+ true
238
+ end
239
+ end
240
+ end