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
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
|