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