activemodel-datastore 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ ### 0.1.0 / 2017-03-27
2
+
3
+ Initial release.
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