activemodel-datastore 0.1.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.
@@ -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