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