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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4d8d2d055a0934bc97f36c9c46a0ef375d30aa4d
|
4
|
+
data.tar.gz: 9640f7bb31c42d4a9c0f7e5d981cd75a20c847a7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fcf58339c5a1e7afc910fc5c3db2b7334af33d194c38966f0b3b875c9b7cec36e6ce83f5a2514b4e0673b92bc50892a981658e912db6bff5a2ab0891cc0753c4
|
7
|
+
data.tar.gz: 13fb3b0621cd3b906156c0ef4e027c54edd20dc6b1cf98a9d34250961e45fa60ea4e89e34a5892f6892e4f5c8cdeb92138a3d1ec9caffb948a6f1afbc6179844
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Agrimatics
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,340 @@
|
|
1
|
+
Active Model Datastore
|
2
|
+
===================================
|
3
|
+
|
4
|
+
Makes the [google-cloud-datastore](https://github.com/GoogleCloudPlatform/google-cloud-ruby/tree/master/google-cloud-datastore) gem compliant with [active_model](https://github.com/rails/rails/tree/master/activemodel) conventions and compatible with your Rails 5 applications.
|
5
|
+
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
6
|
+
|
7
|
+
Why would you want to use Google's NoSQL [Cloud Datastore](https://cloud.google.com/datastore)
|
8
|
+
with Rails?
|
9
|
+
|
10
|
+
When you want a Rails app backed by a managed, massively-scalable datastore solution. Cloud Datastore
|
11
|
+
automatically handles sharding and replication, providing you with a highly available and durable
|
12
|
+
database that scales automatically to handle your applications' load.
|
13
|
+
|
14
|
+
## Table of contents
|
15
|
+
|
16
|
+
- [Setup](#setup)
|
17
|
+
- [Model Example](#model)
|
18
|
+
- [Controller Example](#controller)
|
19
|
+
- [Retrieving Entities](#queries)
|
20
|
+
- [Example Rails App](#rails)
|
21
|
+
- [Development and Test](#development)
|
22
|
+
- [Nested Forms](#nested)
|
23
|
+
- [Work In Progress](#wip)
|
24
|
+
|
25
|
+
## <a name="setup"></a>Setup
|
26
|
+
|
27
|
+
Generate your Rails app without ActiveRecord:
|
28
|
+
|
29
|
+
```bash
|
30
|
+
rails new my_app -O
|
31
|
+
```
|
32
|
+
|
33
|
+
To install, add this line to your `Gemfile` and run `bundle install`:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
gem 'activemodel-datastore'
|
37
|
+
```
|
38
|
+
|
39
|
+
Google Cloud requires a Project ID and Service Account Credentials to connect to the Datastore API.
|
40
|
+
|
41
|
+
*Follow the [activation instructions](https://cloud.google.com/datastore/docs/activate) to use the Google Cloud Datastore API.*
|
42
|
+
|
43
|
+
Set your project id in an `ENV` variable named `GCLOUD_PROJECT`.
|
44
|
+
|
45
|
+
To locate your project ID:
|
46
|
+
|
47
|
+
1. Go to the Cloud Platform Console.
|
48
|
+
2. From the projects list, select the name of your project.
|
49
|
+
3. On the left, click Dashboard. The project name and ID are displayed in the Dashboard.
|
50
|
+
|
51
|
+
When running on Google Cloud Platform environments the Service Account credentials will be discovered automatically.
|
52
|
+
When running on other environments (such as AWS or Heroku), the Service Account credentials need to be
|
53
|
+
specified in two additional `ENV` variables named `SERVICE_ACCOUNT_CLIENT_EMAIL` and `SERVICE_ACCOUNT_PRIVATE_KEY`.
|
54
|
+
|
55
|
+
```bash
|
56
|
+
SERVICE_ACCOUNT_PRIVATE_KEY = -----BEGIN PRIVATE KEY-----\nMIIFfb3...5dmFtABy\n-----END PRIVATE KEY-----\n
|
57
|
+
SERVICE_ACCOUNT_CLIENT_EMAIL = web-app@app-name.iam.gserviceaccount.com
|
58
|
+
```
|
59
|
+
|
60
|
+
On Heroku the `ENV` variables can be set under 'Settings' -> 'Config Variables'.
|
61
|
+
|
62
|
+
## <a name="model"></a>Model Example
|
63
|
+
|
64
|
+
Let's start by implementing the model:
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
class User
|
68
|
+
include ActiveModel::Datastore
|
69
|
+
|
70
|
+
attr_accessor :email, :name, :enabled, :state
|
71
|
+
|
72
|
+
before_validation :set_default_values
|
73
|
+
before_save { puts '** something can happen before save **'}
|
74
|
+
after_save { puts '** something can happen after save **'}
|
75
|
+
|
76
|
+
validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i }
|
77
|
+
validates :name, presence: true, length: { maximum: 30 }
|
78
|
+
|
79
|
+
def entity_properties
|
80
|
+
%w[email name enabled]
|
81
|
+
end
|
82
|
+
|
83
|
+
def set_default_values
|
84
|
+
default_property_value :enabled, true
|
85
|
+
end
|
86
|
+
|
87
|
+
def format_values
|
88
|
+
format_property_value :role, :integer
|
89
|
+
end
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
Using `attr_accessor` the attributes of the model are defined. Validations and Callbacks all work
|
94
|
+
as you would expect. However, `entity_properties` is new. Data objects in Cloud Datastore
|
95
|
+
are known as entities. Entities are of a kind. An entity has one or more named properties, each
|
96
|
+
of which can have one or more values. Think of them like this:
|
97
|
+
* 'Kind' (which is your table)
|
98
|
+
* 'Entity' (which is the record from the table)
|
99
|
+
* 'Property' (which is the attribute of the record)
|
100
|
+
|
101
|
+
The `entity_properties` method defines an Array of the properties that belong to the entity in
|
102
|
+
cloud datastore. With this approach, Rails deals solely with ActiveModel objects. The objects are
|
103
|
+
converted to/from entities as needed during save/query operations.
|
104
|
+
|
105
|
+
We have also added the ability to set default property values and type cast the format of values
|
106
|
+
for entities.
|
107
|
+
|
108
|
+
## <a name="controller"></a>Controller Example
|
109
|
+
|
110
|
+
Now on to the controller! A scaffold generated controller works out of the box:
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
class UsersController < ApplicationController
|
114
|
+
before_action :set_user, only: [:show, :edit, :update, :destroy]
|
115
|
+
|
116
|
+
def index
|
117
|
+
@users = User.all
|
118
|
+
end
|
119
|
+
|
120
|
+
def show
|
121
|
+
end
|
122
|
+
|
123
|
+
def new
|
124
|
+
@user = User.new
|
125
|
+
end
|
126
|
+
|
127
|
+
def edit
|
128
|
+
end
|
129
|
+
|
130
|
+
def create
|
131
|
+
@user = User.new(user_params)
|
132
|
+
respond_to do |format|
|
133
|
+
if @user.save
|
134
|
+
format.html { redirect_to @user, notice: 'User was successfully created.' }
|
135
|
+
else
|
136
|
+
format.html { render :new }
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def update
|
142
|
+
respond_to do |format|
|
143
|
+
if @user.update(user_params)
|
144
|
+
format.html { redirect_to @user, notice: 'User was successfully updated.' }
|
145
|
+
else
|
146
|
+
format.html { render :edit }
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def destroy
|
152
|
+
@user.destroy
|
153
|
+
respond_to do |format|
|
154
|
+
format.html { redirect_to users_url, notice: 'User was successfully destroyed.' }
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def set_user
|
161
|
+
@user = User.find(params[:id])
|
162
|
+
end
|
163
|
+
|
164
|
+
def user_params
|
165
|
+
params.require(:user).permit(:email, :name)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
```
|
169
|
+
|
170
|
+
## <a name="queries"></a>Retrieving Entities
|
171
|
+
|
172
|
+
Queries entities using the provided options. When a limit option is provided queries up to the limit
|
173
|
+
and returns results with a cursor.
|
174
|
+
```ruby
|
175
|
+
users = User.all(options = {})
|
176
|
+
|
177
|
+
parent = CloudDatastore.dataset.key('Parent', 12345)
|
178
|
+
users = User.all(ancestor: parent)
|
179
|
+
|
180
|
+
users = User.all(ancestor: parent, where: ['name', '=', 'Bryce'])
|
181
|
+
|
182
|
+
users = User.all(where: [['name', '=', 'Ian'], ['enabled', '=', true]])
|
183
|
+
|
184
|
+
users, cursor = User.all(limit: 7)
|
185
|
+
|
186
|
+
# @param [Hash] options The options to construct the query with.
|
187
|
+
#
|
188
|
+
# @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results.
|
189
|
+
# @option options [String] :cursor Sets the cursor to start the results at.
|
190
|
+
# @option options [Integer] :limit Sets a limit to the number of results to be returned.
|
191
|
+
# @option options [String] :order Sort the results by property name.
|
192
|
+
# @option options [String] :desc_order Sort the results by descending property name.
|
193
|
+
# @option options [Array] :select Retrieve only select properties from the matched entities.
|
194
|
+
# @option options [Array] :where Adds a property filter of arrays in the format[name, operator, value].
|
195
|
+
```
|
196
|
+
|
197
|
+
Find entity by id - this can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]).
|
198
|
+
The parent key is optional.
|
199
|
+
```ruby
|
200
|
+
user = User.find(1)
|
201
|
+
|
202
|
+
parent = CloudDatastore.dataset.key('Parent', 12345)
|
203
|
+
user = User.find(1, parent: parent)
|
204
|
+
|
205
|
+
users = User.find(1, 2, 3)
|
206
|
+
```
|
207
|
+
|
208
|
+
Finds the first entity matching the specified condition.
|
209
|
+
```ruby
|
210
|
+
user = User.find_by(name: 'Joe')
|
211
|
+
|
212
|
+
user = User.find_by(name: 'Bryce', ancestor: parent)
|
213
|
+
```
|
214
|
+
|
215
|
+
## <a name="rails"></a>Example Rails App
|
216
|
+
|
217
|
+
There is an example Rails 5 app in the test directory [here](https://github.com/Agrimatics/activemodel-datastore/tree/master/test/support/datastore_example_rails_app)
|
218
|
+
|
219
|
+
## <a name="development"></a>Development and Test
|
220
|
+
|
221
|
+
Install the Google Cloud SDK.
|
222
|
+
|
223
|
+
$ curl https://sdk.cloud.google.com | bash
|
224
|
+
|
225
|
+
You can check the version of the SDK and the components installed with:
|
226
|
+
|
227
|
+
$ gcloud components list
|
228
|
+
|
229
|
+
Install the Cloud Datastore Emulator, which provides local emulation of the production Cloud
|
230
|
+
Datastore environment and the gRPC API. However, you'll need to do a small amount of configuration
|
231
|
+
before running the application against the emulator, see [here.](https://cloud.google.com/datastore/docs/tools/datastore-emulator)
|
232
|
+
|
233
|
+
$ gcloud components install cloud-datastore-emulator
|
234
|
+
|
235
|
+
Add the following line to your ~/.bash_profile:
|
236
|
+
|
237
|
+
export PATH="~/google-cloud-sdk/platform/cloud-datastore-emulator:$PATH"
|
238
|
+
|
239
|
+
Restart your shell:
|
240
|
+
|
241
|
+
exec -l $SHELL
|
242
|
+
|
243
|
+
To create the local development datastore execute the following from the root of the project:
|
244
|
+
|
245
|
+
$ cloud_datastore_emulator create tmp/local_datastore
|
246
|
+
|
247
|
+
To create the local test datastore execute the following from the root of the project:
|
248
|
+
|
249
|
+
$ cloud_datastore_emulator create tmp/test_datastore
|
250
|
+
|
251
|
+
To start the local Cloud Datastore emulator:
|
252
|
+
|
253
|
+
$ ./start-local-datastore.sh
|
254
|
+
|
255
|
+
## <a name="nested"></a>Nested Forms
|
256
|
+
|
257
|
+
Adds support for nested attributes to ActiveModel. Heavily inspired by
|
258
|
+
Rails ActiveRecord::NestedAttributes.
|
259
|
+
|
260
|
+
Nested attributes allow you to save attributes on associated records along with the parent.
|
261
|
+
It's used in conjunction with fields_for to build the nested form elements.
|
262
|
+
|
263
|
+
See Rails ActionView::Helpers::FormHelper::fields_for for more info.
|
264
|
+
|
265
|
+
*NOTE*: Unlike ActiveRecord, the way that the relationship is modeled between the parent and
|
266
|
+
child is not enforced. With NoSQL the relationship could be defined by any attribute, or with
|
267
|
+
denormalization exist within the same entity. This library provides a way for the objects to
|
268
|
+
be associated yet saved to the datastore in any way that you choose.
|
269
|
+
|
270
|
+
You enable nested attributes by defining an `:attr_accessor` on the parent with the pluralized
|
271
|
+
name of the child model.
|
272
|
+
|
273
|
+
Nesting also requires that a `<association_name>_attributes=` writer method is defined in your
|
274
|
+
parent model. If an object with an association is instantiated with a params hash, and that
|
275
|
+
hash has a key for the association, Rails will call the `<association_name>_attributes=`
|
276
|
+
method on that object. Within the writer method call `assign_nested_attributes`, passing in
|
277
|
+
the association name and attributes.
|
278
|
+
|
279
|
+
Let's say we have a parent Recipe with Ingredient children.
|
280
|
+
|
281
|
+
Start by defining within the Recipe model:
|
282
|
+
* an attr_accessor of `:ingredients`
|
283
|
+
* a writer method named `ingredients_attributes=`
|
284
|
+
* the `validates_associated` method can be used to validate the nested objects
|
285
|
+
|
286
|
+
Example:
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
class Recipe
|
290
|
+
attr_accessor :ingredients
|
291
|
+
validates :ingredients, presence: true
|
292
|
+
validates_associated :ingredients
|
293
|
+
|
294
|
+
def ingredients_attributes=(attributes)
|
295
|
+
assign_nested_attributes(:ingredients, attributes)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
```
|
299
|
+
|
300
|
+
You may also set a `:reject_if` proc to silently ignore any new record hashes if they fail to
|
301
|
+
pass your criteria. For example:
|
302
|
+
|
303
|
+
```ruby
|
304
|
+
class Recipe
|
305
|
+
def ingredients_attributes=(attributes)
|
306
|
+
reject_proc = proc { |attributes| attributes['name'].blank? }
|
307
|
+
assign_nested_attributes(:ingredients, attributes, reject_if: reject_proc)
|
308
|
+
end
|
309
|
+
end
|
310
|
+
```
|
311
|
+
|
312
|
+
Alternatively,`:reject_if` also accepts a symbol for using methods:
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
class Recipe
|
316
|
+
def ingredients_attributes=(attributes)
|
317
|
+
reject_proc = proc { |attributes| attributes['name'].blank? }
|
318
|
+
assign_nested_attributes(:ingredients, attributes, reject_if: reject_recipes)
|
319
|
+
end
|
320
|
+
|
321
|
+
def reject_recipes(attributes)
|
322
|
+
attributes['name'].blank?
|
323
|
+
end
|
324
|
+
end
|
325
|
+
```
|
326
|
+
|
327
|
+
Within the parent model `valid?` will validate the parent and associated children and
|
328
|
+
`nested_models` will return the child objects. If the nested form submitted params contained
|
329
|
+
a truthy `_destroy` key, the appropriate nested_models will have `marked_for_destruction` set
|
330
|
+
to True.
|
331
|
+
|
332
|
+
## <a name="wip"></a>Work In Progress
|
333
|
+
|
334
|
+
TODO: document datastore eventual consistency and mitigation using ancestor queries and entity groups.
|
335
|
+
|
336
|
+
TODO: document indexes.
|
337
|
+
|
338
|
+
TODO: document using the datastore emulator to generate the index.yaml.
|
339
|
+
|
340
|
+
TODO: document the change tracking implementation.
|
@@ -0,0 +1,519 @@
|
|
1
|
+
##
|
2
|
+
# = Active Model Datastore
|
3
|
+
#
|
4
|
+
# Makes the google-cloud-datastore gem compliant with active_model conventions and compatible with
|
5
|
+
# your Rails 5+ applications.
|
6
|
+
#
|
7
|
+
# Let's start by implementing the model:
|
8
|
+
#
|
9
|
+
# class User
|
10
|
+
# include ActiveModel::Datastore
|
11
|
+
#
|
12
|
+
# attr_accessor :email, :name, :enabled, :state
|
13
|
+
#
|
14
|
+
# before_validation :set_default_values
|
15
|
+
# before_save { puts '** something can happen before save **' }
|
16
|
+
# after_save { puts '** something can happen after save **' }
|
17
|
+
#
|
18
|
+
# validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i }
|
19
|
+
# validates :name, presence: true, length: { maximum: 30 }
|
20
|
+
#
|
21
|
+
# def entity_properties
|
22
|
+
# %w[email name enabled]
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# def set_default_values
|
26
|
+
# default_property_value :enabled, true
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# def format_values
|
30
|
+
# format_property_value :role, :integer
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# Using `attr_accessor` the attributes of the model are defined. Validations and Callbacks all work
|
35
|
+
# as you would expect. However, `entity_properties` is new. Data objects in Google Cloud Datastore
|
36
|
+
# are known as entities. Entities are of a kind. An entity has one or more named properties, each
|
37
|
+
# of which can have one or more values. Think of them like this:
|
38
|
+
# * 'Kind' (which is your table)
|
39
|
+
# * 'Entity' (which is the record from the table)
|
40
|
+
# * 'Property' (which is the attribute of the record)
|
41
|
+
#
|
42
|
+
# The `entity_properties` method defines an Array of the properties that belong to the entity in
|
43
|
+
# cloud datastore. With this approach, Rails deals solely with ActiveModel objects. The objects are
|
44
|
+
# converted to/from entities as needed during save/query operations.
|
45
|
+
#
|
46
|
+
# We have also added the ability to set default property values and type cast the format of values
|
47
|
+
# for entities.
|
48
|
+
#
|
49
|
+
# Now on to the controller! A scaffold generated controller works out of the box:
|
50
|
+
#
|
51
|
+
# class UsersController < ApplicationController
|
52
|
+
# before_action :set_user, only: [:show, :edit, :update, :destroy]
|
53
|
+
#
|
54
|
+
# def index
|
55
|
+
# @users = User.all
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# def show
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# def new
|
62
|
+
# @user = User.new
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# def edit
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
# def create
|
69
|
+
# @user = User.new(user_params)
|
70
|
+
# respond_to do |format|
|
71
|
+
# if @user.save
|
72
|
+
# format.html { redirect_to @user, notice: 'User was successfully created.' }
|
73
|
+
# else
|
74
|
+
# format.html { render :new }
|
75
|
+
# end
|
76
|
+
# end
|
77
|
+
# end
|
78
|
+
#
|
79
|
+
# def update
|
80
|
+
# respond_to do |format|
|
81
|
+
# if @user.update(user_params)
|
82
|
+
# format.html { redirect_to @user, notice: 'User was successfully updated.' }
|
83
|
+
# else
|
84
|
+
# format.html { render :edit }
|
85
|
+
# end
|
86
|
+
# end
|
87
|
+
# end
|
88
|
+
#
|
89
|
+
# def destroy
|
90
|
+
# @user.destroy
|
91
|
+
# respond_to do |format|
|
92
|
+
# format.html { redirect_to users_url, notice: 'User was successfully destroyed.' }
|
93
|
+
# end
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# private
|
97
|
+
#
|
98
|
+
# def set_user
|
99
|
+
# @user = User.find(params[:id])
|
100
|
+
# end
|
101
|
+
#
|
102
|
+
# def user_params
|
103
|
+
# params.require(:user).permit(:email, :name)
|
104
|
+
# end
|
105
|
+
# end
|
106
|
+
#
|
107
|
+
module ActiveModel::Datastore
|
108
|
+
extend ActiveSupport::Concern
|
109
|
+
include ActiveModel::Model
|
110
|
+
include ActiveModel::Dirty
|
111
|
+
include ActiveModel::Validations
|
112
|
+
include ActiveModel::Validations::Callbacks
|
113
|
+
include ActiveModel::Datastore::NestedAttr
|
114
|
+
include ActiveModel::Datastore::TrackChanges
|
115
|
+
|
116
|
+
included do
|
117
|
+
private_class_method :query_options, :query_sort, :query_property_filter, :find_all_entities
|
118
|
+
define_model_callbacks :save, :update, :destroy
|
119
|
+
attr_accessor :id
|
120
|
+
end
|
121
|
+
|
122
|
+
def entity_properties
|
123
|
+
[]
|
124
|
+
end
|
125
|
+
|
126
|
+
##
|
127
|
+
# Used by ActiveModel for determining polymorphic routing.
|
128
|
+
#
|
129
|
+
def persisted?
|
130
|
+
id.present?
|
131
|
+
end
|
132
|
+
|
133
|
+
##
|
134
|
+
# Sets a default value for the property if not currently set.
|
135
|
+
#
|
136
|
+
# Example:
|
137
|
+
# default_property_value :state, 0
|
138
|
+
#
|
139
|
+
# is equivalent to:
|
140
|
+
# self.state = state.presence || 0
|
141
|
+
#
|
142
|
+
# Example:
|
143
|
+
# default_property_value :enabled, false
|
144
|
+
#
|
145
|
+
# is equivalent to:
|
146
|
+
# self.enabled = false if enabled.nil?
|
147
|
+
#
|
148
|
+
def default_property_value(attr, value)
|
149
|
+
if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
150
|
+
send("#{attr.to_sym}=", value) if send(attr.to_sym).nil?
|
151
|
+
else
|
152
|
+
send("#{attr.to_sym}=", send(attr.to_sym).presence || value)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
##
|
157
|
+
# Converts the type of the property.
|
158
|
+
#
|
159
|
+
# Example:
|
160
|
+
# format_property_value :weight, :float
|
161
|
+
#
|
162
|
+
# is equivalent to:
|
163
|
+
# self.weight = weight.to_f if weight.present?
|
164
|
+
#
|
165
|
+
def format_property_value(attr, type)
|
166
|
+
return unless send(attr.to_sym).present?
|
167
|
+
case type.to_sym
|
168
|
+
when :float
|
169
|
+
send("#{attr.to_sym}=", send(attr.to_sym).to_f)
|
170
|
+
when :integer
|
171
|
+
send("#{attr.to_sym}=", send(attr.to_sym).to_i)
|
172
|
+
else
|
173
|
+
raise ArgumentError, 'Supported types are :float, :integer'
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
##
|
178
|
+
# Builds the Cloud Datastore entity with attributes from the Model object.
|
179
|
+
#
|
180
|
+
# @return [Entity] The updated Google::Cloud::Datastore::Entity.
|
181
|
+
#
|
182
|
+
def build_entity(parent = nil)
|
183
|
+
entity = CloudDatastore.dataset.entity self.class.name, id
|
184
|
+
entity.key.parent = parent if parent.present?
|
185
|
+
entity_properties.each do |attr|
|
186
|
+
entity[attr] = instance_variable_get("@#{attr}")
|
187
|
+
end
|
188
|
+
entity
|
189
|
+
end
|
190
|
+
|
191
|
+
def save(parent = nil)
|
192
|
+
save_entity(parent)
|
193
|
+
end
|
194
|
+
|
195
|
+
##
|
196
|
+
# For compatibility with libraries that require the bang method version (example, factory_girl).
|
197
|
+
# If you require a save! method that supports parents (ancestor queries), override this method
|
198
|
+
# in your own code with something like this:
|
199
|
+
#
|
200
|
+
# def save!
|
201
|
+
# parent = nil
|
202
|
+
# if account_id.present?
|
203
|
+
# parent = CloudDatastore.dataset.key 'Parent' + self.class.name, account_id.to_i
|
204
|
+
# end
|
205
|
+
# msg = 'Failed to save the entity'
|
206
|
+
# save_entity(parent) || raise(ActiveModel::Datastore::EntityNotSavedError, msg)
|
207
|
+
# end
|
208
|
+
#
|
209
|
+
def save!
|
210
|
+
save_entity || raise(EntityNotSavedError, 'Failed to save the entity')
|
211
|
+
end
|
212
|
+
|
213
|
+
def update(params)
|
214
|
+
assign_attributes(params)
|
215
|
+
return unless valid?
|
216
|
+
run_callbacks :update do
|
217
|
+
entity = build_entity
|
218
|
+
self.class.retry_on_exception? { CloudDatastore.dataset.save entity }
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def destroy
|
223
|
+
run_callbacks :destroy do
|
224
|
+
key = CloudDatastore.dataset.key self.class.name, id
|
225
|
+
self.class.retry_on_exception? { CloudDatastore.dataset.delete key }
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
private
|
230
|
+
|
231
|
+
def save_entity(parent = nil)
|
232
|
+
return unless valid?
|
233
|
+
run_callbacks :save do
|
234
|
+
entity = build_entity(parent)
|
235
|
+
success = self.class.retry_on_exception? { CloudDatastore.dataset.save entity }
|
236
|
+
self.id = entity.key.id if success
|
237
|
+
success
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# Methods defined here will be class methods when 'include ActiveModel::Datastore'.
|
242
|
+
module ClassMethods
|
243
|
+
##
|
244
|
+
# Retrieves an entity by id or name and by an optional parent.
|
245
|
+
#
|
246
|
+
# @param [Integer or String] id_or_name The id or name value of the entity Key.
|
247
|
+
# @param [Google::Cloud::Datastore::Key] parent The parent Key of the entity.
|
248
|
+
#
|
249
|
+
# @return [Entity, nil] a Google::Cloud::Datastore::Entity object or nil.
|
250
|
+
#
|
251
|
+
def find_entity(id_or_name, parent = nil)
|
252
|
+
key = CloudDatastore.dataset.key name, id_or_name
|
253
|
+
key.parent = parent if parent.present?
|
254
|
+
retry_on_exception { CloudDatastore.dataset.find key }
|
255
|
+
end
|
256
|
+
|
257
|
+
##
|
258
|
+
# Retrieves the entities for the provided ids by key and by an optional parent.
|
259
|
+
# The find_all method returns LookupResults, which is a special case Array with
|
260
|
+
# additional values. LookupResults are returned in batches, and the batch size is
|
261
|
+
# determined by the Datastore API. Batch size is not guaranteed. It will be affected
|
262
|
+
# by the size of the data being returned, and by other forces such as how distributed
|
263
|
+
# and/or consistent the data in Datastore is. Calling `all` on the LookupResults retrieves
|
264
|
+
# all results by repeatedly loading #next until #next? returns false. The `all` method
|
265
|
+
# returns an enumerator unless passed a block. We iterate on the enumerator to return
|
266
|
+
# the model entity objects.
|
267
|
+
#
|
268
|
+
# @param [Integer, String] ids_or_names One or more ids to retrieve.
|
269
|
+
# @param [Google::Cloud::Datastore::Key] parent The parent Key of the entity.
|
270
|
+
#
|
271
|
+
# @return [Array<Entity>] an array of Google::Cloud::Datastore::Entity objects.
|
272
|
+
#
|
273
|
+
def find_entities(*ids_or_names, parent: nil)
|
274
|
+
ids_or_names = ids_or_names.flatten.compact.uniq
|
275
|
+
lookup_results = find_all_entities(ids_or_names, parent)
|
276
|
+
lookup_results.all.collect { |x| x }
|
277
|
+
end
|
278
|
+
|
279
|
+
##
|
280
|
+
# Queries entities from Cloud Datastore by named kind and using the provided options.
|
281
|
+
# When a limit option is provided queries up to the limit and returns results with a cursor.
|
282
|
+
#
|
283
|
+
# This method may make several API calls until all query results are retrieved. The `run`
|
284
|
+
# method returns a QueryResults object, which is a special case Array with additional values.
|
285
|
+
# QueryResults are returned in batches, and the batch size is determined by the Datastore API.
|
286
|
+
# Batch size is not guaranteed. It will be affected by the size of the data being returned,
|
287
|
+
# and by other forces such as how distributed and/or consistent the data in Datastore is.
|
288
|
+
# Calling `all` on the QueryResults retrieves all results by repeatedly loading #next until
|
289
|
+
# #next? returns false. The `all` method returns an enumerator which from_entities iterates on.
|
290
|
+
#
|
291
|
+
# Be sure to use as narrow a search criteria as possible. Please use with caution.
|
292
|
+
#
|
293
|
+
# @param [Hash] options The options to construct the query with.
|
294
|
+
#
|
295
|
+
# @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results.
|
296
|
+
# @option options [String] :cursor Sets the cursor to start the results at.
|
297
|
+
# @option options [Integer] :limit Sets a limit to the number of results to be returned.
|
298
|
+
# @option options [String] :order Sort the results by property name.
|
299
|
+
# @option options [String] :desc_order Sort the results by descending property name.
|
300
|
+
# @option options [Array] :select Retrieve only select properties from the matched entities.
|
301
|
+
# @option options [Array] :where Adds a property filter of arrays in the format
|
302
|
+
# [name, operator, value].
|
303
|
+
#
|
304
|
+
# @return [Array<Model>, String] An array of ActiveModel results
|
305
|
+
#
|
306
|
+
# or if options[:limit] was provided:
|
307
|
+
#
|
308
|
+
# @return [Array<Model>, String] An array of ActiveModel results and a cursor that
|
309
|
+
# can be used to query for additional results.
|
310
|
+
#
|
311
|
+
def all(options = {})
|
312
|
+
next_cursor = nil
|
313
|
+
query = build_query(options)
|
314
|
+
query_results = retry_on_exception { CloudDatastore.dataset.run query }
|
315
|
+
if options[:limit]
|
316
|
+
next_cursor = query_results.cursor if query_results.size == options[:limit]
|
317
|
+
return from_entities(query_results.all), next_cursor
|
318
|
+
end
|
319
|
+
from_entities(query_results.all)
|
320
|
+
end
|
321
|
+
|
322
|
+
##
|
323
|
+
# Find entity by id - this can either be a specific id (1), a list of ids (1, 5, 6),
|
324
|
+
# or an array of ids ([5, 6, 10]). The parent key is optional.
|
325
|
+
#
|
326
|
+
# @param [Integer] ids One or more ids to retrieve.
|
327
|
+
# @param [Google::Cloud::Datastore::Key] parent The parent key of the entity.
|
328
|
+
#
|
329
|
+
# @return [Model, nil] An ActiveModel object or nil for a single id.
|
330
|
+
# @return [Array<Model>] An array of ActiveModel objects for more than one id.
|
331
|
+
#
|
332
|
+
def find(*ids, parent: nil)
|
333
|
+
expects_array = ids.first.is_a?(Array)
|
334
|
+
ids = ids.flatten.compact.uniq.map(&:to_i)
|
335
|
+
|
336
|
+
case ids.size
|
337
|
+
when 0
|
338
|
+
raise EntityError, "Couldn't find #{name} without an ID"
|
339
|
+
when 1
|
340
|
+
entity = find_entity(ids.first, parent)
|
341
|
+
model_entity = from_entity(entity)
|
342
|
+
expects_array ? [model_entity].compact : model_entity
|
343
|
+
else
|
344
|
+
lookup_results = find_all_entities(ids, parent)
|
345
|
+
from_entities(lookup_results.all)
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
##
|
350
|
+
# Finds the first entity matching the specified condition.
|
351
|
+
#
|
352
|
+
# @param [Hash] args In which the key is the property and the value is the value to look for.
|
353
|
+
# @option args [Google::Cloud::Datastore::Key] :ancestor filter for inherited results
|
354
|
+
#
|
355
|
+
# @return [Model, nil] An ActiveModel object or nil.
|
356
|
+
#
|
357
|
+
# @example
|
358
|
+
# User.find_by(name: 'Joe')
|
359
|
+
# User.find_by(name: 'Bryce', ancestor: parent)
|
360
|
+
#
|
361
|
+
def find_by(args)
|
362
|
+
query = CloudDatastore.dataset.query name
|
363
|
+
query.ancestor(args[:ancestor]) if args[:ancestor]
|
364
|
+
query.limit(1)
|
365
|
+
query.where(args.keys[0].to_s, '=', args.values[0])
|
366
|
+
query_results = retry_on_exception { CloudDatastore.dataset.run query }
|
367
|
+
from_entity(query_results.first)
|
368
|
+
end
|
369
|
+
|
370
|
+
##
|
371
|
+
# Translates an Enumerator of Datastore::Entity objects to ActiveModel::Model objects.
|
372
|
+
#
|
373
|
+
# Results provided by the dataset `find_all` or `run query` will be a Dataset::LookupResults or
|
374
|
+
# Dataset::QueryResults object. Invoking `all` on those objects returns an enumerator.
|
375
|
+
#
|
376
|
+
# @param [Enumerator] entities An enumerator representing the datastore entities.
|
377
|
+
#
|
378
|
+
def from_entities(entities)
|
379
|
+
raise ArgumentError, 'Entities param must be an Enumerator' unless entities.is_a? Enumerator
|
380
|
+
entities.map { |entity| from_entity(entity) }
|
381
|
+
end
|
382
|
+
|
383
|
+
##
|
384
|
+
# Translates between Datastore::Entity objects and ActiveModel::Model objects.
|
385
|
+
#
|
386
|
+
# @param [Entity] entity Entity from Cloud Datastore.
|
387
|
+
# @return [Model] The translated ActiveModel object.
|
388
|
+
#
|
389
|
+
def from_entity(entity)
|
390
|
+
return if entity.nil?
|
391
|
+
model_entity = new
|
392
|
+
model_entity.id = entity.key.id unless entity.key.id.nil?
|
393
|
+
model_entity.id = entity.key.name unless entity.key.name.nil?
|
394
|
+
entity.properties.to_hash.each do |name, value|
|
395
|
+
model_entity.send "#{name}=", value if model_entity.respond_to? "#{name}="
|
396
|
+
end
|
397
|
+
model_entity.reload!
|
398
|
+
model_entity
|
399
|
+
end
|
400
|
+
|
401
|
+
def exclude_from_index(entity, boolean)
|
402
|
+
entity.properties.to_h.keys.each do |value|
|
403
|
+
entity.exclude_from_indexes! value, boolean
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
##
|
408
|
+
# Constructs a Google::Cloud::Datastore::Query.
|
409
|
+
#
|
410
|
+
# @param [Hash] options The options to construct the query with.
|
411
|
+
#
|
412
|
+
# @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results.
|
413
|
+
# @option options [String] :cursor Sets the cursor to start the results at.
|
414
|
+
# @option options [Integer] :limit Sets a limit to the number of results to be returned.
|
415
|
+
# @option options [String] :order Sort the results by property name.
|
416
|
+
# @option options [String] :desc_order Sort the results by descending property name.
|
417
|
+
# @option options [Array] :select Retrieve only select properties from the matched entities.
|
418
|
+
# @option options [Array] :where Adds a property filter of arrays in the format
|
419
|
+
# [name, operator, value].
|
420
|
+
#
|
421
|
+
# @return [Query] A datastore query.
|
422
|
+
#
|
423
|
+
def build_query(options = {})
|
424
|
+
query = CloudDatastore.dataset.query name
|
425
|
+
query_options(query, options)
|
426
|
+
end
|
427
|
+
|
428
|
+
def retry_on_exception?(max_retry_count = 5)
|
429
|
+
retries = 0
|
430
|
+
sleep_time = 0.25
|
431
|
+
begin
|
432
|
+
yield
|
433
|
+
rescue => e
|
434
|
+
return false if retries >= max_retry_count
|
435
|
+
puts "\e[33mRescued exception #{e.message.inspect}, retrying in #{sleep_time}\e[0m"
|
436
|
+
# 0.25, 0.5, 1, 2, and 4 second between retries.
|
437
|
+
sleep sleep_time
|
438
|
+
retries += 1
|
439
|
+
sleep_time *= 2
|
440
|
+
retry
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
def retry_on_exception(max_retry_count = 5)
|
445
|
+
retries = 0
|
446
|
+
sleep_time = 0.25
|
447
|
+
begin
|
448
|
+
yield
|
449
|
+
rescue => e
|
450
|
+
raise e if retries >= max_retry_count
|
451
|
+
puts "\e[33mRescued exception #{e.message.inspect}, retrying in #{sleep_time}\e[0m"
|
452
|
+
# 0.25, 0.5, 1, 2, and 4 second between retries.
|
453
|
+
sleep sleep_time
|
454
|
+
retries += 1
|
455
|
+
sleep_time *= 2
|
456
|
+
retry
|
457
|
+
end
|
458
|
+
end
|
459
|
+
|
460
|
+
def log_google_cloud_error
|
461
|
+
yield
|
462
|
+
rescue Google::Cloud::Error => e
|
463
|
+
puts "\e[33m[#{e.message.inspect}]\e[0m"
|
464
|
+
raise e
|
465
|
+
end
|
466
|
+
|
467
|
+
# **************** private ****************
|
468
|
+
|
469
|
+
def query_options(query, options)
|
470
|
+
query.ancestor(options[:ancestor]) if options[:ancestor]
|
471
|
+
query.cursor(options[:cursor]) if options[:cursor]
|
472
|
+
query.limit(options[:limit]) if options[:limit]
|
473
|
+
query_sort(query, options)
|
474
|
+
query.select(options[:select]) if options[:select]
|
475
|
+
query_property_filter(query, options)
|
476
|
+
end
|
477
|
+
|
478
|
+
##
|
479
|
+
# Adds sorting to the results by a property name if included in the options.
|
480
|
+
#
|
481
|
+
def query_sort(query, options)
|
482
|
+
query.order(options[:order]) if options[:order]
|
483
|
+
query.order(options[:desc_order], :desc) if options[:desc_order]
|
484
|
+
query
|
485
|
+
end
|
486
|
+
|
487
|
+
##
|
488
|
+
# Adds property filters to the query if included in the options.
|
489
|
+
# Accepts individual or nested Arrays:
|
490
|
+
# [['superseded', '=', false], ['email', '=', 'something']]
|
491
|
+
#
|
492
|
+
def query_property_filter(query, options)
|
493
|
+
if options[:where]
|
494
|
+
opts = options[:where]
|
495
|
+
if opts[0].is_a?(Array)
|
496
|
+
opts.each do |opt|
|
497
|
+
query.where(opt[0], opt[1], opt[2]) unless opt.nil?
|
498
|
+
end
|
499
|
+
else
|
500
|
+
query.where(opts[0], opts[1], opts[2])
|
501
|
+
end
|
502
|
+
end
|
503
|
+
query
|
504
|
+
end
|
505
|
+
|
506
|
+
##
|
507
|
+
# Finds entities by keys using the provided array items. Results provided by the
|
508
|
+
# dataset `find_all` is a Dataset::LookupResults object.
|
509
|
+
#
|
510
|
+
# @param [Array<Integer>, Array<String>] ids_or_names An array of ids or names.
|
511
|
+
#
|
512
|
+
#
|
513
|
+
def find_all_entities(ids_or_names, parent)
|
514
|
+
keys = ids_or_names.map { |id| CloudDatastore.dataset.key name, id }
|
515
|
+
keys.map { |key| key.parent = parent } if parent.present?
|
516
|
+
retry_on_exception { CloudDatastore.dataset.find_all keys }
|
517
|
+
end
|
518
|
+
end
|
519
|
+
end
|