activemodel-datastore 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,39 @@
1
+ ##
2
+ # Returns a Google::Cloud::Datastore::Dataset object for the configured dataset.
3
+ #
4
+ # The dataset instance is used to create, read, update, and delete entity objects.
5
+ #
6
+ # GCLOUD_PROJECT is an environment variable representing the Datastore project ID.
7
+ # DATASTORE_KEYFILE_JSON is an environment variable that Datastore checks for credentials.
8
+ #
9
+ # ENV['GCLOUD_KEYFILE_JSON'] = '{
10
+ # "private_key": "-----BEGIN PRIVATE KEY-----\nMIIFfb3...5dmFtABy\n-----END PRIVATE KEY-----\n",
11
+ # "client_email": "web-app@app-name.iam.gserviceaccount.com"
12
+ # }'
13
+ #
14
+ module CloudDatastore
15
+ if defined?(Rails) == 'constant'
16
+ if Rails.env.development?
17
+ ENV['DATASTORE_EMULATOR_HOST'] = 'localhost:8180'
18
+ ENV['GCLOUD_PROJECT'] = 'local-datastore'
19
+ elsif Rails.env.test?
20
+ ENV['DATASTORE_EMULATOR_HOST'] = 'localhost:8181'
21
+ ENV['GCLOUD_PROJECT'] = 'test-datastore'
22
+ elsif ENV['SERVICE_ACCOUNT_PRIVATE_KEY'].present? &&
23
+ ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'].present?
24
+ ENV['GCLOUD_KEYFILE_JSON'] = '{"private_key": "' + ENV['SERVICE_ACCOUNT_PRIVATE_KEY'] + '",
25
+ "client_email": "' + ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'] + '"}'
26
+ end
27
+ else
28
+ ENV['DATASTORE_EMULATOR_HOST'] = 'localhost:8181'
29
+ ENV['GCLOUD_PROJECT'] = 'test-datastore'
30
+ end
31
+
32
+ def self.dataset
33
+ @dataset ||= Google::Cloud.datastore(ENV['GCLOUD_PROJECT'])
34
+ end
35
+
36
+ def self.reset_dataset
37
+ @dataset = nil
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ module ActiveModel::Datastore
2
+ ##
3
+ # Generic Active Model Cloud Datastore exception class.
4
+ #
5
+ class Error < StandardError
6
+ end
7
+
8
+ ##
9
+ # Raised while attempting to save an invalid entity.
10
+ #
11
+ class EntityNotSavedError < Error
12
+ end
13
+
14
+ ##
15
+ # Raised when an entity is not configured for tracking changes.
16
+ #
17
+ class TrackChangesError < Error
18
+ end
19
+
20
+ ##
21
+ # Raised when unable to find an entity by given id or set of ids.
22
+ #
23
+ class EntityError < Error
24
+ end
25
+ end
@@ -0,0 +1,260 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+
3
+ module ActiveModel::Datastore
4
+ ##
5
+ # = ActiveModel Datastore Nested Attributes
6
+ #
7
+ # Adds support for nested attributes to ActiveModel. Heavily inspired by Rails
8
+ # ActiveRecord::NestedAttributes.
9
+ #
10
+ # Nested attributes allow you to save attributes on associated records along with the parent.
11
+ # It's used in conjunction with fields_for to build the nested form elements.
12
+ #
13
+ # See Rails ActionView::Helpers::FormHelper::fields_for for more info.
14
+ #
15
+ # *NOTE*: Unlike ActiveRecord, the way that the relationship is modeled between the parent and
16
+ # child is not enforced. With NoSQL the relationship could be defined by any attribute, or with
17
+ # denormalization exist within the same entity. This library provides a way for the objects to
18
+ # be associated yet saved to the datastore in any way that you choose.
19
+ #
20
+ # You enable nested attributes by defining an +:attr_accessor+ on the parent with the pluralized
21
+ # name of the child model.
22
+ #
23
+ # Nesting also requires that a +<association_name>_attributes=+ writer method is defined in your
24
+ # parent model. If an object with an association is instantiated with a params hash, and that
25
+ # hash has a key for the association, Rails will call the +<association_name>_attributes=+
26
+ # method on that object. Within the writer method call +assign_nested_attributes+, passing in
27
+ # the association name and attributes.
28
+ #
29
+ # Let's say we have a parent Recipe with Ingredient children.
30
+ #
31
+ # Start by defining within the Recipe model:
32
+ # * an attr_accessor of +:ingredients+
33
+ # * a writer method named +ingredients_attributes=+
34
+ # * the +validates_associated+ method can be used to validate the nested objects
35
+ #
36
+ # Example:
37
+ # class Recipe
38
+ # attr_accessor :ingredients
39
+ # validates :ingredients, presence: true
40
+ # validates_associated :ingredients
41
+ #
42
+ # def ingredients_attributes=(attributes)
43
+ # assign_nested_attributes(:ingredients, attributes)
44
+ # end
45
+ # end
46
+ #
47
+ # You may also set a +:reject_if+ proc to silently ignore any new record hashes if they fail to
48
+ # pass your criteria. For example:
49
+ #
50
+ # class Recipe
51
+ # def ingredients_attributes=(attributes)
52
+ # reject_proc = proc { |attributes| attributes['name'].blank? }
53
+ # assign_nested_attributes(:ingredients, attributes, reject_if: reject_proc)
54
+ # end
55
+ # end
56
+ #
57
+ # Alternatively, +:reject_if+ also accepts a symbol for using methods:
58
+ #
59
+ # class Recipe
60
+ # def ingredients_attributes=(attributes)
61
+ # reject_proc = proc { |attributes| attributes['name'].blank? }
62
+ # assign_nested_attributes(:ingredients, attributes, reject_if: reject_recipes)
63
+ # end
64
+ #
65
+ # def reject_recipes(attributes)
66
+ # attributes['name'].blank?
67
+ # end
68
+ # end
69
+ #
70
+ # Within the parent model +valid?+ will validate the parent and associated children and
71
+ # +nested_models+ will return the child objects. If the nested form submitted params contained
72
+ # a truthy +_destroy+ key, the appropriate nested_models will have +marked_for_destruction+ set
73
+ # to True.
74
+ #
75
+ # Created by Bryce McLean on 2016-12-06.
76
+ #
77
+ module NestedAttr
78
+ extend ActiveSupport::Concern
79
+ include ActiveModel::Model
80
+
81
+ included do
82
+ attr_accessor :nested_attributes, :marked_for_destruction, :_destroy
83
+ end
84
+
85
+ def mark_for_destruction
86
+ @marked_for_destruction = true
87
+ end
88
+
89
+ def marked_for_destruction?
90
+ @marked_for_destruction
91
+ end
92
+
93
+ def nested_attributes?
94
+ nested_attributes.is_a?(Array) && !nested_attributes.empty?
95
+ end
96
+
97
+ ##
98
+ # For each attribute name in nested_attributes extract and return the nested model objects.
99
+ #
100
+ def nested_models
101
+ model_entities = []
102
+ nested_attributes.each { |attr| model_entities << send(attr.to_sym) } if nested_attributes?
103
+ model_entities.flatten
104
+ end
105
+
106
+ def nested_model_class_names
107
+ entity_kinds = []
108
+ if nested_attributes?
109
+ nested_models.each { |x| entity_kinds << x.class.name }
110
+ end
111
+ entity_kinds.uniq
112
+ end
113
+
114
+ def nested_errors
115
+ errors = []
116
+ if nested_attributes?
117
+ nested_attributes.each do |attr|
118
+ send(attr.to_sym).each { |child| errors << child.errors }
119
+ end
120
+ end
121
+ errors
122
+ end
123
+
124
+ ##
125
+ # Assigns the given nested child attributes.
126
+ #
127
+ # Attribute hashes with an +:id+ value matching an existing associated object will update
128
+ # that object. Hashes without an +:id+ value will build a new object for the association.
129
+ # Hashes with a matching +:id+ value and a +:_destroy+ key set to a truthy value will mark
130
+ # the matched object for destruction.
131
+ #
132
+ # Pushes a key of the association name onto the parent object's +nested_attributes+ attribute.
133
+ # The +nested_attributes+ can be used for determining when the parent has associated children.
134
+ #
135
+ # @param [Symbol] association_name The attribute name of the associated children.
136
+ # @param [ActiveSupport::HashWithIndifferentAccess, ActionController::Parameters] attributes
137
+ # The attributes provided by Rails ActionView. Typically new objects will arrive as
138
+ # ActiveSupport::HashWithIndifferentAccess and updates as ActionController::Parameters.
139
+ # @param [Hash] options The options to control how nested attributes are applied.
140
+ #
141
+ # @option options [Proc, Symbol] :reject_if Allows you to specify a Proc or a Symbol pointing
142
+ # to a method that checks whether a record should be built for a certain attribute
143
+ # hash. The hash is passed to the supplied Proc or the method and it should return either
144
+ # +true+ or +false+. Passing +:all_blank+ instead of a Proc will create a proc
145
+ # that will reject a record where all the attributes are blank.
146
+ #
147
+ # The following example will update the amount of the ingredient with ID 1, build a new
148
+ # associated ingredient with the amount of 45, and mark the associated ingredient
149
+ # with ID 2 for destruction.
150
+ #
151
+ # assign_nested_attributes(:ingredients, {
152
+ # '0' => { id: '1', amount: '123' },
153
+ # '1' => { amount: '45' },
154
+ # '2' => { id: '2', _destroy: true }
155
+ # })
156
+ #
157
+ def assign_nested_attributes(association_name, attributes, options = {})
158
+ attributes = validate_attributes(attributes)
159
+ association_name = association_name.to_sym
160
+ send("#{association_name}=", []) if send(association_name).nil?
161
+
162
+ attributes.each do |_i, params|
163
+ if params['id'].blank?
164
+ unless reject_new_record?(params, options)
165
+ new = association_name.to_c.new(params.except(*UNASSIGNABLE_KEYS))
166
+ send(association_name).push(new)
167
+ end
168
+ else
169
+ existing = send(association_name).detect { |record| record.id.to_s == params['id'].to_s }
170
+ assign_to_or_mark_for_destruction(existing, params)
171
+ end
172
+ end
173
+ (self.nested_attributes ||= []).push(association_name)
174
+ end
175
+
176
+ private
177
+
178
+ UNASSIGNABLE_KEYS = %w[id _destroy].freeze
179
+
180
+ def validate_attributes(attributes)
181
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
182
+ unless attributes.is_a?(Hash)
183
+ raise ArgumentError, "Hash expected, got #{attributes.class.name} (#{attributes.inspect})"
184
+ end
185
+ attributes
186
+ end
187
+
188
+ ##
189
+ # Updates an object with attributes or marks it for destruction if has_destroy_flag?.
190
+ #
191
+ def assign_to_or_mark_for_destruction(record, attributes)
192
+ record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS))
193
+ record.mark_for_destruction if destroy_flag?(attributes)
194
+ end
195
+
196
+ ##
197
+ # Determines if a hash contains a truthy _destroy key.
198
+ #
199
+ def destroy_flag?(hash)
200
+ [true, 1, '1', 't', 'T', 'true', 'TRUE'].include?(hash['_destroy'])
201
+ end
202
+
203
+ ##
204
+ # Determines if a new record should be rejected by checking if a <tt>:reject_if</tt> option
205
+ # exists and evaluates to +true+.
206
+ #
207
+ def reject_new_record?(attributes, options)
208
+ call_reject_if(attributes, options)
209
+ end
210
+
211
+ ##
212
+ # Determines if a record with the particular +attributes+ should be rejected by calling the
213
+ # reject_if Symbol or Proc (if provided in options).
214
+ #
215
+ # Returns false if there is a +destroy_flag+ on the attributes.
216
+ #
217
+ def call_reject_if(attributes, options)
218
+ return false if destroy_flag?(attributes)
219
+ attributes = attributes.with_indifferent_access
220
+ blank_proc = proc { |attrs| attrs.all? { |_key, value| value.blank? } }
221
+ options[:reject_if] = blank_proc if options[:reject_if] == :all_blank
222
+ case callback = options[:reject_if]
223
+ when Symbol
224
+ method(callback).arity.zero? ? send(callback) : send(callback, attributes)
225
+ when Proc
226
+ callback.call(attributes)
227
+ else
228
+ false
229
+ end
230
+ end
231
+
232
+ # Methods defined here will be class methods whenever we 'include DatastoreUtils'.
233
+ module ClassMethods
234
+ ##
235
+ # Validates whether the associated object or objects are all valid, typically used with
236
+ # nested attributes such as multi-model forms.
237
+ #
238
+ # NOTE: This validation will not fail if the association hasn't been assigned. If you want
239
+ # to ensure that the association is both present and guaranteed to be valid, you also need
240
+ # to use validates_presence_of.
241
+ #
242
+ def validates_associated(*attr_names)
243
+ validates_with AssociatedValidator, _merge_attributes(attr_names)
244
+ end
245
+ end
246
+
247
+ class AssociatedValidator < ActiveModel::EachValidator
248
+ def validate_each(record, attribute, value)
249
+ return unless Array(value).reject(&:valid?).any?
250
+ record.errors.add(attribute, :invalid, options.merge(value: value))
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ class Symbol
257
+ def to_c
258
+ to_s.singularize.camelize.constantize
259
+ end
260
+ end
@@ -0,0 +1,122 @@
1
+ module ActiveModel::Datastore
2
+ module TrackChanges
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ attr_accessor :exclude_from_save
7
+ end
8
+
9
+ def tracked_attributes
10
+ []
11
+ end
12
+
13
+ ##
14
+ # Resets the ActiveModel::Dirty tracked changes.
15
+ #
16
+ def reload!
17
+ clear_changes_information
18
+ self.exclude_from_save = false
19
+ end
20
+
21
+ def exclude_from_save?
22
+ @exclude_from_save.nil? ? false : @exclude_from_save
23
+ end
24
+
25
+ ##
26
+ # Determines if any attribute values have changed using ActiveModel::Dirty.
27
+ # For attributes enabled for change tracking compares changed values. All values
28
+ # submitted from an HTML form are strings, thus a string of 25.0 doesn't match an
29
+ # original float of 25.0. Call this method after valid? to allow for any type coercing
30
+ # occurring before saving to datastore.
31
+ #
32
+ # Consider the scenario in which the user submits an unchanged form value named `area`.
33
+ # The initial value from datastore is a float of 25.0, which during assign_attributes
34
+ # is set to a string of '25.0'. It is then coerced back to a float of 25.0 during a
35
+ # validation callback. The area_changed? will return true, yet the value is back where
36
+ # is started.
37
+ #
38
+ # For example:
39
+ #
40
+ # class Shapes
41
+ # include ActiveModel::Datastore
42
+ #
43
+ # attr_accessor :area
44
+ # enable_change_tracking :area
45
+ # after_validation :format_values
46
+ #
47
+ # def format_values
48
+ # format_property_value :area, :float
49
+ # end
50
+ #
51
+ # def update(params)
52
+ # assign_attributes(params)
53
+ # if valid?
54
+ # puts values_changed?
55
+ # puts area_changed?
56
+ # p area_change
57
+ # end
58
+ # end
59
+ # end
60
+ #
61
+ # Will result in this:
62
+ # values_changed? false
63
+ # area_changed? true # This is correct, as area was changed but the value is identical.
64
+ # area_change [0, 0]
65
+ #
66
+ # If none of the tracked attributes have changed, the `exclude_from_save` attribute is
67
+ # set to true and the method returns false.
68
+ #
69
+ def values_changed?
70
+ unless tracked_attributes.present?
71
+ raise TrackChangesError, 'Object has not been configured for change tracking.'
72
+ end
73
+ changed = marked_for_destruction? ? true : false
74
+ tracked_attributes.each do |attr|
75
+ break if changed
76
+ if send("#{attr}_changed?")
77
+ changed = send(attr) == send("#{attr}_was") ? false : true
78
+ end
79
+ end
80
+ self.exclude_from_save = !changed
81
+ changed
82
+ end
83
+
84
+ def remove_unmodified_children
85
+ return unless tracked_attributes.present? && nested_attributes?
86
+ nested_attributes.each do |attr|
87
+ with_changes = Array(send(attr.to_sym)).select(&:values_changed?)
88
+ send("#{attr}=", with_changes)
89
+ end
90
+ nested_attributes.delete_if { |attr| Array(send(attr.to_sym)).size.zero? }
91
+ end
92
+
93
+ module ClassMethods
94
+ ##
95
+ # Enables track changes functionality for the provided attributes using ActiveModel::Dirty.
96
+ #
97
+ # Calls define_attribute_methods for each attribute provided.
98
+ #
99
+ # Creates a setter for each attribute that will look something like this:
100
+ # def name=(value)
101
+ # name_will_change! unless value == @name
102
+ # @name = value
103
+ # end
104
+ #
105
+ # Overrides tracked_attributes to return an Array of the attributes configured for tracking.
106
+ #
107
+ def enable_change_tracking(*attributes)
108
+ attributes = attributes.collect(&:to_sym)
109
+ attributes.each do |attr|
110
+ define_attribute_methods attr
111
+
112
+ define_method("#{attr}=") do |value|
113
+ send("#{attr}_will_change!") unless value == instance_variable_get("@#{attr}")
114
+ instance_variable_set("@#{attr}", value)
115
+ end
116
+ end
117
+
118
+ define_method('tracked_attributes') { attributes }
119
+ end
120
+ end
121
+ end
122
+ end