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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +340 -0
- data/lib/active_model/datastore.rb +519 -0
- data/lib/active_model/datastore/connection.rb +39 -0
- data/lib/active_model/datastore/errors.rb +25 -0
- data/lib/active_model/datastore/nested_attr.rb +260 -0
- data/lib/active_model/datastore/track_changes.rb +122 -0
- data/lib/active_model/datastore/version.rb +5 -0
- data/lib/activemodel/datastore.rb +12 -0
- metadata +207 -0
@@ -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
|