couchbase-orm 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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