├── .gitignore ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── Rakefile ├── activemodel-datastore.gemspec ├── lib ├── active_model │ ├── datastore.rb │ └── datastore │ │ ├── carrier_wave_uploader.rb │ │ ├── connection.rb │ │ ├── errors.rb │ │ ├── excluded_indexes.rb │ │ ├── nested_attr.rb │ │ ├── property_values.rb │ │ ├── track_changes.rb │ │ └── version.rb └── activemodel │ └── datastore.rb ├── test ├── cases │ ├── active_model_compliance_test.rb │ ├── callbacks_test.rb │ ├── carrier_wave_uploader_test.rb │ ├── datastore_test.rb │ ├── excluded_indexes_test.rb │ ├── nested_attr_test.rb │ ├── property_values_test.rb │ └── track_changes_test.rb ├── entity_class_method_extensions.rb ├── factories.rb ├── images │ ├── test-image-1.jpg │ ├── test-image-2.jpeg │ └── test-image-3.png ├── support │ └── datastore_example_rails_app │ │ ├── .ruby-gemset │ │ ├── .ruby-version │ │ ├── Gemfile │ │ ├── Gemfile.lock │ │ ├── Procfile │ │ ├── Rakefile │ │ ├── app │ │ ├── assets │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ ├── images │ │ │ │ └── fallback_user.png │ │ │ ├── javascripts │ │ │ │ ├── active_model_nested_attr.coffee │ │ │ │ └── application.js │ │ │ └── stylesheets │ │ │ │ ├── application.scss │ │ │ │ └── scaffolds.scss │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── home_controller.rb │ │ │ └── users_controller.rb │ │ ├── helpers │ │ │ ├── application_helper.rb │ │ │ └── users_helper.rb │ │ ├── mailers │ │ │ └── .keep │ │ ├── models │ │ │ ├── recipe.rb │ │ │ └── user.rb │ │ ├── uploaders │ │ │ └── profile_image_uploader.rb │ │ └── views │ │ │ ├── home │ │ │ └── index.html.erb │ │ │ ├── layouts │ │ │ └── application.html.erb │ │ │ └── users │ │ │ ├── _form.html.erb │ │ │ ├── edit.html.erb │ │ │ ├── index.html.erb │ │ │ ├── new.html.erb │ │ │ └── show.html.erb │ │ ├── bin │ │ ├── bundle │ │ ├── rails │ │ ├── rake │ │ ├── setup │ │ ├── spring │ │ ├── update │ │ └── yarn │ │ ├── config.ru │ │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── cable.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── application_controller_renderer.rb │ │ │ ├── assets.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── carrierwave.rb │ │ │ ├── content_security_policy.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ ├── mime_types.rb │ │ │ ├── new_framework_defaults_5_1.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── rack_timeout.rb │ │ │ ├── session_store.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── puma.rb │ │ ├── routes.rb │ │ └── secrets.yml │ │ ├── lib │ │ ├── assets │ │ │ └── .keep │ │ └── tasks │ │ │ └── .keep │ │ ├── log │ │ └── .keep │ │ ├── public │ │ ├── 404.html │ │ ├── 422.html │ │ ├── 500.html │ │ ├── favicon.ico │ │ └── robots.txt │ │ ├── start-local-datastore.sh │ │ ├── test │ │ ├── controllers │ │ │ └── users_controller_test.rb │ │ ├── entity_class_method_extensions.rb │ │ ├── factories │ │ │ └── user_factories.rb │ │ ├── helpers │ │ │ └── .keep │ │ ├── integration │ │ │ └── .keep │ │ ├── mailers │ │ │ └── .keep │ │ ├── models │ │ │ └── user_test.rb │ │ └── test_helper.rb │ │ ├── tmp │ │ └── .keep │ │ └── vendor │ │ └── assets │ │ ├── javascripts │ │ └── .keep │ │ └── stylesheets │ │ └── .keep └── test_helper.rb └── tmp └── .keep /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | tmp/* 3 | !tmp/.keep 4 | pkg 5 | 6 | # RubyMine project files 7 | /.idea/* 8 | 9 | # Ignore all example Rails app logfiles and tempfiles. 10 | /test/support/datastore_example_rails_app/log/* 11 | !/test/support/datastore_example_rails_app/log/.keep 12 | /test/support/datastore_example_rails_app/tmp/* 13 | !/test/support/datastore_example_rails_app/tmp/.keep 14 | /test/support/datastore_example_rails_app/public/assets/* 15 | !/test/support/datastore_example_rails_app/Gemfile.lock 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | DisplayCopNames: true 4 | DisplayStyleGuide: true 5 | ExtraDetails: false 6 | SuggestExtensions: false 7 | TargetRubyVersion: 2.6 8 | Exclude: 9 | - "activemodel-datastore.gemspec" 10 | - "Rakefile" 11 | - "lib/active_model/datastore/version.rb" 12 | - "test/support/datastore_example_rails_app/bin/**/*" 13 | - "test/support/datastore_example_rails_app/config/environments/**/*" 14 | - "test/support/datastore_example_rails_app/config/application.rb" 15 | - "test/support/datastore_example_rails_app/config/initializers/backtrace_silencers.rb" 16 | - "test/support/datastore_example_rails_app/config/initializers/content_security_policy.rb" 17 | - "test/support/datastore_example_rails_app/config/initializers/session_store.rb" 18 | - "test/support/datastore_example_rails_app/vendor/**/*" 19 | - "test/support/datastore_example_rails_app/Gemfile" 20 | - "vendor/**/*" 21 | 22 | #################### Layout ########################## 23 | 24 | Layout/LineLength: 25 | Max: 100 26 | 27 | Layout/SpaceAroundMethodCallOperator: 28 | Enabled: true 29 | 30 | Layout/EmptyLinesAroundAttributeAccessor: 31 | Enabled: false 32 | 33 | Layout/LineEndStringConcatenationIndentation: 34 | Enabled: false 35 | 36 | ##################### Lint ########################### 37 | 38 | Lint/SendWithMixinArgument: 39 | Enabled: false 40 | 41 | Lint/ConstantDefinitionInBlock: 42 | Exclude: 43 | - "test/cases/datastore_test.rb" 44 | - "test/cases/track_changes_test.rb" 45 | 46 | #################### Style ########################### 47 | 48 | Style/AccessorGrouping: 49 | Enabled: false 50 | 51 | Style/Documentation: 52 | Enabled: false 53 | 54 | Style/FrozenStringLiteralComment: 55 | Enabled: false 56 | 57 | Style/ClassAndModuleChildren: 58 | Enabled: false 59 | 60 | Style/EmptyMethod: 61 | EnforcedStyle: expanded 62 | 63 | Style/NumericLiterals: 64 | Enabled: false 65 | 66 | Style/StringConcatenation: 67 | Enabled: false 68 | 69 | Style/SymbolArray: 70 | EnforcedStyle: brackets 71 | 72 | Style/IfWithBooleanLiteralBranches: 73 | Enabled: false 74 | 75 | Style/DocumentDynamicEvalDefinition: 76 | Exclude: 77 | - "lib/active_model/datastore/carrier_wave_uploader.rb" 78 | 79 | Style/FetchEnvVar: 80 | Enabled: false 81 | 82 | #################### Metrics ######################### 83 | 84 | Metrics/ModuleLength: 85 | CountComments: false 86 | Max: 150 87 | 88 | Metrics/ClassLength: 89 | CountComments: false 90 | Max: 175 91 | Exclude: 92 | - "test/**/*" 93 | 94 | Metrics/MethodLength: 95 | CountComments: false 96 | Max: 20 97 | Exclude: 98 | - 'lib/active_model/datastore/carrier_wave_uploader.rb' 99 | 100 | Metrics/BlockLength: 101 | Exclude: 102 | - 'Rakefile' 103 | - '**/*.rake' 104 | - "test/**/*" 105 | 106 | Metrics/AbcSize: 107 | Max: 26 108 | 109 | #################### Naming ########################## 110 | 111 | Naming/VariableNumber: 112 | Enabled: false 113 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | activemodel-datastore -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.8 4 | - 2.6.6 5 | - ruby-head 6 | 7 | sudo: false 8 | dist: trusty 9 | 10 | cache: 11 | bundler: true 12 | directories: 13 | - "$HOME/google-cloud-sdk/" 14 | 15 | before_install: 16 | - gcloud version || true 17 | - if [ ! -d "$HOME/google-cloud-sdk/bin" ]; then rm -rf $HOME/google-cloud-sdk; export CLOUDSDK_CORE_DISABLE_PROMPTS=1; curl https://sdk.cloud.google.com | bash; fi 18 | # Add gcloud to $PATH: 19 | - source /home/travis/google-cloud-sdk/path.bash.inc 20 | - gcloud version 21 | - gcloud components install cloud-datastore-emulator 22 | - export PATH="$HOME/google-cloud-sdk/platform/cloud-datastore-emulator:$PATH" 23 | 24 | install: 25 | - bundle install --jobs=3 --retry=3 26 | - (cd test/support/datastore_example_rails_app/ && BUNDLE_GEMFILE=./Gemfile bundle install --jobs=3 --retry=3) 27 | 28 | script: 29 | - bundle exec rake 30 | - (cd test/support/datastore_example_rails_app/ ; BUNDLE_GEMFILE=./Gemfile bundle exec rake) 31 | - bundle exec rubocop 32 | 33 | matrix: 34 | allow_failures: 35 | - rvm: ruby-head 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.8.0 / 2024-03-05 2 | * updating GCLOUD_KEYFILE_JSON to work with the latest version of GoogleAuth 3 | 4 | ### 0.7.0 / 2021-11-04 5 | * adding support for Ruby 3 6 | 7 | ### 0.6.0 / 2021-09-20 8 | * defaulting the Google::Cloud.datastore network timeout to 15 sec and providing the env var DATASTORE_NETWORK_TIMEOUT as an override. 9 | 10 | ### 0.5.0 / 2020-08-17 11 | * adding support Google::Cloud::Datastore 2.0.0 (rewritten low-level client, with improved performance and stability). 12 | 13 | ### 0.4.0 / 2019-08-23 14 | * adding support for Rails 6 15 | 16 | ### 0.3.0 / 2018-04-17 17 | * adding Travis CI configuration (rud) 18 | * no longer override connection related environment variables if already defined(shao1555) 19 | * adding support for passing query an array of select properties 20 | 21 | ### 0.2.5 / 2017-11-06 22 | * adding support for setting indexed false on individual entity properties 23 | * updating example Cloud Datastore Rails app to 5.1.4 24 | * retry on exceptions are now specific to Google::Cloud::Error 25 | 26 | ### 0.2.4 / 2017-10-31 27 | * non-Rails projects now source client authentication settings automatically (rjmooney) 28 | * documentation improvements 29 | 30 | ### 0.2.3 / 2017-05-24 31 | * adding CarrierWave file upload support 32 | * updating example Cloud Datastore Rails app to 5.1 33 | * adding image upload example to example Rails app 34 | 35 | ### 0.2.2 / 2017-04-27 36 | 37 | * now store a hash of entity properties during entity to model conversion 38 | * preparing for CarrierWave file upload support 39 | 40 | ### 0.2.1 / 2017-04-25 41 | 42 | * adding support for boolean types to format_property_value 43 | 44 | ### 0.2.0 / 2017-04-19 45 | 46 | * many documentation improvements 47 | * adding support for creating entity groups through either a parent or parent_key_id 48 | * example Rails 5 app 49 | 50 | ### 0.1.0 / 2017-03-27 51 | 52 | Initial release. 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in activemodel-datastore.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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. It is a highly available and durable database that 12 | automatically scales to handle your applications' load. Cloud Datastore is a schemaless database 13 | suited for unstructured or semi-structured application data. 14 | 15 | [![Gem Version](https://badge.fury.io/rb/activemodel-datastore.svg)](https://badge.fury.io/rb/activemodel-datastore) 16 | [![Build Status](https://travis-ci.org/Agrimatics/activemodel-datastore.svg?branch=master)](https://travis-ci.org/Agrimatics/activemodel-datastore) 17 | 18 | ## Table of contents 19 | 20 | - [Setup](#setup) 21 | - [Model Example](#model) 22 | - [Controller Example](#controller) 23 | - [Retrieving Entities](#queries) 24 | - [Datastore Consistency](#consistency) 25 | - [Datastore Indexes](#indexes) 26 | - [Datastore Emulator](#emulator) 27 | - [Example Rails App](#rails) 28 | - [CarrierWave File Uploads](#carrierwave) 29 | - [Track Changes](#track_changes) 30 | - [Nested Forms](#nested) 31 | - [Datastore Gotchas](#gotchas) 32 | 33 | ## Setup 34 | 35 | Generate your Rails app without ActiveRecord: 36 | 37 | ```bash 38 | rails new my_app -O 39 | ``` 40 | 41 | You can remove the db/ directory as it won't be needed. 42 | 43 | To install, add this line to your `Gemfile` and run `bundle install`: 44 | 45 | ```ruby 46 | gem 'activemodel-datastore' 47 | ``` 48 | 49 | Create a Google Cloud account [here](https://cloud.google.com) and create a project. 50 | 51 | Google Cloud requires the Project ID and Service Account Credentials to connect to the Datastore API. 52 | 53 | *Follow the [activation instructions](https://cloud.google.com/datastore/docs/activate) to enable the 54 | Google Cloud Datastore API. When running on Google Cloud Platform environments the Service Account 55 | credentials will be discovered automatically. When running on other environments (such as AWS or Heroku) 56 | you need to create a service account with the role of editor and generate json credentials.* 57 | 58 | Set your project id in an `ENV` variable named `GCLOUD_PROJECT`. 59 | 60 | To locate your project ID: 61 | 62 | 1. Go to the Cloud Platform Console. 63 | 2. From the projects list, select the name of your project. 64 | 3. On the left, click Dashboard. The project name and ID are displayed in the Dashboard. 65 | 66 | If you have an external application running on a platform outside of Google Cloud you also need to 67 | provide the Service Account credentials. They are specified in two additional `ENV` variables named 68 | `SERVICE_ACCOUNT_CLIENT_EMAIL` and `SERVICE_ACCOUNT_PRIVATE_KEY`. The values for these two `ENV` 69 | variables will be in the downloaded service account json credentials file. 70 | 71 | ```bash 72 | SERVICE_ACCOUNT_PRIVATE_KEY = -----BEGIN PRIVATE KEY-----\nMIIFfb3...5dmFtABy\n-----END PRIVATE KEY-----\n 73 | SERVICE_ACCOUNT_CLIENT_EMAIL = web-app@app-name.iam.gserviceaccount.com 74 | ``` 75 | 76 | On Heroku the `ENV` variables can be set under 'Settings' -> 'Config Variables'. 77 | 78 | Active Model Datastore will then handle the authentication for you, and the datastore instance can 79 | be accessed with `CloudDatastore.dataset`. 80 | 81 | There is an example Puma config file [here](https://github.com/Agrimatics/activemodel-datastore/blob/master/test/support/datastore_example_rails_app/config/puma.rb). 82 | 83 | ## Model Example 84 | 85 | Let's start by implementing the model: 86 | 87 | ```ruby 88 | class User 89 | include ActiveModel::Datastore 90 | 91 | attr_accessor :email, :enabled, :name, :role, :state 92 | 93 | def entity_properties 94 | %w[email enabled name role] 95 | end 96 | end 97 | ``` 98 | 99 | Data objects in Cloud Datastore are known as entities. Entities are of a kind. An entity has one 100 | or more named properties, each of which can have one or more values. Think of them like this: 101 | * 'Kind' (which is your table and the name of your Rails model) 102 | * 'Entity' (which is the record from the table) 103 | * 'Property' (which is the attribute of the record) 104 | 105 | The `entity_properties` method defines an Array of properties that belong to the entity in cloud 106 | datastore. Define the attributes of your model using `attr_accessor`. With this approach, Rails 107 | deals solely with ActiveModel objects. The objects are converted to/from entities automatically 108 | during save/query operations. You can still use virtual attributes on the model (such as the 109 | `:state` attribute above) by simply excluding it from `entity_properties`. In this example state 110 | is available to the model but won't be persisted with the entity in datastore. 111 | 112 | Validations work as you would expect: 113 | 114 | ```ruby 115 | class User 116 | include ActiveModel::Datastore 117 | 118 | attr_accessor :email, :enabled, :name, :role, :state 119 | 120 | validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } 121 | validates :name, presence: true, length: { maximum: 30 } 122 | 123 | def entity_properties 124 | %w[email enabled name role] 125 | end 126 | end 127 | ``` 128 | 129 | Callbacks work as you would expect. We have also added the ability to set default values through 130 | [`default_property_value`](http://www.rubydoc.info/gems/activemodel-datastore/ActiveModel/Datastore/PropertyValues#default_property_value-instance_method) 131 | and type cast the format of values through [`format_property_value`](http://www.rubydoc.info/gems/activemodel-datastore/ActiveModel/Datastore/PropertyValues#format_property_value-instance_method): 132 | 133 | ```ruby 134 | class User 135 | include ActiveModel::Datastore 136 | 137 | attr_accessor :email, :enabled, :name, :role, :state 138 | 139 | before_validation :set_default_values 140 | after_validation :format_values 141 | 142 | before_save { puts '** something can happen before save **'} 143 | after_save { puts '** something can happen after save **'} 144 | 145 | validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } 146 | validates :name, presence: true, length: { maximum: 30 } 147 | validates :role, presence: true 148 | 149 | def entity_properties 150 | %w[email enabled name role] 151 | end 152 | 153 | def set_default_values 154 | default_property_value :enabled, true 155 | default_property_value :role, 1 156 | end 157 | 158 | def format_values 159 | format_property_value :role, :integer 160 | end 161 | end 162 | ``` 163 | 164 | ## Controller Example 165 | 166 | Now on to the controller! A scaffold generated controller works out of the box: 167 | 168 | ```ruby 169 | class UsersController < ApplicationController 170 | before_action :set_user, only: [:show, :edit, :update, :destroy] 171 | 172 | def index 173 | @users = User.all 174 | end 175 | 176 | def show 177 | end 178 | 179 | def new 180 | @user = User.new 181 | end 182 | 183 | def edit 184 | end 185 | 186 | def create 187 | @user = User.new(user_params) 188 | respond_to do |format| 189 | if @user.save 190 | format.html { redirect_to @user, notice: 'User was successfully created.' } 191 | else 192 | format.html { render :new } 193 | end 194 | end 195 | end 196 | 197 | def update 198 | respond_to do |format| 199 | if @user.update(user_params) 200 | format.html { redirect_to @user, notice: 'User was successfully updated.' } 201 | else 202 | format.html { render :edit } 203 | end 204 | end 205 | end 206 | 207 | def destroy 208 | @user.destroy 209 | respond_to do |format| 210 | format.html { redirect_to users_url, notice: 'User was successfully destroyed.' } 211 | end 212 | end 213 | 214 | private 215 | 216 | def set_user 217 | @user = User.find(params[:id]) 218 | end 219 | 220 | def user_params 221 | params.require(:user).permit(:email, :name) 222 | end 223 | end 224 | ``` 225 | 226 | ## Retrieving Entities 227 | 228 | Each entity in Cloud Datastore has a key that uniquely identifies it. The key consists of the 229 | following components: 230 | 231 | * the kind of the entity, which is User in these examples 232 | * an identifier for the individual entity, which can be either a a key name string or an integer numeric ID 233 | * an optional ancestor path locating the entity within the Cloud Datastore hierarchy 234 | 235 | #### [all(options = {})](http://www.rubydoc.info/gems/activemodel-datastore/ActiveModel%2FDatastore%2FClassMethods:all) 236 | Queries entities using the provided options. When a limit option is provided queries up to the limit 237 | and returns results with a cursor. 238 | ```ruby 239 | users = User.all(options = {}) 240 | 241 | parent_key = CloudDatastore.dataset.key('Parent', 12345) 242 | users = User.all(ancestor: parent_key) 243 | 244 | users = User.all(ancestor: parent_key, where: ['name', '=', 'Bryce']) 245 | 246 | users = User.all(where: [['name', '=', 'Ian'], ['enabled', '=', true]]) 247 | 248 | users, cursor = User.all(limit: 7) 249 | 250 | # @param [Hash] options The options to construct the query with. 251 | # 252 | # @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results. 253 | # @option options [String] :cursor Sets the cursor to start the results at. 254 | # @option options [Integer] :limit Sets a limit to the number of results to be returned. 255 | # @option options [String] :order Sort the results by property name. 256 | # @option options [String] :desc_order Sort the results by descending property name. 257 | # @option options [Array] :select Retrieve only select properties from the matched entities. 258 | # @option options [Array] :distinct_on Group results by a list of properties. 259 | # @option options [Array] :where Adds a property filter of arrays in the format[name, operator, value]. 260 | ``` 261 | 262 | #### [find(*ids, parent: nil)](http://www.rubydoc.info/gems/activemodel-datastore/ActiveModel%2FDatastore%2FClassMethods:find) 263 | 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]). 264 | The parent key is optional. This method is a lookup by key and results will be strongly consistent. 265 | ```ruby 266 | user = User.find(1) 267 | 268 | parent_key = CloudDatastore.dataset.key('Parent', 12345) 269 | user = User.find(1, parent: parent_key) 270 | 271 | users = User.find(1, 2, 3) 272 | ``` 273 | 274 | #### [find_by(args)](http://www.rubydoc.info/gems/activemodel-datastore/ActiveModel%2FDatastore%2FClassMethods:find_by) 275 | Queries for the first entity matching the specified condition. 276 | ```ruby 277 | user = User.find_by(name: 'Joe') 278 | 279 | user = User.find_by(name: 'Bryce', ancestor: parent_key) 280 | ``` 281 | 282 | Cloud Datastore has documentation on how [Datastore Queries](https://cloud.google.com/datastore/docs/concepts/queries#datastore-basic-query-ruby) 283 | work, and pay special attention to the the [restrictions](https://cloud.google.com/datastore/docs/concepts/queries#restrictions_on_queries). 284 | 285 | ## Datastore Consistency 286 | 287 | Cloud Datastore is a non-relational databases, or NoSQL database. It distributes data over many 288 | machines and uses synchronous replication over a wide geographic area. Because of this architecture 289 | it offers a balance of strong and eventual consistency. 290 | 291 | What is eventual consistency? 292 | 293 | It means that an updated entity value may not be immediately visible when executing a query. 294 | Eventual consistency is a theoretical guarantee that, provided no new updates to an entity are made, 295 | all reads of the entity will eventually return the last updated value. 296 | 297 | In the context of a Rails app, there are times that eventual consistency is not ideal. For example, 298 | let's say you create a user entity with a key that looks like this: 299 | 300 | `@key=#` 301 | 302 | and then immediately redirect to the index view of users. There is a good chance that your new user 303 | is not yet visible in the list. If you perform a refresh on the index view a second or two later 304 | the user will appear. 305 | 306 | "Wait a minute!" you say. "This is crap!" you say. Fear not! We can make the query of users strongly 307 | consistent. We just need to use entity groups and ancestor queries. An entity group is a hierarchy 308 | formed by a root entity and its children. To create an entity group, you specify an ancestor path 309 | for the entity which is a parent key as part of the child key. 310 | 311 | Before using the `save` method, assign the `parent_key_id` attribute an ID. Let's say that 12345 312 | represents the ID of the company that the users belong to. The key of the user entity will now 313 | look like this: 314 | 315 | `@key=#>` 316 | 317 | All of the User entities will now belong to an entity group named ParentUser and can be queried by the 318 | Company ID. When we query for the users we will provide User.parent_key(12345) as the ancestor option. 319 | 320 | *Ancestor queries are always strongly consistent.* 321 | 322 | However, there is a small downside. Entities with the same ancestor are limited to 1 write per second. 323 | Also, the entity group relationship cannot be changed after creating the entity (as you can't modify 324 | an entity's key after it has been saved). 325 | 326 | The Users controller would now look like this: 327 | 328 | ```ruby 329 | class UsersController < ApplicationController 330 | before_action :set_user, only: [:show, :edit, :update, :destroy] 331 | 332 | def index 333 | @users = User.all(ancestor: User.parent_key(12345)) 334 | end 335 | 336 | def show 337 | end 338 | 339 | def new 340 | @user = User.new 341 | end 342 | 343 | def edit 344 | end 345 | 346 | def create 347 | @user = User.new(user_params) 348 | @user.parent_key_id = 12345 349 | respond_to do |format| 350 | if @user.save 351 | format.html { redirect_to @user, notice: 'User was successfully created.' } 352 | else 353 | format.html { render :new } 354 | end 355 | end 356 | end 357 | 358 | def update 359 | respond_to do |format| 360 | if @user.update(user_params) 361 | format.html { redirect_to @user, notice: 'User was successfully updated.' } 362 | else 363 | format.html { render :edit } 364 | end 365 | end 366 | end 367 | 368 | def destroy 369 | @user.destroy 370 | respond_to do |format| 371 | format.html { redirect_to users_url, notice: 'User was successfully destroyed.' } 372 | end 373 | end 374 | 375 | private 376 | 377 | def set_user 378 | @user = User.find(params[:id], parent: User.parent_key(12345)) 379 | end 380 | 381 | def user_params 382 | params.require(:user).permit(:email, :name) 383 | end 384 | end 385 | ``` 386 | 387 | See here for the Cloud Datastore documentation on [Data Consistency](https://cloud.google.com/datastore/docs/concepts/structuring_for_strong_consistency). 388 | 389 | ## Datastore Indexes 390 | 391 | Every cloud datastore query requires an index. Yes, you read that correctly. Every single one. The 392 | indexes contain entity keys in a sequence specified by the index's properties and, optionally, 393 | the entity's ancestors. 394 | 395 | There are two types of indexes, *built-in* and *composite*. 396 | 397 | #### Built-in 398 | By default, Cloud Datastore automatically predefines an index for each property of each entity kind. 399 | These single property indexes are suitable for simple types of queries. These indexes are free and 400 | do not count against your index limit. 401 | 402 | #### Composite 403 | Composite index multiple property values per indexed entity. Composite indexes support complex 404 | queries and are defined in an index.yaml file. 405 | 406 | Composite indexes are required for queries of the following form: 407 | 408 | * queries with ancestor and inequality filters 409 | * queries with one or more inequality filters on a property and one or more equality filters on other properties 410 | * queries with a sort order on keys in descending order 411 | * queries with multiple sort orders 412 | * queries with one or more filters and one or more sort orders 413 | 414 | *NOTE*: Inequality filters are LESS_THAN, LESS_THAN_OR_EQUAL, GREATER_THAN, GREATER_THAN_OR_EQUAL. 415 | 416 | Google has excellent doc regarding datastore indexes [here](https://cloud.google.com/datastore/docs/concepts/indexes). 417 | 418 | The datastore emulator generates composite indexes in an index.yaml file automatically. The file 419 | can be found in /tmp/local_datastore/WEB-INF/index.yaml. If your localhost Rails app exercises every 420 | possible query the application will issue, using every combination of filter and sort order, the 421 | generated entries will represent your complete set of indexes. 422 | 423 | One thing to note is that the datastore emulator caches indexes. As you add and modify application 424 | code you might find that the local datastore index.yaml contains indexes that are no longer needed. 425 | In this scenario try deleting the index.yaml and restarting the emulator. Navigate through your Rails 426 | app and the index.yaml will be built from scratch. 427 | 428 | ## Datastore Emulator 429 | 430 | Install the Google Cloud SDK. 431 | 432 | $ curl https://sdk.cloud.google.com | bash 433 | 434 | You can check the version of the SDK and the components installed with: 435 | 436 | $ gcloud components list 437 | 438 | Install the Cloud Datastore Emulator, which provides local emulation of the production Cloud 439 | Datastore environment and the gRPC API. However, you'll need to do a small amount of configuration 440 | before running the application against the emulator, see [here.](https://cloud.google.com/datastore/docs/tools/datastore-emulator) 441 | 442 | $ gcloud components install cloud-datastore-emulator 443 | 444 | Add the following line to your ~/.bash_profile: 445 | 446 | export PATH="~/google-cloud-sdk/platform/cloud-datastore-emulator:$PATH" 447 | 448 | Restart your shell: 449 | 450 | exec -l $SHELL 451 | 452 | To create the local development datastore execute the following from the root of the project: 453 | 454 | $ cloud_datastore_emulator create tmp/local_datastore 455 | 456 | To create the local test datastore execute the following from the root of the project: 457 | 458 | $ cloud_datastore_emulator create tmp/test_datastore 459 | 460 | To start the local Cloud Datastore emulator: 461 | 462 | $ cloud_datastore_emulator start --port=8180 tmp/local_datastore 463 | 464 | ## Example Rails App 465 | 466 | 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). 467 | 468 | ```bash 469 | $ bundle 470 | $ cloud_datastore_emulator create tmp/local_datastore 471 | $ cloud_datastore_emulator create tmp/test_datastore 472 | $ ./start-local-datastore.sh 473 | $ rails s 474 | ``` 475 | 476 | Navigate to http://localhost:3000. 477 | 478 | ## CarrierWave File Uploads 479 | 480 | Active Model Datastore has built in support for [CarrierWave](https://github.com/carrierwaveuploader/carrierwave) 481 | which is a simple and extremely flexible way to upload files from Rails applications. You can use 482 | different stores, including filesystem and cloud storage such as Google Cloud Storage or AWS. 483 | 484 | Simply require `active_model/datastore/carrier_wave_uploader` and extend your model with the 485 | CarrierWaveUploader (after including ActiveModel::Datastore). Follow the CarrierWave 486 | [instructions](https://github.com/carrierwaveuploader/carrierwave#getting-started) for generating 487 | an uploader. 488 | 489 | In this example it will be something like: 490 | 491 | `rails generate uploader ProfileImage` 492 | 493 | Define an attribute on the model for your file(s). You can then mount the uploaders using 494 | `mount_uploader` (single file) or `mount_uploaders` (array of files). Don't forget to add the new 495 | attribute to `entity_properties` and whitelist the attribute in the controller if using strong 496 | parameters. 497 | 498 | ```ruby 499 | require 'active_model/datastore/carrier_wave_uploader' 500 | 501 | class User 502 | include ActiveModel::Datastore 503 | extend CarrierWaveUploader 504 | 505 | attr_accessor :email, :enabled, :name, :profile_image, :role 506 | 507 | mount_uploader :profile_image, ProfileImageUploader 508 | 509 | def entity_properties 510 | %w[email enabled name profile_image role] 511 | end 512 | end 513 | ``` 514 | 515 | You will want to add something like this to your Rails form: 516 | 517 | `<%= form.file_field :profile_image %>` 518 | 519 | ## Track Changes 520 | 521 | TODO: document the change tracking implementation. 522 | 523 | ## Nested Forms 524 | 525 | Adds support for nested attributes to ActiveModel. Heavily inspired by 526 | Rails ActiveRecord::NestedAttributes. 527 | 528 | Nested attributes allow you to save attributes on associated records along with the parent. 529 | It's used in conjunction with fields_for to build the nested form elements. 530 | 531 | See Rails [ActionView::Helpers::FormHelper::fields_for](http://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-fields_for) for more info. 532 | 533 | *NOTE*: Unlike ActiveRecord, the way that the relationship is modeled between the parent and 534 | child is not enforced. With NoSQL the relationship could be defined by any attribute, or with 535 | denormalization exist within the same entity. This library provides a way for the objects to 536 | be associated yet saved to the datastore in any way that you choose. 537 | 538 | You enable nested attributes by defining an `:attr_accessor` on the parent with the pluralized 539 | name of the child model. 540 | 541 | Nesting also requires that a `_attributes=` writer method is defined in your 542 | parent model. If an object with an association is instantiated with a params hash, and that 543 | hash has a key for the association, Rails will call the `_attributes=` 544 | method on that object. Within the writer method call `assign_nested_attributes`, passing in 545 | the association name and attributes. 546 | 547 | Let's say we have a parent Recipe with Ingredient children. 548 | 549 | Start by defining within the Recipe model: 550 | * an attr_accessor of `:ingredients` 551 | * a writer method named `ingredients_attributes=` 552 | * the `validates_associated` method can be used to validate the nested objects 553 | 554 | Example: 555 | 556 | ```ruby 557 | class Recipe 558 | attr_accessor :ingredients 559 | validates :ingredients, presence: true 560 | validates_associated :ingredients 561 | 562 | def ingredients_attributes=(attributes) 563 | assign_nested_attributes(:ingredients, attributes) 564 | end 565 | end 566 | ``` 567 | 568 | You may also set a `:reject_if` proc to silently ignore any new record hashes if they fail to 569 | pass your criteria. For example: 570 | 571 | ```ruby 572 | class Recipe 573 | def ingredients_attributes=(attributes) 574 | reject_proc = proc { |attributes| attributes['name'].blank? } 575 | assign_nested_attributes(:ingredients, attributes, reject_if: reject_proc) 576 | end 577 | end 578 | ``` 579 | 580 | Alternatively,`:reject_if` also accepts a symbol for using methods: 581 | 582 | ```ruby 583 | class Recipe 584 | def ingredients_attributes=(attributes) 585 | reject_proc = proc { |attributes| attributes['name'].blank? } 586 | assign_nested_attributes(:ingredients, attributes, reject_if: reject_recipes) 587 | end 588 | 589 | def reject_recipes(attributes) 590 | attributes['name'].blank? 591 | end 592 | end 593 | ``` 594 | 595 | Within the parent model `valid?` will validate the parent and associated children and 596 | `nested_models` will return the child objects. If the nested form submitted params contained 597 | a truthy `_destroy` key, the appropriate nested_models will have `marked_for_destruction` set 598 | to True. 599 | 600 | ## Datastore Gotchas 601 | #### Ordering of query results is undefined when no sort order is specified. 602 | When a query does not specify a sort order, the results are returned in the order they are retrieved. 603 | As Cloud Datastore implementation evolves (or if a project's indexes change), this order may change. 604 | Therefore, if your application requires its query results in a particular order, be sure to specify 605 | that sort order explicitly in the query. 606 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing Active Model Datastore 2 | 3 | 1. After all pull requests have been merged open the GitHub compare view in your browser and review. 4 | 5 | `open https://github.com/Agrimatics/activemodel-datastore/compare/v...master` 6 | 7 | 2. If you haven't already, switch to the main branch, ensure that you have no changes, and pull 8 | from origin. 9 | 10 | 3. Edit the gem's version.rb file, changing the value to the new version number. 11 | 12 | 4. Run `rubocop`. The code base must have no offenses. 13 | 14 | 5. Run the gem tests with `rake test`. 15 | 16 | 6. You need to `cd test/support/datastore_example_rails_app/` and run the example Rails app tests 17 | with `rails test`. 18 | 19 | 7. Update the CHANGELOG.md. 20 | 21 | 8. Commit and push to main. 22 | 23 | 9. Run the `rake release` command. This will package the gem, a tag for the version of the release 24 | in Github and push to Rubygems. 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | task default: :test 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'test' 8 | t.test_files = FileList['test/cases/**/*_test.rb'] 9 | t.verbose = false 10 | t.warning = false 11 | end -------------------------------------------------------------------------------- /activemodel-datastore.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_model/datastore/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'activemodel-datastore' 8 | gem.version = ActiveModel::Datastore::VERSION 9 | gem.authors = ['Bryce McLean'] 10 | gem.email = ['mclean.bryce@gmail.com'] 11 | 12 | gem.summary = 'Cloud Datastore integration with Active Model' 13 | gem.description = 'Makes the google-cloud-datastore gem compliant with active_model conventions and compatible with your Rails 5+ applications.' 14 | gem.homepage = 'https://github.com/Agrimatics/activemodel-datastore' 15 | gem.license = 'MIT' 16 | 17 | gem.metadata = { 18 | "homepage_uri" => "https://github.com/Agrimatics/activemodel-datastore", 19 | "changelog_uri" => "https://github.com/Agrimatics/activemodel-datastore/blob/master/CHANGELOG.md", 20 | "source_code_uri" => "https://github.com/Agrimatics/activemodel-datastore/", 21 | "bug_tracker_uri" => "https://github.com/Agrimatics/activemodel-datastore/issues" 22 | } 23 | 24 | gem.required_ruby_version = '>= 2.2.2' 25 | 26 | gem.files = Dir['CHANGELOG.md', 'README.md', 'LICENSE.txt', 'lib/**/*'] 27 | gem.require_paths = ['lib'] 28 | 29 | gem.add_runtime_dependency 'activemodel', '>= 5.0.0' 30 | gem.add_runtime_dependency 'activesupport', '>= 5.0.0' 31 | gem.add_runtime_dependency 'google-cloud-datastore', '~> 2.0' 32 | 33 | gem.add_development_dependency 'rake' 34 | gem.add_development_dependency 'actionpack', '>= 5.0.0' 35 | gem.add_development_dependency 'factory_bot', '~> 6.1' 36 | gem.add_development_dependency 'faker', '~> 2.1', '>= 2.1.2' 37 | gem.add_development_dependency 'minitest', '~> 5.10' 38 | gem.add_development_dependency 'rubocop', '~> 1.14' 39 | gem.add_development_dependency 'carrierwave', '~> 2.1' 40 | end 41 | -------------------------------------------------------------------------------- /lib/active_model/datastore.rb: -------------------------------------------------------------------------------- 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, :enabled, :name, :role, :state 13 | # 14 | # before_validation :set_default_values 15 | # after_validation :format_values 16 | # 17 | # before_save { puts '** something can happen before save **'} 18 | # after_save { puts '** something can happen after save **'} 19 | # 20 | # validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } 21 | # validates :name, presence: true, length: { maximum: 30 } 22 | # validates :role, presence: true 23 | # 24 | # def entity_properties 25 | # %w[email enabled name role] 26 | # end 27 | # 28 | # def set_default_values 29 | # default_property_value :enabled, true 30 | # default_property_value :role, 1 31 | # end 32 | # 33 | # def format_values 34 | # format_property_value :role, :integer 35 | # end 36 | # end 37 | # 38 | # Using `attr_accessor` the attributes of the model are defined. Validations and Callbacks all work 39 | # as you would expect. However, `entity_properties` is new. Data objects in Google Cloud Datastore 40 | # are known as entities. Entities are of a kind. An entity has one or more named properties, each 41 | # of which can have one or more values. Think of them like this: 42 | # * 'Kind' (which is your table) 43 | # * 'Entity' (which is the record from the table) 44 | # * 'Property' (which is the attribute of the record) 45 | # 46 | # The `entity_properties` method defines an Array of the properties that belong to the entity in 47 | # cloud datastore. With this approach, Rails deals solely with ActiveModel objects. The objects are 48 | # converted to/from entities as needed during save/query operations. 49 | # 50 | # We have also added the ability to set default property values and type cast the format of values 51 | # for entities. 52 | # 53 | # Now on to the controller! A scaffold generated controller works out of the box: 54 | # 55 | # class UsersController < ApplicationController 56 | # before_action :set_user, only: [:show, :edit, :update, :destroy] 57 | # 58 | # def index 59 | # @users = User.all 60 | # end 61 | # 62 | # def show 63 | # end 64 | # 65 | # def new 66 | # @user = User.new 67 | # end 68 | # 69 | # def edit 70 | # end 71 | # 72 | # def create 73 | # @user = User.new(user_params) 74 | # respond_to do |format| 75 | # if @user.save 76 | # format.html { redirect_to @user, notice: 'User was successfully created.' } 77 | # else 78 | # format.html { render :new } 79 | # end 80 | # end 81 | # end 82 | # 83 | # def update 84 | # respond_to do |format| 85 | # if @user.update(user_params) 86 | # format.html { redirect_to @user, notice: 'User was successfully updated.' } 87 | # else 88 | # format.html { render :edit } 89 | # end 90 | # end 91 | # end 92 | # 93 | # def destroy 94 | # @user.destroy 95 | # respond_to do |format| 96 | # format.html { redirect_to users_url, notice: 'User was successfully destroyed.' } 97 | # end 98 | # end 99 | # 100 | # private 101 | # 102 | # def set_user 103 | # @user = User.find(params[:id]) 104 | # end 105 | # 106 | # def user_params 107 | # params.require(:user).permit(:email, :name) 108 | # end 109 | # end 110 | # 111 | module ActiveModel::Datastore 112 | extend ActiveSupport::Concern 113 | include ActiveModel::Model 114 | include ActiveModel::Dirty 115 | include ActiveModel::Validations 116 | include ActiveModel::Validations::Callbacks 117 | include ActiveModel::Datastore::ExcludedIndexes 118 | include ActiveModel::Datastore::NestedAttr 119 | include ActiveModel::Datastore::PropertyValues 120 | include ActiveModel::Datastore::TrackChanges 121 | 122 | included do 123 | private_class_method :query_options, :query_sort, :query_property_filter, :find_all_entities 124 | define_model_callbacks :save, :update, :destroy 125 | attr_accessor :id, :parent_key_id, :entity_property_values 126 | end 127 | 128 | def entity_properties 129 | [] 130 | end 131 | 132 | ## 133 | # Used to determine if the ActiveModel object belongs to an entity group. 134 | # 135 | def parent? 136 | parent_key_id.present? 137 | end 138 | 139 | ## 140 | # Used by ActiveModel for determining polymorphic routing. 141 | # 142 | def persisted? 143 | id.present? 144 | end 145 | 146 | ## 147 | # Builds the Cloud Datastore entity with attributes from the Model object. 148 | # 149 | # @param [Google::Cloud::Datastore::Key] parent An optional parent Key of the entity. 150 | # 151 | # @return [Entity] The updated Google::Cloud::Datastore::Entity. 152 | # 153 | def build_entity(parent = nil) 154 | entity = CloudDatastore.dataset.entity self.class.name, id 155 | if parent.present? 156 | raise ArgumentError, 'Must be a Key' unless parent.is_a? Google::Cloud::Datastore::Key 157 | 158 | entity.key.parent = parent 159 | elsif parent? 160 | entity.key.parent = self.class.parent_key(parent_key_id) 161 | end 162 | entity_properties.each do |attr| 163 | entity[attr] = instance_variable_get("@#{attr}") 164 | entity.exclude_from_indexes!(attr, true) if no_index_attributes.include? attr 165 | end 166 | entity 167 | end 168 | 169 | def save(parent = nil) 170 | save_entity(parent) 171 | end 172 | 173 | ## 174 | # For compatibility with libraries that require the bang method version (example, factory_bot). 175 | # 176 | def save! 177 | save_entity || raise(EntityNotSavedError, 'Failed to save the entity') 178 | end 179 | 180 | def update(params) 181 | assign_attributes(params) 182 | return unless valid? 183 | 184 | run_callbacks :update do 185 | entity = build_entity 186 | self.class.retry_on_exception? { CloudDatastore.dataset.save entity } 187 | end 188 | end 189 | 190 | def destroy 191 | run_callbacks :destroy do 192 | key = CloudDatastore.dataset.key self.class.name, id 193 | key.parent = self.class.parent_key(parent_key_id) if parent? 194 | self.class.retry_on_exception? { CloudDatastore.dataset.delete key } 195 | end 196 | end 197 | 198 | private 199 | 200 | def save_entity(parent = nil) 201 | return unless valid? 202 | 203 | run_callbacks :save do 204 | entity = build_entity(parent) 205 | success = self.class.retry_on_exception? { CloudDatastore.dataset.save entity } 206 | self.id = entity.key.id if success 207 | self.parent_key_id = entity.key.parent.id if entity.key.parent.present? 208 | success 209 | end 210 | end 211 | 212 | # Methods defined here will be class methods when 'include ActiveModel::Datastore'. 213 | module ClassMethods 214 | ## 215 | # A default parent key for specifying an ancestor path and creating an entity group. 216 | # 217 | def parent_key(parent_id) 218 | CloudDatastore.dataset.key('Parent' + name, parent_id.to_i) 219 | end 220 | 221 | ## 222 | # Retrieves an entity by id or name and by an optional parent. 223 | # 224 | # @param [Integer or String] id_or_name The id or name value of the entity Key. 225 | # @param [Google::Cloud::Datastore::Key] parent The parent Key of the entity. 226 | # 227 | # @return [Entity, nil] a Google::Cloud::Datastore::Entity object or nil. 228 | # 229 | def find_entity(id_or_name, parent = nil) 230 | key = CloudDatastore.dataset.key name, id_or_name 231 | key.parent = parent if parent.present? 232 | retry_on_exception { CloudDatastore.dataset.find key } 233 | end 234 | 235 | ## 236 | # Retrieves the entities for the provided ids by key and by an optional parent. 237 | # The find_all method returns LookupResults, which is a special case Array with 238 | # additional values. LookupResults are returned in batches, and the batch size is 239 | # determined by the Datastore API. Batch size is not guaranteed. It will be affected 240 | # by the size of the data being returned, and by other forces such as how distributed 241 | # and/or consistent the data in Datastore is. Calling `all` on the LookupResults retrieves 242 | # all results by repeatedly loading #next until #next? returns false. The `all` method 243 | # returns an enumerator unless passed a block. We iterate on the enumerator to return 244 | # the model entity objects. 245 | # 246 | # @param [Integer, String] ids_or_names One or more ids to retrieve. 247 | # @param [Google::Cloud::Datastore::Key] parent The parent Key of the entity. 248 | # 249 | # @return [Array] an array of Google::Cloud::Datastore::Entity objects. 250 | # 251 | def find_entities(*ids_or_names, parent: nil) 252 | ids_or_names = ids_or_names.flatten.compact.uniq 253 | lookup_results = find_all_entities(ids_or_names, parent) 254 | lookup_results.all.collect { |x| x } 255 | end 256 | 257 | ## 258 | # Queries entities from Cloud Datastore by named kind and using the provided options. 259 | # When a limit option is provided queries up to the limit and returns results with a cursor. 260 | # 261 | # This method may make several API calls until all query results are retrieved. The `run` 262 | # method returns a QueryResults object, which is a special case Array with additional values. 263 | # QueryResults are returned in batches, and the batch size is determined by the Datastore API. 264 | # Batch size is not guaranteed. It will be affected by the size of the data being returned, 265 | # and by other forces such as how distributed and/or consistent the data in Datastore is. 266 | # Calling `all` on the QueryResults retrieves all results by repeatedly loading #next until 267 | # #next? returns false. The `all` method returns an enumerator which from_entities iterates on. 268 | # 269 | # Be sure to use as narrow a search criteria as possible. Please use with caution. 270 | # 271 | # @param [Hash] options The options to construct the query with. 272 | # 273 | # @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results. 274 | # @option options [String] :cursor Sets the cursor to start the results at. 275 | # @option options [Integer] :limit Sets a limit to the number of results to be returned. 276 | # @option options [String] :order Sort the results by property name. 277 | # @option options [String] :desc_order Sort the results by descending property name. 278 | # @option options [Array] :select Retrieve only select properties from the matched entities. 279 | # @option options [Array] :distinct_on Group results by a list of properties. 280 | # @option options [Array] :where Adds a property filter of arrays in the format 281 | # [name, operator, value]. 282 | # 283 | # @return [Array, String] An array of ActiveModel results 284 | # 285 | # or if options[:limit] was provided: 286 | # 287 | # @return [Array, String] An array of ActiveModel results and a cursor that 288 | # can be used to query for additional results. 289 | # 290 | def all(options = {}) 291 | next_cursor = nil 292 | query = build_query(options) 293 | query_results = retry_on_exception { CloudDatastore.dataset.run query } 294 | if options[:limit] 295 | next_cursor = query_results.cursor if query_results.size == options[:limit] 296 | return from_entities(query_results.all), next_cursor 297 | end 298 | from_entities(query_results.all) 299 | end 300 | 301 | ## 302 | # Find entity by id - this can either be a specific id (1), a list of ids (1, 5, 6), 303 | # or an array of ids ([5, 6, 10]). The parent key is optional. 304 | # 305 | # @param [Integer] ids One or more ids to retrieve. 306 | # @param [Google::Cloud::Datastore::Key] parent The parent key of the entity. 307 | # 308 | # @return [Model, nil] An ActiveModel object or nil for a single id. 309 | # @return [Array] An array of ActiveModel objects for more than one id. 310 | # 311 | def find(*ids, parent: nil) 312 | expects_array = ids.first.is_a?(Array) 313 | ids = ids.flatten.compact.uniq.map(&:to_i) 314 | 315 | case ids.size 316 | when 0 317 | raise EntityError, "Couldn't find #{name} without an ID" 318 | when 1 319 | entity = find_entity(ids.first, parent) 320 | model_entity = from_entity(entity) 321 | expects_array ? [model_entity].compact : model_entity 322 | else 323 | lookup_results = find_all_entities(ids, parent) 324 | from_entities(lookup_results.all) 325 | end 326 | end 327 | 328 | ## 329 | # Finds the first entity matching the specified condition. 330 | # 331 | # @param [Hash] args In which the key is the property and the value is the value to look for. 332 | # @option args [Google::Cloud::Datastore::Key] :ancestor filter for inherited results 333 | # 334 | # @return [Model, nil] An ActiveModel object or nil. 335 | # 336 | # @example 337 | # User.find_by(name: 'Joe') 338 | # User.find_by(name: 'Bryce', ancestor: parent_key) 339 | # 340 | def find_by(args) 341 | query = CloudDatastore.dataset.query name 342 | query.ancestor(args[:ancestor]) if args[:ancestor] 343 | query.limit(1) 344 | query.where(args.keys[0].to_s, '=', args.values[0]) 345 | query_results = retry_on_exception { CloudDatastore.dataset.run query } 346 | from_entity(query_results.first) 347 | end 348 | 349 | ## 350 | # Translates an Enumerator of Datastore::Entity objects to ActiveModel::Model objects. 351 | # 352 | # Results provided by the dataset `find_all` or `run query` will be a Dataset::LookupResults or 353 | # Dataset::QueryResults object. Invoking `all` on those objects returns an enumerator. 354 | # 355 | # @param [Enumerator] entities An enumerator representing the datastore entities. 356 | # 357 | def from_entities(entities) 358 | raise ArgumentError, 'Entities param must be an Enumerator' unless entities.is_a? Enumerator 359 | 360 | entities.map { |entity| from_entity(entity) } 361 | end 362 | 363 | ## 364 | # Translates between Datastore::Entity objects and ActiveModel::Model objects. 365 | # 366 | # @param [Entity] entity Entity from Cloud Datastore. 367 | # @return [Model] The translated ActiveModel object. 368 | # 369 | def from_entity(entity) 370 | return if entity.nil? 371 | 372 | model_entity = build_model(entity) 373 | model_entity.entity_property_values = entity.properties.to_h 374 | entity.properties.to_h.each do |name, value| 375 | model_entity.send "#{name}=", value if model_entity.respond_to? "#{name}=" 376 | end 377 | model_entity.reload! 378 | model_entity 379 | end 380 | 381 | ## 382 | # Constructs a Google::Cloud::Datastore::Query. 383 | # 384 | # @param [Hash] options The options to construct the query with. 385 | # 386 | # @option options [Google::Cloud::Datastore::Key] :ancestor Filter for inherited results. 387 | # @option options [String] :cursor Sets the cursor to start the results at. 388 | # @option options [Integer] :limit Sets a limit to the number of results to be returned. 389 | # @option options [String] :order Sort the results by property name. 390 | # @option options [String] :desc_order Sort the results by descending property name. 391 | # @option options [Array] :select Retrieve only select properties from the matched entities. 392 | # @option options [Array] :distinct_on Group results by a list of properties. 393 | # @option options [Array] :where Adds a property filter of arrays in the format 394 | # [name, operator, value]. 395 | # 396 | # @return [Query] A datastore query. 397 | # 398 | def build_query(options = {}) 399 | query = CloudDatastore.dataset.query name 400 | query_options(query, options) 401 | end 402 | 403 | def retry_on_exception?(max_retry_count = 5) 404 | retries = 0 405 | sleep_time = 0.25 406 | begin 407 | yield 408 | rescue Google::Cloud::Error => e 409 | return false if retries >= max_retry_count 410 | 411 | puts "\e[33mRescued exception #{e.message.inspect}, retrying in #{sleep_time}\e[0m" 412 | # 0.25, 0.5, 1, 2, and 4 second between retries. 413 | sleep sleep_time 414 | retries += 1 415 | sleep_time *= 2 416 | retry 417 | end 418 | end 419 | 420 | def retry_on_exception(max_retry_count = 5) 421 | retries = 0 422 | sleep_time = 0.25 423 | begin 424 | yield 425 | rescue Google::Cloud::Error => e 426 | raise e if retries >= max_retry_count 427 | 428 | puts "\e[33mRescued exception #{e.message.inspect}, retrying in #{sleep_time}\e[0m" 429 | # 0.25, 0.5, 1, 2, and 4 second between retries. 430 | sleep sleep_time 431 | retries += 1 432 | sleep_time *= 2 433 | retry 434 | end 435 | end 436 | 437 | def log_google_cloud_error 438 | yield 439 | rescue Google::Cloud::Error => e 440 | puts "\e[33m[#{e.message.inspect}]\e[0m" 441 | raise e 442 | end 443 | 444 | # **************** private **************** 445 | 446 | def query_options(query, options) 447 | query.ancestor(options[:ancestor]) if options[:ancestor] 448 | query.cursor(options[:cursor]) if options[:cursor] 449 | query.limit(options[:limit]) if options[:limit] 450 | query_sort(query, options) 451 | query.select(*options[:select]) if options[:select] 452 | query.distinct_on(*options[:distinct_on]) if options[:distinct_on] 453 | query_property_filter(query, options) 454 | end 455 | 456 | ## 457 | # Adds sorting to the results by a property name if included in the options. 458 | # 459 | def query_sort(query, options) 460 | query.order(options[:order]) if options[:order] 461 | query.order(options[:desc_order], :desc) if options[:desc_order] 462 | query 463 | end 464 | 465 | ## 466 | # Adds property filters to the query if included in the options. 467 | # Accepts individual or nested Arrays: 468 | # [['superseded', '=', false], ['email', '=', 'something']] 469 | # 470 | def query_property_filter(query, options) 471 | if options[:where] 472 | opts = options[:where] 473 | if opts[0].is_a?(Array) 474 | opts.each do |opt| 475 | query.where(opt[0], opt[1], opt[2]) unless opt.nil? 476 | end 477 | else 478 | query.where(opts[0], opts[1], opts[2]) 479 | end 480 | end 481 | query 482 | end 483 | 484 | ## 485 | # Finds entities by keys using the provided array items. Results provided by the 486 | # dataset `find_all` is a Dataset::LookupResults object. 487 | # 488 | # @param [Array, Array] ids_or_names An array of ids or names. 489 | # 490 | # 491 | def find_all_entities(ids_or_names, parent) 492 | keys = ids_or_names.map { |id| CloudDatastore.dataset.key name, id } 493 | keys.map { |key| key.parent = parent } if parent.present? 494 | retry_on_exception { CloudDatastore.dataset.find_all keys } 495 | end 496 | 497 | def build_model(entity) 498 | model_entity = new 499 | model_entity.id = entity.key.id unless entity.key.id.nil? 500 | model_entity.id = entity.key.name unless entity.key.name.nil? 501 | model_entity.parent_key_id = entity.key.parent.id if entity.key.parent.present? 502 | model_entity 503 | end 504 | end 505 | end 506 | -------------------------------------------------------------------------------- /lib/active_model/datastore/carrier_wave_uploader.rb: -------------------------------------------------------------------------------- 1 | module CarrierWaveUploader 2 | include CarrierWave::Mount 3 | 4 | private 5 | 6 | def mount_base(column, uploader = nil, options = {}, &block) 7 | super 8 | 9 | # include CarrierWave::Validations::ActiveModel 10 | # 11 | # validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) 12 | # validates_processing_of column if uploader_option(column.to_sym, :validate_processing) 13 | # validates_download_of column if uploader_option(column.to_sym, :validate_download) 14 | 15 | after_save :"store_#{column}!" 16 | after_update :"store_#{column}!" 17 | after_destroy :"remove_#{column}!" 18 | 19 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 20 | ## 21 | # Override for setting the file urls on the entity. 22 | # 23 | def build_entity(parent = nil) 24 | entity = super(parent) 25 | self.class.uploaders.keys.each do |col| 26 | entity[col.to_s] = send("get_" + col.to_s + "_identifiers") 27 | end 28 | entity 29 | end 30 | 31 | ## 32 | # Override to append file names for mount_uploaders. 33 | # Works with multiple files stored as an Array. 34 | # 35 | def update(params) 36 | existing_files = {} 37 | self.class.uploaders.keys.each do |attr_name| 38 | existing_files[attr_name] = uploader_file_names(attr_name) if send(attr_name).is_a? Array 39 | end 40 | assign_attributes(params) 41 | return unless valid? 42 | run_callbacks :update do 43 | entity = build_entity 44 | self.class.uploaders.keys.each do |attr_name| 45 | entity[attr_name] = append_files(entity[attr_name], existing_files[attr_name]) 46 | end 47 | self.class.retry_on_exception? { CloudDatastore.dataset.save entity } 48 | end 49 | end 50 | 51 | ## 52 | # For new entities, set the identifiers (file names). 53 | # For deleted entities, set the identifier (which will be nil). 54 | # For updated entities, set the identifiers if they have changed. The 55 | # identifier will be nil if files were not uploaded during the update. 56 | # 57 | def get_#{column}_identifiers 58 | identifier = write_#{column}_identifier 59 | if persisted? && !remove_#{column}? && identifier.nil? 60 | if defined?(#{column}_identifier) && #{column}_identifier.present? 61 | #{column}_identifier if defined?(#{column}_identifier) && #{column}_identifier.present? 62 | elsif defined?(#{column}_identifiers) && #{column}_identifiers.present? 63 | #{column}_identifiers 64 | end 65 | else 66 | identifier 67 | end 68 | end 69 | 70 | ## 71 | # Called by CarrierWave::Mount.mount_uploaders -> write_#{column}_identifier. 72 | # 73 | def write_uploader(column, identifier) 74 | identifier 75 | end 76 | 77 | ## 78 | # This gets called whenever the uploaders instance variable is nil. 79 | # It returns the uploader identifiers (file names) for the desired column. 80 | # 81 | def read_uploader(column) 82 | if entity_property_values.present? && entity_property_values.key?(column.to_s) 83 | entity_property_values[column.to_s] 84 | end 85 | end 86 | 87 | ## 88 | # Reset cached mounter on record reload. 89 | # 90 | def reload! 91 | @_mounters = nil 92 | super 93 | end 94 | 95 | ## 96 | # Reset cached mounter on record dup. 97 | # 98 | def initialize_dup(other) 99 | @_mounters = nil 100 | super 101 | end 102 | 103 | # private 104 | 105 | def uploader_file_names(attr_name) 106 | send(attr_name).map { |x| x.file.filename } 107 | end 108 | 109 | def append_files(files, new_files) 110 | if files.is_a?(Array) && !new_files.nil? 111 | files = files.push(*new_files).flatten.compact.uniq 112 | end 113 | files 114 | end 115 | RUBY 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/active_model/datastore/connection.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Returns a Google::Cloud::Datastore::Dataset object for the configured dataset. 3 | # 4 | # The dataset instance is used to create, read, update, and delete entity objects. 5 | # 6 | # GCLOUD_PROJECT is an environment variable representing the Datastore project ID. 7 | # DATASTORE_KEYFILE_JSON is an environment variable that Datastore checks for credentials. 8 | # 9 | # ENV['GCLOUD_KEYFILE_JSON'] = '{ 10 | # "private_key": "-----BEGIN PRIVATE KEY-----\nMIIFfb3...5dmFtABy\n-----END PRIVATE KEY-----\n", 11 | # "client_email": "web-app@app-name.iam.gserviceaccount.com" 12 | # }' 13 | # 14 | module CloudDatastore 15 | if defined?(Rails) == 'constant' 16 | if Rails.env.development? 17 | ENV['DATASTORE_EMULATOR_HOST'] ||= 'localhost:8180' 18 | ENV['GCLOUD_PROJECT'] ||= 'local-datastore' 19 | elsif Rails.env.test? 20 | ENV['DATASTORE_EMULATOR_HOST'] ||= 'localhost:8181' 21 | ENV['GCLOUD_PROJECT'] ||= 'test-datastore' 22 | elsif ENV['SERVICE_ACCOUNT_PRIVATE_KEY'].present? && 23 | ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'].present? 24 | ENV['GCLOUD_KEYFILE_JSON'] ||= 25 | '{' \ 26 | '"private_key": "' + ENV['SERVICE_ACCOUNT_PRIVATE_KEY'] + '",' \ 27 | '"client_email": "' + ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'] + '",' \ 28 | '"type": "service_account"' \ 29 | '}' 30 | end 31 | end 32 | 33 | def self.dataset 34 | timeout = ENV.fetch('DATASTORE_NETWORK_TIMEOUT', 15).to_i 35 | @dataset ||= Google::Cloud.datastore(timeout: timeout) 36 | end 37 | 38 | def self.reset_dataset 39 | @dataset = nil 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/active_model/datastore/errors.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel::Datastore 2 | ## 3 | # Generic Active Model Cloud Datastore exception class. 4 | # 5 | class Error < StandardError 6 | end 7 | 8 | ## 9 | # Raised while attempting to save an invalid entity. 10 | # 11 | class EntityNotSavedError < Error 12 | end 13 | 14 | ## 15 | # Raised when an entity is not configured for tracking changes. 16 | # 17 | class TrackChangesError < Error 18 | end 19 | 20 | ## 21 | # Raised when unable to find an entity by given id or set of ids. 22 | # 23 | class EntityError < Error 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/active_model/datastore/excluded_indexes.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel::Datastore 2 | module ExcludedIndexes 3 | extend ActiveSupport::Concern 4 | 5 | def no_index_attributes 6 | [] 7 | end 8 | 9 | ## 10 | # Sets all entity properties to be included/excluded from the Datastore indexes. 11 | # 12 | def exclude_from_index(entity, boolean) 13 | entity.properties.to_h.each_key do |value| 14 | entity.exclude_from_indexes! value, boolean 15 | end 16 | end 17 | 18 | module ClassMethods 19 | ## 20 | # Sets attributes to be excluded from the Datastore indexes. 21 | # 22 | # Overrides no_index_attributes to return an Array of the attributes configured 23 | # to be indexed. 24 | # 25 | # For example, an indexed string property can not exceed 1500 bytes. String properties 26 | # that are not indexed can be up to 1,048,487 bytes. All properties indexed by default. 27 | # 28 | def no_indexes(*attributes) 29 | attributes = attributes.collect(&:to_s) 30 | define_method('no_index_attributes') { attributes } 31 | end 32 | 33 | def clear_index_exclusions! 34 | define_method('no_index_attributes') { [] } 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/active_model/datastore/nested_attr.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/hash/indifferent_access' 2 | 3 | module ActiveModel::Datastore 4 | ## 5 | # = ActiveModel Datastore Nested Attributes 6 | # 7 | # Adds support for nested attributes to ActiveModel. Heavily inspired by Rails 8 | # ActiveRecord::NestedAttributes. 9 | # 10 | # Nested attributes allow you to save attributes on associated records along with the parent. 11 | # It's used in conjunction with fields_for to build the nested form elements. 12 | # 13 | # See Rails ActionView::Helpers::FormHelper::fields_for for more info. 14 | # 15 | # *NOTE*: Unlike ActiveRecord, the way that the relationship is modeled between the parent and 16 | # child is not enforced. With NoSQL the relationship could be defined by any attribute, or with 17 | # denormalization exist within the same entity. This library provides a way for the objects to 18 | # be associated yet saved to the datastore in any way that you choose. 19 | # 20 | # You enable nested attributes by defining an +:attr_accessor+ on the parent with the pluralized 21 | # name of the child model. 22 | # 23 | # Nesting also requires that a +_attributes=+ writer method is defined in your 24 | # parent model. If an object with an association is instantiated with a params hash, and that 25 | # hash has a key for the association, Rails will call the +_attributes=+ 26 | # method on that object. Within the writer method call +assign_nested_attributes+, passing in 27 | # the association name and attributes. 28 | # 29 | # Let's say we have a parent Recipe with Ingredient children. 30 | # 31 | # Start by defining within the Recipe model: 32 | # * an attr_accessor of +:ingredients+ 33 | # * a writer method named +ingredients_attributes=+ 34 | # * the +validates_associated+ method can be used to validate the nested objects 35 | # 36 | # Example: 37 | # class Recipe 38 | # attr_accessor :ingredients 39 | # validates :ingredients, presence: true 40 | # validates_associated :ingredients 41 | # 42 | # def ingredients_attributes=(attributes) 43 | # assign_nested_attributes(:ingredients, attributes) 44 | # end 45 | # end 46 | # 47 | # You may also set a +:reject_if+ proc to silently ignore any new record hashes if they fail to 48 | # pass your criteria. For example: 49 | # 50 | # class Recipe 51 | # def ingredients_attributes=(attributes) 52 | # reject_proc = proc { |attributes| attributes['name'].blank? } 53 | # assign_nested_attributes(:ingredients, attributes, reject_if: reject_proc) 54 | # end 55 | # end 56 | # 57 | # Alternatively, +:reject_if+ also accepts a symbol for using methods: 58 | # 59 | # class Recipe 60 | # def ingredients_attributes=(attributes) 61 | # reject_proc = proc { |attributes| attributes['name'].blank? } 62 | # assign_nested_attributes(:ingredients, attributes, reject_if: reject_recipes) 63 | # end 64 | # 65 | # def reject_recipes(attributes) 66 | # attributes['name'].blank? 67 | # end 68 | # end 69 | # 70 | # Within the parent model +valid?+ will validate the parent and associated children and 71 | # +nested_models+ will return the child objects. If the nested form submitted params contained 72 | # a truthy +_destroy+ key, the appropriate nested_models will have +marked_for_destruction+ set 73 | # to True. 74 | # 75 | # Created by Bryce McLean on 2016-12-06. 76 | # 77 | module NestedAttr 78 | extend ActiveSupport::Concern 79 | include ActiveModel::Model 80 | 81 | included do 82 | attr_accessor :nested_attributes, :marked_for_destruction, :_destroy 83 | end 84 | 85 | def mark_for_destruction 86 | @marked_for_destruction = true 87 | end 88 | 89 | def marked_for_destruction? 90 | @marked_for_destruction 91 | end 92 | 93 | def nested_attributes? 94 | nested_attributes.is_a?(Array) && !nested_attributes.empty? 95 | end 96 | 97 | ## 98 | # For each attribute name in nested_attributes extract and return the nested model objects. 99 | # 100 | def nested_models 101 | model_entities = [] 102 | nested_attributes.each { |attr| model_entities << send(attr.to_sym) } if nested_attributes? 103 | model_entities.flatten 104 | end 105 | 106 | def nested_model_class_names 107 | entity_kinds = [] 108 | nested_models.each { |x| entity_kinds << x.class.name } if nested_attributes? 109 | entity_kinds.uniq 110 | end 111 | 112 | def nested_errors 113 | errors = [] 114 | if nested_attributes? 115 | nested_attributes.each do |attr| 116 | send(attr.to_sym).each { |child| errors << child.errors } 117 | end 118 | end 119 | errors 120 | end 121 | 122 | ## 123 | # Assigns the given nested child attributes. 124 | # 125 | # Attribute hashes with an +:id+ value matching an existing associated object will update 126 | # that object. Hashes without an +:id+ value will build a new object for the association. 127 | # Hashes with a matching +:id+ value and a +:_destroy+ key set to a truthy value will mark 128 | # the matched object for destruction. 129 | # 130 | # Pushes a key of the association name onto the parent object's +nested_attributes+ attribute. 131 | # The +nested_attributes+ can be used for determining when the parent has associated children. 132 | # 133 | # @param [Symbol] association_name The attribute name of the associated children. 134 | # @param [ActiveSupport::HashWithIndifferentAccess, ActionController::Parameters] attributes 135 | # The attributes provided by Rails ActionView. Typically new objects will arrive as 136 | # ActiveSupport::HashWithIndifferentAccess and updates as ActionController::Parameters. 137 | # @param [Hash] options The options to control how nested attributes are applied. 138 | # 139 | # @option options [Proc, Symbol] :reject_if Allows you to specify a Proc or a Symbol pointing 140 | # to a method that checks whether a record should be built for a certain attribute 141 | # hash. The hash is passed to the supplied Proc or the method and it should return either 142 | # +true+ or +false+. Passing +:all_blank+ instead of a Proc will create a proc 143 | # that will reject a record where all the attributes are blank. 144 | # 145 | # The following example will update the amount of the ingredient with ID 1, build a new 146 | # associated ingredient with the amount of 45, and mark the associated ingredient 147 | # with ID 2 for destruction. 148 | # 149 | # assign_nested_attributes(:ingredients, { 150 | # '0' => { id: '1', amount: '123' }, 151 | # '1' => { amount: '45' }, 152 | # '2' => { id: '2', _destroy: true } 153 | # }) 154 | # 155 | def assign_nested_attributes(association_name, attributes, options = {}) 156 | attributes = validate_attributes(attributes) 157 | association_name = association_name.to_sym 158 | send("#{association_name}=", []) if send(association_name).nil? 159 | 160 | attributes.each_value do |params| 161 | if params['id'].blank? 162 | unless reject_new_record?(params, options) 163 | new = association_name.to_c.new(params.except(*UNASSIGNABLE_KEYS)) 164 | send(association_name).push(new) 165 | end 166 | else 167 | existing = send(association_name).detect { |record| record.id.to_s == params['id'].to_s } 168 | assign_to_or_mark_for_destruction(existing, params) 169 | end 170 | end 171 | (self.nested_attributes ||= []).push(association_name) 172 | end 173 | 174 | private 175 | 176 | UNASSIGNABLE_KEYS = %w[id _destroy].freeze 177 | 178 | def validate_attributes(attributes) 179 | attributes = attributes.to_h if attributes.respond_to?(:permitted?) 180 | unless attributes.is_a?(Hash) 181 | raise ArgumentError, "Hash expected, got #{attributes.class.name} (#{attributes.inspect})" 182 | end 183 | 184 | attributes 185 | end 186 | 187 | ## 188 | # Updates an object with attributes or marks it for destruction if has_destroy_flag?. 189 | # 190 | def assign_to_or_mark_for_destruction(record, attributes) 191 | record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS)) 192 | record.mark_for_destruction if destroy_flag?(attributes) 193 | end 194 | 195 | ## 196 | # Determines if a hash contains a truthy _destroy key. 197 | # 198 | def destroy_flag?(hash) 199 | [true, 1, '1', 't', 'T', 'true', 'TRUE'].include?(hash['_destroy']) 200 | end 201 | 202 | ## 203 | # Determines if a new record should be rejected by checking if a :reject_if option 204 | # exists and evaluates to +true+. 205 | # 206 | def reject_new_record?(attributes, options) 207 | call_reject_if(attributes, options) 208 | end 209 | 210 | ## 211 | # Determines if a record with the particular +attributes+ should be rejected by calling the 212 | # reject_if Symbol or Proc (if provided in options). 213 | # 214 | # Returns false if there is a +destroy_flag+ on the attributes. 215 | # 216 | def call_reject_if(attributes, options) 217 | return false if destroy_flag?(attributes) 218 | 219 | attributes = attributes.with_indifferent_access 220 | blank_proc = proc { |attrs| attrs.all? { |_key, value| value.blank? } } 221 | options[:reject_if] = blank_proc if options[:reject_if] == :all_blank 222 | case callback = options[:reject_if] 223 | when Symbol 224 | method(callback).arity.zero? ? send(callback) : send(callback, attributes) 225 | when Proc 226 | callback.call(attributes) 227 | else 228 | false 229 | end 230 | end 231 | 232 | # Methods defined here will be class methods whenever we 'include DatastoreUtils'. 233 | module ClassMethods 234 | ## 235 | # Validates whether the associated object or objects are all valid, typically used with 236 | # nested attributes such as multi-model forms. 237 | # 238 | # NOTE: This validation will not fail if the association hasn't been assigned. If you want 239 | # to ensure that the association is both present and guaranteed to be valid, you also need 240 | # to use validates_presence_of. 241 | # 242 | def validates_associated(*attr_names) 243 | validates_with AssociatedValidator, _merge_attributes(attr_names) 244 | end 245 | end 246 | 247 | class AssociatedValidator < ActiveModel::EachValidator 248 | def validate_each(record, attribute, value) 249 | return unless Array(value).reject(&:valid?).any? 250 | 251 | record.errors.add(attribute, :invalid, **options.merge(value: value)) 252 | end 253 | end 254 | end 255 | end 256 | 257 | class Symbol 258 | def to_c 259 | to_s.singularize.camelize.constantize 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /lib/active_model/datastore/property_values.rb: -------------------------------------------------------------------------------- 1 | require 'active_model/type' 2 | 3 | module ActiveModel::Datastore 4 | module PropertyValues 5 | extend ActiveSupport::Concern 6 | 7 | ## 8 | # Sets a default value for the property if not currently set. 9 | # 10 | # Example: 11 | # default_property_value :state, 0 12 | # 13 | # is equivalent to: 14 | # self.state = state.presence || 0 15 | # 16 | # Example: 17 | # default_property_value :enabled, false 18 | # 19 | # is equivalent to: 20 | # self.enabled = false if enabled.nil? 21 | # 22 | def default_property_value(attr, value) 23 | if value.is_a?(TrueClass) || value.is_a?(FalseClass) 24 | send("#{attr.to_sym}=", value) if send(attr.to_sym).nil? 25 | else 26 | send("#{attr.to_sym}=", send(attr.to_sym).presence || value) 27 | end 28 | end 29 | 30 | ## 31 | # Converts the type of the property. 32 | # 33 | # Example: 34 | # format_property_value :weight, :float 35 | # 36 | # is equivalent to: 37 | # self.weight = weight.to_f if weight.present? 38 | # 39 | def format_property_value(attr, type) 40 | return unless send(attr.to_sym).present? 41 | 42 | case type.to_sym 43 | when :integer 44 | send("#{attr.to_sym}=", send(attr.to_sym).to_i) 45 | when :float 46 | send("#{attr.to_sym}=", send(attr.to_sym).to_f) 47 | when :boolean 48 | send("#{attr.to_sym}=", ActiveModel::Type::Boolean.new.cast(send(attr.to_sym))) 49 | else 50 | raise ArgumentError, 'Supported types are :boolean, :integer, :float' 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/active_model/datastore/track_changes.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel::Datastore 2 | module TrackChanges 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | attr_accessor :exclude_from_save 7 | end 8 | 9 | def tracked_attributes 10 | [] 11 | end 12 | 13 | ## 14 | # Resets the ActiveModel::Dirty tracked changes. 15 | # 16 | def reload! 17 | clear_changes_information 18 | self.exclude_from_save = false 19 | end 20 | 21 | def exclude_from_save? 22 | @exclude_from_save.nil? ? false : @exclude_from_save 23 | end 24 | 25 | ## 26 | # Determines if any attribute values have changed using ActiveModel::Dirty. 27 | # For attributes enabled for change tracking compares changed values. All values 28 | # submitted from an HTML form are strings, thus a string of 25.0 doesn't match an 29 | # original float of 25.0. Call this method after valid? to allow for any type coercing 30 | # occurring before saving to datastore. 31 | # 32 | # Consider the scenario in which the user submits an unchanged form value named `area`. 33 | # The initial value from datastore is a float of 25.0, which during assign_attributes 34 | # is set to a string of '25.0'. It is then coerced back to a float of 25.0 during a 35 | # validation callback. The area_changed? will return true, yet the value is back where 36 | # is started. 37 | # 38 | # For example: 39 | # 40 | # class Shapes 41 | # include ActiveModel::Datastore 42 | # 43 | # attr_accessor :area 44 | # enable_change_tracking :area 45 | # after_validation :format_values 46 | # 47 | # def format_values 48 | # format_property_value :area, :float 49 | # end 50 | # 51 | # def update(params) 52 | # assign_attributes(params) 53 | # if valid? 54 | # puts values_changed? 55 | # puts area_changed? 56 | # p area_change 57 | # end 58 | # end 59 | # end 60 | # 61 | # Will result in this: 62 | # values_changed? false 63 | # area_changed? true # This is correct, as area was changed but the value is identical. 64 | # area_change [0, 0] 65 | # 66 | # If none of the tracked attributes have changed, the `exclude_from_save` attribute is 67 | # set to true and the method returns false. 68 | # 69 | def values_changed? 70 | unless tracked_attributes.present? 71 | raise TrackChangesError, 'Object has not been configured for change tracking.' 72 | end 73 | 74 | changed = marked_for_destruction? ? true : false 75 | tracked_attributes.each do |attr| 76 | break if changed 77 | 78 | changed = send(attr) != send("#{attr}_was") if send("#{attr}_changed?") 79 | end 80 | self.exclude_from_save = !changed 81 | changed 82 | end 83 | 84 | def remove_unmodified_children 85 | return unless tracked_attributes.present? && nested_attributes? 86 | 87 | nested_attributes.each do |attr| 88 | with_changes = Array(send(attr.to_sym)).select(&:values_changed?) 89 | send("#{attr}=", with_changes) 90 | end 91 | nested_attributes.delete_if { |attr| Array(send(attr.to_sym)).empty? } 92 | end 93 | 94 | module ClassMethods 95 | ## 96 | # Enables track changes functionality for the provided attributes using ActiveModel::Dirty. 97 | # 98 | # Calls define_attribute_methods for each attribute provided. 99 | # 100 | # Creates a setter for each attribute that will look something like this: 101 | # def name=(value) 102 | # name_will_change! unless value == @name 103 | # @name = value 104 | # end 105 | # 106 | # Overrides tracked_attributes to return an Array of the attributes configured for tracking. 107 | # 108 | def enable_change_tracking(*attributes) 109 | attributes = attributes.collect(&:to_sym) 110 | attributes.each do |attr| 111 | define_attribute_methods attr 112 | 113 | define_method("#{attr}=") do |value| 114 | send("#{attr}_will_change!") unless value == instance_variable_get("@#{attr}") 115 | instance_variable_set("@#{attr}", value) 116 | end 117 | end 118 | 119 | define_method('tracked_attributes') { attributes } 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/active_model/datastore/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel 2 | module Datastore 3 | VERSION = '0.8.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/activemodel/datastore.rb: -------------------------------------------------------------------------------- 1 | # Need this file for Bundler auto-require based on gem name. 2 | 3 | require 'google/cloud/datastore' 4 | require 'active_support' 5 | require 'active_support/concern' 6 | require 'active_model' 7 | 8 | require 'active_model/datastore/connection' 9 | require 'active_model/datastore/errors' 10 | require 'active_model/datastore/excluded_indexes' 11 | require 'active_model/datastore/nested_attr' 12 | require 'active_model/datastore/property_values' 13 | require 'active_model/datastore/track_changes' 14 | require 'active_model/datastore' 15 | -------------------------------------------------------------------------------- /test/cases/active_model_compliance_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ActiveModelComplianceTest < ActiveSupport::TestCase 4 | include ActiveModel::Lint::Tests 5 | 6 | def setup 7 | @model = MockModel.new 8 | end 9 | 10 | def teardown 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/cases/callbacks_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CallbacksTest < ActiveSupport::TestCase 4 | def setup 5 | super 6 | @mock_model = MockModel.new 7 | @mock_model.name = 'Initial Name' 8 | end 9 | 10 | test 'before validation callback on save' do 11 | class << @mock_model 12 | before_validation { self.name = nil } 13 | end 14 | refute @mock_model.save 15 | assert_nil @mock_model.name 16 | assert_equal 0, MockModel.count_test_entities 17 | end 18 | 19 | test 'after validation callback on save' do 20 | class << @mock_model 21 | after_validation { self.name = nil } 22 | end 23 | assert @mock_model.save 24 | assert_nil @mock_model.name 25 | assert_equal 1, MockModel.count_test_entities 26 | end 27 | 28 | test 'before save callback' do 29 | class << @mock_model 30 | before_save { self.name = 'Name changed before save' } 31 | end 32 | assert @mock_model.save 33 | assert_equal 'Name changed before save', @mock_model.name 34 | assert_equal 'Name changed before save', MockModel.all.first.name 35 | end 36 | 37 | test 'after save callback' do 38 | class << @mock_model 39 | after_save { self.name = 'Name changed after save' } 40 | end 41 | assert @mock_model.save 42 | assert_equal 'Name changed after save', @mock_model.name 43 | assert_equal 'Initial Name', MockModel.all.first.name 44 | end 45 | 46 | test 'before validation callback on update' do 47 | class << @mock_model 48 | before_validation { self.name = nil } 49 | end 50 | refute @mock_model.update(name: 'Different Name') 51 | assert_nil @mock_model.name 52 | end 53 | 54 | test 'after validation callback on update' do 55 | class << @mock_model 56 | after_validation { self.name = nil } 57 | end 58 | assert @mock_model.update(name: 'Different Name') 59 | assert_nil @mock_model.name 60 | end 61 | 62 | test 'before update callback' do 63 | class << @mock_model 64 | before_update { self.name = 'Name changed before update' } 65 | end 66 | assert @mock_model.update(name: 'This name should get changed') 67 | assert_equal 'Name changed before update', @mock_model.name 68 | assert_equal 'Name changed before update', MockModel.all.first.name 69 | end 70 | 71 | test 'after update callback' do 72 | class << @mock_model 73 | after_update { self.name = 'Name changed after update' } 74 | end 75 | assert @mock_model.update(name: 'This name should make it into datastore') 76 | assert_equal 'Name changed after update', @mock_model.name 77 | assert_equal 'This name should make it into datastore', MockModel.all.first.name 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/cases/carrier_wave_uploader_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CarrierWaveUploaderTest < ActiveSupport::TestCase 4 | def setup 5 | super 6 | MockModel.send(:extend, CarrierWaveUploader) 7 | MockModel.send(:attr_accessor, :image) 8 | MockModel.send(:attr_accessor, :images) 9 | @mock_model = MockModel.new(name: 'A Mock Model') 10 | @uploader = Class.new(CarrierWave::Uploader::Base) 11 | image_path = File.join(Dir.pwd, 'test', 'images') 12 | @image_1 = File.join(image_path, 'test-image-1.jpg') 13 | @image_2 = File.join(image_path, 'test-image-2.jpeg') 14 | @image_3 = File.join(image_path, 'test-image-3.png') 15 | end 16 | 17 | test 'should return blank uploader when nothing' do 18 | MockModel.mount_uploader(:image, @uploader) 19 | assert @mock_model.image.blank? 20 | end 21 | 22 | test 'should return blank uploader when empty string' do 23 | MockModel.mount_uploader(:image, @uploader) 24 | @mock_model.image = '' 25 | @mock_model.save 26 | mock_model = MockModel.all.first 27 | assert_instance_of @uploader, mock_model.image 28 | assert mock_model.image.blank? 29 | end 30 | 31 | test 'should retrieve file from storage' do 32 | MockModel.mount_uploader(:image, @uploader) 33 | create_with_image(@image_1) 34 | mock_model = MockModel.all.first 35 | validate_image(mock_model, 'test-image-1.jpg') 36 | end 37 | 38 | test 'should copy a file into the cache directory' do 39 | MockModel.mount_uploader(:image, @uploader) 40 | @mock_model.image = Rack::Test::UploadedFile.new(@image_1, 'image/png') 41 | assert_match '/tmp/carrierwave-tests/carrierwave-cache/', @mock_model.image.current_path 42 | end 43 | 44 | test 'should set the file url on the entity' do 45 | MockModel.mount_uploader(:image, @uploader) 46 | create_with_image(@image_1) 47 | query = CloudDatastore.dataset.query 'MockModel' 48 | entity = CloudDatastore.dataset.run(query).first 49 | assert_equal 'test-image-1.jpg', entity['image'] 50 | end 51 | 52 | test 'should retrieve files from storage' do 53 | MockModel.mount_uploaders(:images, @uploader) 54 | create_with_images(@image_1, @image_2) 55 | mock_model = MockModel.all.first 56 | validate_images(mock_model, 'test-image-1.jpg', 'test-image-2.jpeg') 57 | end 58 | 59 | test 'should set an array of file identifiers' do 60 | MockModel.mount_uploaders(:images, @uploader) 61 | create_with_images(@image_1, @image_2) 62 | query = CloudDatastore.dataset.query 'MockModel' 63 | entity = CloudDatastore.dataset.run(query).first 64 | assert entity['images'].is_a? Array 65 | assert_includes entity['images'], 'test-image-1.jpg' 66 | assert_includes entity['images'], 'test-image-2.jpeg' 67 | end 68 | 69 | test 'should retrieve files with multiple uploaders' do 70 | MockModel.mount_uploader(:image, @uploader) 71 | MockModel.mount_uploaders(:images, @uploader) 72 | @mock_model.image = Rack::Test::UploadedFile.new(@image_1, 'image/png') 73 | create_with_images(@image_2, @image_3) 74 | mock_model = MockModel.all.first 75 | validate_image(mock_model, 'test-image-1.jpg') 76 | validate_images(mock_model, 'test-image-2.jpeg', 'test-image-3.png') 77 | end 78 | 79 | test 'should update file' do 80 | MockModel.mount_uploader(:image, @uploader) 81 | create_with_image(@image_1) 82 | @mock_model.update(image: Rack::Test::UploadedFile.new(@image_2, 'image/png')) 83 | mock_model = MockModel.all.first 84 | validate_image(mock_model, 'test-image-2.jpeg') 85 | end 86 | 87 | test 'should update files' do 88 | MockModel.mount_uploaders(:images, @uploader) 89 | create_with_images(@image_2, @image_3) 90 | images = [Rack::Test::UploadedFile.new(@image_1, 'image/png')] 91 | @mock_model.update(images: images) 92 | mock_model = MockModel.all.first 93 | validate_images(mock_model, 'test-image-1.jpg', 'test-image-2.jpeg', 'test-image-3.png') 94 | end 95 | 96 | test 'should update file with multiple uploaders' do 97 | MockModel.mount_uploader(:image, @uploader) 98 | MockModel.mount_uploaders(:images, @uploader) 99 | create_with_image(@image_1) 100 | @mock_model.update(image: Rack::Test::UploadedFile.new(@image_2, 'image/png')) 101 | mock_model = MockModel.all.first 102 | validate_image(mock_model, 'test-image-2.jpeg') 103 | end 104 | 105 | test 'should update files with multiple uploaders' do 106 | MockModel.mount_uploader(:image, @uploader) 107 | MockModel.mount_uploaders(:images, @uploader) 108 | create_with_images(@image_3) 109 | @mock_model.update(images: [Rack::Test::UploadedFile.new(@image_1, 'image/png')]) 110 | mock_model = MockModel.all.first 111 | validate_images(mock_model, 'test-image-1.jpg', 'test-image-3.png') 112 | end 113 | 114 | test 'should retain file when not changed' do 115 | MockModel.mount_uploader(:image, @uploader) 116 | create_with_image(@image_2) 117 | @mock_model.update(name: 'No image changes') 118 | mock_model = MockModel.all.first 119 | validate_image(mock_model, 'test-image-2.jpeg') 120 | end 121 | 122 | test 'should retain files when not changed' do 123 | MockModel.mount_uploaders(:images, @uploader) 124 | create_with_images(@image_1, @image_2, @image_3) 125 | @mock_model.update(name: 'No image changes') 126 | mock_model = MockModel.all.first 127 | validate_images(mock_model, 'test-image-1.jpg', 'test-image-2.jpeg', 'test-image-3.png') 128 | end 129 | 130 | test 'deleting entity should delete file' do 131 | MockModel.mount_uploader(:image, @uploader) 132 | create_with_image(@image_1) 133 | @mock_model.destroy 134 | assert_equal 0, Dir[File.join(Dir.pwd, 'tmp', 'carrierwave-tests', 'uploads', '*')].size 135 | end 136 | 137 | private 138 | 139 | def create_with_image(file) 140 | @mock_model.image = Rack::Test::UploadedFile.new(file, 'image/png') 141 | @mock_model.save 142 | @mock_model = MockModel.find(@mock_model.id) 143 | end 144 | 145 | def create_with_images(*files) 146 | images = files.map { |file| Rack::Test::UploadedFile.new(file, 'image/png') } 147 | @mock_model.images = images 148 | @mock_model.save 149 | @mock_model = MockModel.find(@mock_model.id) 150 | end 151 | 152 | def validate_image(mock_model, image_name) 153 | assert_instance_of @uploader, mock_model.image 154 | assert_equal "/uploads/#{image_name}", mock_model.image.url 155 | end 156 | 157 | def validate_images(mock_model, *image_names) 158 | assert mock_model.images.is_a? Array 159 | assert_equal image_names.size, mock_model.images.size 160 | urls = mock_model.images.map(&:url) 161 | image_names.each { |name| assert_includes urls, "/uploads/#{name}" } 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /test/cases/datastore_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ActiveModel::DatastoreTest < ActiveSupport::TestCase 4 | # Packaging tests. 5 | 6 | test 'test that it has a version number' do 7 | refute_nil ::ActiveModel::Datastore::VERSION 8 | end 9 | 10 | # Instance method tests. 11 | 12 | test 'entity properties' do 13 | class MockModelNoAttr 14 | include ActiveModel::Datastore 15 | end 16 | mock_model = MockModelNoAttr.new 17 | assert_equal [], mock_model.entity_properties 18 | end 19 | 20 | test 'parent?' do 21 | mock_model = MockModel.new 22 | refute mock_model.parent? 23 | mock_model.parent_key_id = 12345 24 | assert mock_model.parent? 25 | end 26 | 27 | test 'persisted?' do 28 | mock_model = MockModel.new 29 | refute mock_model.persisted? 30 | mock_model.id = 1 31 | assert mock_model.persisted? 32 | end 33 | 34 | test 'build entity' do 35 | mock_model = MockModel.new(name: 'Entity Test') 36 | entity = mock_model.build_entity 37 | assert_equal 'Entity Test', entity.properties['name'] 38 | assert_equal 'MockModel', entity.key.kind 39 | assert_nil entity.key.id 40 | assert_nil entity.key.name 41 | assert_nil entity.key.parent 42 | end 43 | 44 | test 'build existing entity' do 45 | mock_model = MockModel.new(name: 'Entity Test') 46 | mock_model.id = 12345 47 | entity = mock_model.build_entity 48 | assert_equal 'Entity Test', entity.properties['name'] 49 | assert_equal 'MockModel', entity.key.kind 50 | assert_equal 12345, entity.key.id 51 | assert_nil entity.key.name 52 | assert_nil entity.key.parent 53 | end 54 | 55 | test 'build entity with parent' do 56 | mock_model = MockModel.new(name: 'Entity Test') 57 | parent_key = CloudDatastore.dataset.key('Parent', 212121) 58 | entity = mock_model.build_entity(parent_key) 59 | assert_equal 'Entity Test', entity.properties['name'] 60 | assert_equal 'MockModel', entity.key.kind 61 | assert_nil entity.key.id 62 | assert_equal 'Parent', entity.key.parent.kind 63 | assert_equal 212121, entity.key.parent.id 64 | end 65 | 66 | test 'build entity with parent key id' do 67 | mock_model = MockModel.new(name: 'Entity Test', parent_key_id: MOCK_PARENT_ID) 68 | entity = mock_model.build_entity 69 | assert_equal 'Entity Test', entity.properties['name'] 70 | assert_equal 'MockModel', entity.key.kind 71 | assert_nil entity.key.id 72 | assert_nil entity.key.name 73 | assert_nil entity.key.id 74 | assert_equal 'ParentMockModel', entity.key.parent.kind 75 | assert_equal MOCK_PARENT_ID, entity.key.parent.id 76 | end 77 | 78 | test 'build entity with index exclusion' do 79 | MockModel.no_indexes :name 80 | name = Faker::Lorem.characters(number: 1600) 81 | mock_model = MockModel.new(name: name) 82 | mock_model.save 83 | entity = mock_model.build_entity 84 | assert_equal name, entity.properties['name'] 85 | assert entity.exclude_from_indexes? 'name' 86 | assert entity.exclude_from_indexes? :name 87 | refute entity.exclude_from_indexes? :role 88 | end 89 | 90 | test 'save' do 91 | count = MockModel.count_test_entities 92 | mock_model = MockModel.new 93 | refute mock_model.save 94 | assert_equal count, MockModel.count_test_entities 95 | mock_model = MockModel.new(name: 'Save Test') 96 | assert mock_model.save 97 | assert_equal count + 1, MockModel.count_test_entities 98 | assert_not_nil mock_model.id 99 | assert_nil mock_model.parent_key_id 100 | end 101 | 102 | test 'save with parent' do 103 | count = MockModel.count_test_entities 104 | parent_key = CloudDatastore.dataset.key('Company', MOCK_PARENT_ID) 105 | mock_model = MockModel.new(name: 'Save Test') 106 | assert mock_model.save(parent_key) 107 | assert_equal count + 1, MockModel.count_test_entities 108 | assert_not_nil mock_model.id 109 | assert_equal MOCK_PARENT_ID, mock_model.parent_key_id 110 | key = CloudDatastore.dataset.key 'MockModel', mock_model.id 111 | key.parent = parent_key 112 | entity = CloudDatastore.dataset.find key 113 | assert_equal mock_model.id, entity.key.id 114 | assert_equal 'MockModel', entity.key.kind 115 | assert_equal 'Company', entity.key.parent.kind 116 | assert_equal MOCK_PARENT_ID, entity.key.parent.id 117 | end 118 | 119 | test 'save within default entity group' do 120 | count = MockModel.count_test_entities 121 | mock_model = MockModel.new(name: 'Ancestor Test', parent_key_id: MOCK_PARENT_ID) 122 | assert mock_model.save 123 | assert_equal count + 1, MockModel.count_test_entities 124 | assert_not_nil mock_model.id 125 | key = CloudDatastore.dataset.key 'MockModel', mock_model.id 126 | key.parent = CloudDatastore.dataset.key('ParentMockModel', MOCK_PARENT_ID) 127 | entity = CloudDatastore.dataset.find key 128 | assert_equal mock_model.id, entity.key.id 129 | assert_equal 'MockModel', entity.key.kind 130 | assert_equal 'ParentMockModel', entity.key.parent.kind 131 | assert_equal MOCK_PARENT_ID, entity.key.parent.id 132 | end 133 | 134 | test 'update' do 135 | mock_model = create(:mock_model) 136 | id = mock_model.id 137 | count = MockModel.count_test_entities 138 | mock_model.update(name: 'different name') 139 | assert_equal id, mock_model.id 140 | assert_equal count, MockModel.count_test_entities 141 | key = CloudDatastore.dataset.key 'MockModel', mock_model.id 142 | entity = CloudDatastore.dataset.find key 143 | assert_equal id, entity.key.id 144 | assert_equal 'MockModel', entity.key.kind 145 | assert_nil entity.key.parent 146 | assert_equal 'different name', entity['name'] 147 | end 148 | 149 | test 'update within entity group' do 150 | mock_model = create(:mock_model, parent_key_id: MOCK_PARENT_ID) 151 | id = mock_model.id 152 | count = MockModel.count_test_entities 153 | mock_model.update(name: 'different name') 154 | assert_equal id, mock_model.id 155 | assert_equal count, MockModel.count_test_entities 156 | key = CloudDatastore.dataset.key 'MockModel', mock_model.id 157 | key.parent = CloudDatastore.dataset.key('ParentMockModel', MOCK_PARENT_ID) 158 | entity = CloudDatastore.dataset.find key 159 | assert_equal id, entity.key.id 160 | assert_equal 'MockModel', entity.key.kind 161 | assert_equal 'ParentMockModel', entity.key.parent.kind 162 | assert_equal 'different name', entity['name'] 163 | end 164 | 165 | test 'destroy' do 166 | mock_model = create(:mock_model) 167 | count = MockModel.count_test_entities 168 | mock_model.destroy 169 | assert_equal count - 1, MockModel.count_test_entities 170 | end 171 | 172 | test 'destroy within entity group' do 173 | mock_model = create(:mock_model, parent_key_id: MOCK_PARENT_ID) 174 | count = MockModel.count_test_entities 175 | mock_model.destroy 176 | assert_equal count - 1, MockModel.count_test_entities 177 | end 178 | 179 | # Class method tests. 180 | test 'parent key' do 181 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 182 | assert parent_key.is_a? Google::Cloud::Datastore::Key 183 | assert_equal 'ParentMockModel', parent_key.kind 184 | assert_equal MOCK_PARENT_ID, parent_key.id 185 | end 186 | 187 | test 'all' do 188 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 189 | 15.times do 190 | create(:mock_model, name: Faker::Name.name) 191 | end 192 | 15.times do 193 | attr = attributes_for(:mock_model, name: Faker::Name.name, parent_key_id: MOCK_PARENT_ID) 194 | mock_model = MockModel.new(attr) 195 | mock_model.save 196 | end 197 | objects = MockModel.all 198 | assert_equal 30, objects.size 199 | objects = MockModel.all(ancestor: parent_key) 200 | assert_equal 15, objects.size 201 | name = objects[5].name 202 | objects = MockModel.all(ancestor: parent_key, where: ['name', '=', name]) 203 | assert_equal 1, objects.size 204 | assert_equal name, objects.first.name 205 | assert objects.first.is_a?(MockModel) 206 | end 207 | 208 | test 'find in batches' do 209 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 210 | 10.times do 211 | attr = attributes_for(:mock_model, name: Faker::Name.name, parent_key_id: MOCK_PARENT_ID) 212 | mock_model = MockModel.new(attr) 213 | mock_model.save 214 | end 215 | attr = attributes_for(:mock_model, name: 'MockModel', parent_key_id: MOCK_PARENT_ID) 216 | mock_model = MockModel.new(attr) 217 | mock_model.save 218 | create(:mock_model, name: 'MockModel No Ancestor') 219 | objects = MockModel.all 220 | assert_equal MockModel, objects.first.class 221 | assert_equal 12, objects.count 222 | objects = MockModel.all(ancestor: parent_key) 223 | assert_equal 11, objects.count 224 | objects, start_cursor = MockModel.all(ancestor: parent_key, limit: 7) 225 | assert_equal 7, objects.count 226 | refute_nil start_cursor # requested 7 results and there are 4 more 227 | objects = MockModel.all(ancestor: parent_key, cursor: start_cursor) 228 | assert_equal 4, objects.count 229 | objects, cursor = MockModel.all(ancestor: parent_key, cursor: start_cursor, limit: 5) 230 | assert_equal 4, objects.count 231 | assert_nil cursor # query started where we left off, requested 5 results and there were 4 more 232 | objects, cursor = MockModel.all(ancestor: parent_key, cursor: start_cursor, limit: 4) 233 | assert_equal 4, objects.count 234 | refute_nil cursor # query started where we left off, requested 4 results and there were 4 more 235 | objects = MockModel.all(ancestor: parent_key, where: ['name', '=', mock_model.name]) 236 | assert_equal 1, objects.count 237 | objects, _cursor = MockModel.all(ancestor: parent_key, select: 'name', limit: 1) 238 | assert_equal 1, objects.count 239 | refute_nil objects.first.name 240 | end 241 | 242 | test 'find entity' do 243 | mock_model_1 = create(:mock_model, name: 'Entity 1') 244 | entity = MockModel.find_entity(mock_model_1.id) 245 | assert entity.is_a?(Google::Cloud::Datastore::Entity) 246 | assert_equal 'Entity 1', entity.properties['name'] 247 | assert_equal 'Entity 1', entity['name'] 248 | assert_equal 'Entity 1', entity[:name] 249 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 250 | attr = attributes_for(:mock_model, name: 'Entity 2', parent_key_id: MOCK_PARENT_ID) 251 | mock_model_2 = MockModel.new(attr) 252 | mock_model_2.save 253 | entity = MockModel.find_entity(mock_model_2.id) 254 | assert_nil entity 255 | entity = MockModel.find_entity(mock_model_2.id, parent_key) 256 | assert entity.is_a?(Google::Cloud::Datastore::Entity) 257 | assert_equal 'Entity 2', entity.properties['name'] 258 | assert_nil MockModel.find_entity(mock_model_2.id + 1) 259 | end 260 | 261 | test 'find entities' do 262 | mock_model_1 = create(:mock_model, name: 'Entity 1') 263 | mock_model_2 = create(:mock_model, name: 'Entity 2') 264 | entities = MockModel.find_entities(mock_model_1.id, mock_model_2.id) 265 | assert_equal 2, entities.size 266 | entities.each { |entity| assert entity.is_a?(Google::Cloud::Datastore::Entity) } 267 | assert_equal 'Entity 1', entities[0][:name] 268 | assert_equal 'Entity 2', entities[1][:name] 269 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 270 | attr = attributes_for(:mock_model, name: 'Entity 3', parent_key_id: MOCK_PARENT_ID) 271 | mock_model_3 = MockModel.new(attr) 272 | mock_model_3.save 273 | entities = MockModel.find_entities([mock_model_1.id, mock_model_2.id, mock_model_3.id]) 274 | assert_equal 2, entities.size 275 | entities = MockModel.find_entities(mock_model_2.id, mock_model_3.id, parent: parent_key) 276 | assert_equal 1, entities.size 277 | assert_equal 'Entity 3', entities[0][:name] 278 | assert_empty MockModel.find_entities(mock_model_3.id + 1) 279 | end 280 | 281 | test 'find entities should exclude duplicates' do 282 | mock_model_1 = create(:mock_model, name: 'Entity 1') 283 | entities = MockModel.find_entities(mock_model_1.id, mock_model_1.id, mock_model_1.id) 284 | assert_equal 1, entities.size 285 | end 286 | 287 | test 'find entities should exclude nil ids' do 288 | mock_model_1 = create(:mock_model, name: 'Entity 1') 289 | entities = MockModel.find_entities(mock_model_1.id, nil) 290 | assert_equal 1, entities.size 291 | end 292 | 293 | test 'find' do 294 | mock_model = create(:mock_model, name: 'Entity') 295 | model_entity = MockModel.find(mock_model.id) 296 | assert model_entity.is_a?(MockModel) 297 | assert_equal 'Entity', model_entity.name 298 | end 299 | 300 | test 'find by parent' do 301 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 302 | attr = attributes_for(:mock_model, name: 'Entity With Parent', parent_key_id: MOCK_PARENT_ID) 303 | mock_model = MockModel.new(attr) 304 | mock_model.save 305 | model_entity = MockModel.find(mock_model.id, parent: parent_key) 306 | assert model_entity.is_a?(MockModel) 307 | assert_equal 'Entity With Parent', model_entity.name 308 | end 309 | 310 | test 'find all by parent' do 311 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 312 | attr = attributes_for(:mock_model, name: 'Entity 1 With Parent', parent_key_id: MOCK_PARENT_ID) 313 | mock_model_1 = MockModel.new(attr) 314 | mock_model_1.save 315 | attr = attributes_for(:mock_model, name: 'Entity 2 With Parent', parent_key_id: MOCK_PARENT_ID) 316 | mock_model_2 = MockModel.new(attr) 317 | mock_model_2.save 318 | model_entities = MockModel.find(mock_model_1.id, mock_model_2.id, parent: parent_key) 319 | model_entities.each { |model| assert model.is_a?(MockModel) } 320 | assert_equal 'Entity 1 With Parent', model_entities[0].name 321 | assert_equal 'Entity 2 With Parent', model_entities[1].name 322 | end 323 | 324 | test 'find without result' do 325 | assert_nil MockModel.find(99999) 326 | assert_empty MockModel.find([99999]) 327 | end 328 | 329 | test 'find without results' do 330 | assert_empty MockModel.find(99999, 88888) 331 | assert_empty MockModel.find([99999, 88888]) 332 | end 333 | 334 | test 'find by' do 335 | model_entity = MockModel.find_by(name: 'Billy Bob') 336 | assert_nil model_entity 337 | create(:mock_model, name: 'Billy Bob') 338 | model_entity = MockModel.find_by(name: 'Billy Bob') 339 | assert model_entity.is_a?(MockModel) 340 | assert_equal 'Billy Bob', model_entity.name 341 | parent_key = MockModel.parent_key(MOCK_PARENT_ID) 342 | attr = attributes_for(:mock_model, name: 'Entity With Parent', parent_key_id: MOCK_PARENT_ID) 343 | mock_model_2 = MockModel.new(attr) 344 | mock_model_2.save 345 | model_entity = MockModel.find_by(name: 'Billy Bob') 346 | assert_equal 'Billy Bob', model_entity.name 347 | model_entity = MockModel.find_by(name: 'Billy Bob', ancestor: parent_key) 348 | assert_nil model_entity 349 | model_entity = MockModel.find_by(name: 'Entity With Parent', ancestor: parent_key) 350 | assert_equal 'Entity With Parent', model_entity.name 351 | end 352 | 353 | test 'from_entity' do 354 | entity = CloudDatastore.dataset.entity 355 | key = CloudDatastore.dataset.key('MockEntity', '12345') 356 | key.parent = CloudDatastore.dataset.key('Parent', 11111) 357 | entity.key = key 358 | entity['name'] = 'A Mock Entity' 359 | entity['role'] = 1 360 | assert_nil MockModel.from_entity(nil) 361 | model_entity = MockModel.from_entity(entity) 362 | assert model_entity.is_a?(MockModel) 363 | refute model_entity.role_changed? 364 | assert model_entity.entity_property_values.is_a? Hash 365 | assert_equal model_entity.entity_property_values['name'], 'A Mock Entity' 366 | end 367 | 368 | test 'build query' do 369 | query = MockModel.build_query(kind: 'MockModel') 370 | assert query.instance_of?(Google::Cloud::Datastore::Query) 371 | grpc = query.to_grpc 372 | assert_equal 'MockModel', grpc.kind[0].name 373 | assert_nil grpc.filter 374 | assert_nil grpc.limit 375 | assert_equal '', grpc.start_cursor 376 | assert_empty grpc.projection 377 | grpc = MockModel.build_query(where: ['name', '=', 'something']).to_grpc 378 | refute_nil grpc.filter 379 | grpc = MockModel.build_query(limit: 5).to_grpc 380 | refute_nil grpc.limit 381 | assert_equal 5, grpc.limit.value 382 | grpc = MockModel.build_query(select: 'name').to_grpc 383 | refute_nil grpc.projection 384 | assert_equal 1, grpc.projection.count 385 | grpc = MockModel.build_query(select: %w[name role]).to_grpc 386 | refute_nil grpc.projection 387 | assert_equal 2, grpc.projection.count 388 | grpc = MockModel.build_query(distinct_on: 'name').to_grpc 389 | refute_nil grpc.distinct_on 390 | assert_equal 1, grpc.distinct_on.count 391 | assert_equal 'name', grpc.distinct_on.first.name 392 | grpc = MockModel.build_query(distinct_on: %w[name role]).to_grpc 393 | refute_nil grpc.distinct_on 394 | assert_equal 2, grpc.distinct_on.count 395 | assert_equal 'role', grpc.distinct_on.last.name 396 | grpc = MockModel.build_query(cursor: 'a_cursor').to_grpc 397 | refute_nil grpc.start_cursor 398 | parent_int_key = CloudDatastore.dataset.key('Parent', MOCK_PARENT_ID) 399 | grpc = MockModel.build_query(ancestor: parent_int_key).to_grpc 400 | ancestor_filter = grpc.filter.composite_filter.filters.first 401 | assert_equal '__key__', ancestor_filter.property_filter.property.name 402 | assert_equal :HAS_ANCESTOR, ancestor_filter.property_filter.op 403 | key = ancestor_filter.property_filter.value.key_value.path[0] 404 | assert_equal parent_int_key.kind, key.kind 405 | assert_equal parent_int_key.id, key.id 406 | assert_equal key.id_type, :id 407 | parent_string_key = CloudDatastore.dataset.key('Parent', 'ABCDEF') 408 | grpc = MockModel.build_query(ancestor: parent_string_key).to_grpc 409 | ancestor_filter = grpc.filter.composite_filter.filters.first 410 | key = ancestor_filter.property_filter.value.key_value.path[0] 411 | assert_equal parent_string_key.kind, key.kind 412 | assert_equal key.id_type, :name 413 | assert_equal parent_string_key.name, key.name 414 | end 415 | end 416 | -------------------------------------------------------------------------------- /test/cases/excluded_indexes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ExcludedIndexesTest < ActiveSupport::TestCase 4 | test 'responds to no index attributes' do 5 | mock_model = MockModel.new 6 | assert mock_model.respond_to? :no_index_attributes 7 | assert_empty mock_model.no_index_attributes 8 | end 9 | 10 | test 'excludes index of single attribute' do 11 | MockModel.no_indexes :name 12 | mock_model = MockModel.new 13 | assert_includes mock_model.no_index_attributes, 'name' 14 | assert_equal 1, mock_model.no_index_attributes.size 15 | end 16 | 17 | test 'excludes index of multiple attributes' do 18 | MockModel.no_indexes :name, :role 19 | mock_model = MockModel.new 20 | assert_includes mock_model.no_index_attributes, 'name' 21 | assert_includes mock_model.no_index_attributes, 'role' 22 | assert_equal 2, mock_model.no_index_attributes.size 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/cases/nested_attr_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class NestedAttrTest < ActiveSupport::TestCase 4 | def setup 5 | super 6 | MockModelParent.clear_validators! 7 | MockModelParent.validates_associated(:mock_models) 8 | @mock_model_parent = MockModelParent.new(name: 'whatever') 9 | MockModel.clear_validators! 10 | MockModel.validates :name, presence: true 11 | end 12 | 13 | # Instance method tests. 14 | test 'nested_attributes?' do 15 | MockModelParent.validates_associated(:mock_models) 16 | @mock_model_parent.mock_models = [MockModel.new(name: 'M1'), MockModel.new(name: 'M2')] 17 | refute @mock_model_parent.nested_attributes? 18 | @mock_model_parent.nested_attributes = :mock_models 19 | refute @mock_model_parent.nested_attributes? 20 | @mock_model_parent.nested_attributes = [:mock_models] 21 | assert @mock_model_parent.nested_attributes? 22 | end 23 | 24 | test 'should extract and return nested models' do 25 | assert_empty @mock_model_parent.nested_models 26 | @mock_model_parent.mock_models = [m1 = MockModel.new(name: 'M'), m2 = MockModel.new(name: 'M2')] 27 | assert_empty @mock_model_parent.nested_models 28 | @mock_model_parent.nested_attributes = [:mock_models] 29 | nested_models = @mock_model_parent.nested_models 30 | assert_equal 2, nested_models.size 31 | assert_equal m1, nested_models[0] 32 | assert_equal m2, nested_models[1] 33 | end 34 | 35 | test 'should return a list of nested model class names' do 36 | @mock_model_parent.mock_models = [MockModel.new(name: 'M'), MockModel.new(name: 'M2')] 37 | @mock_model_parent.nested_attributes = [:mock_models] 38 | classes = @mock_model_parent.nested_model_class_names 39 | assert_equal 1, classes.size 40 | assert_equal ['MockModel'], classes 41 | end 42 | 43 | test 'should return a list of nested error objects' do 44 | @mock_model_parent.mock_models = [MockModel.new, MockModel.new, MockModel.new(name: 'M3')] 45 | @mock_model_parent.nested_attributes = [:mock_models] 46 | errors = @mock_model_parent.nested_errors 47 | assert errors.is_a? Array 48 | # Each model should have an ActiveModel::Errors object, regardless of validation status 49 | assert_equal 3, errors.size 50 | end 51 | 52 | test 'assigns new nested objects with hash attributes' do 53 | assert_nil @mock_model_parent.nested_attributes 54 | params = { '0' => { name: 'Mock Model 1', role: 0 }, '1' => { name: 'Mock Model 2', role: 1 } } 55 | @mock_model_parent.assign_nested_attributes(:mock_models, params) 56 | assert @mock_model_parent.mock_models.is_a? Array 57 | assert_equal 2, @mock_model_parent.mock_models.size 58 | mock_model_1 = @mock_model_parent.mock_models[0] 59 | mock_model_2 = @mock_model_parent.mock_models[1] 60 | assert mock_model_1.is_a? MockModel 61 | assert mock_model_2.is_a? MockModel 62 | assert_equal 'Mock Model 1', mock_model_1.name 63 | assert_equal 0, mock_model_1.role 64 | assert_equal 'Mock Model 2', mock_model_2.name 65 | assert_equal 1, mock_model_2.role 66 | assert_equal [:mock_models], @mock_model_parent.nested_attributes 67 | end 68 | 69 | test 'updates existing nested objects' do 70 | mock_model_1 = create(:mock_model, name: 'Model 1', role: 0) 71 | mock_model_2 = create(:mock_model, name: 'Model 2', role: 0) 72 | @mock_model_parent.mock_models = [mock_model_1, mock_model_2] 73 | params_1 = ActionController::Parameters.new(id: mock_model_1.id, name: 'Model 1A', role: 1) 74 | params_2 = ActionController::Parameters.new(id: mock_model_2.id, name: 'Model 2A', role: 1) 75 | form_params = ActionController::Parameters.new('0' => params_1, '1' => params_2) 76 | params = form_params.permit('0' => [:id, :name, :role], '1' => [:id, :name, :role]) 77 | @mock_model_parent.assign_nested_attributes(:mock_models, params) 78 | assert @mock_model_parent.mock_models.is_a? Array 79 | assert_equal 2, @mock_model_parent.mock_models.size 80 | mock_model_1 = @mock_model_parent.mock_models[0] 81 | mock_model_2 = @mock_model_parent.mock_models[1] 82 | assert_equal 'Model 1A', mock_model_1.name 83 | assert_equal 1, mock_model_1.role 84 | assert_equal 'Model 2A', mock_model_2.name 85 | assert_equal 1, mock_model_2.role 86 | end 87 | 88 | test 'marks a deleted object for destruction' do 89 | mock_model_1 = create(:mock_model, name: 'Model 1', role: 0) 90 | mock_model_2 = create(:mock_model, name: 'Model 2', role: 0) 91 | @mock_model_parent.mock_models = [mock_model_1, mock_model_2] 92 | params_1 = ActionController::Parameters.new(id: mock_model_1.id, _destroy: '1') 93 | form_params = ActionController::Parameters.new('0' => params_1) 94 | params = form_params.permit('0' => [:id, :name, :role, :_destroy]) 95 | @mock_model_parent.assign_nested_attributes(:mock_models, params) 96 | assert mock_model_1.marked_for_destruction 97 | refute mock_model_2.marked_for_destruction 98 | end 99 | 100 | test 'does not respond to underscore_destroy without id' do 101 | params = { '0' => { name: 'Mock Model 1', role: 0, _destroy: '1' } } 102 | @mock_model_parent.assign_nested_attributes(:mock_models, params) 103 | mock_model_1 = @mock_model_parent.mock_models[0] 104 | refute mock_model_1.marked_for_destruction 105 | end 106 | 107 | test 'rejects new objects if proc supplied' do 108 | @mock_model_parent.nested_attributes = nil 109 | params = { '0' => { name: 'Mock Model 1', role: 0 }, '1' => { name: '', role: 1 } } 110 | reject_proc = proc { |attributes| attributes['name'].blank? } 111 | @mock_model_parent.assign_nested_attributes(:mock_models, params, reject_if: reject_proc) 112 | assert_equal 1, @mock_model_parent.mock_models.size 113 | assert_equal 'Mock Model 1', @mock_model_parent.mock_models[0].name 114 | end 115 | 116 | test 'rejects new objects with all_blank symbol' do 117 | @mock_model_parent.nested_attributes = nil 118 | params = { '0' => { name: '', role: nil }, '1' => { name: '', role: nil } } 119 | @mock_model_parent.assign_nested_attributes(:mock_models, params, reject_if: :all_blank) 120 | assert_equal 0, @mock_model_parent.mock_models.size 121 | end 122 | 123 | # Class method tests. 124 | 125 | test 'validates associated' do 126 | @mock_model_parent.mock_models = [m = MockModel.new, m2 = MockModel.new(name: 'Model 2'), 127 | m3 = MockModel.new, m4 = MockModel.new(name: 'Model 4')] 128 | assert !@mock_model_parent.valid? 129 | assert @mock_model_parent.errors[:mock_models].any? 130 | assert_equal 1, m.errors.count 131 | assert_equal 0, m2.errors.count 132 | assert_equal 1, m3.errors.count 133 | assert_equal 0, m4.errors.count 134 | m.name = m3.name = 'non-empty' 135 | assert @mock_model_parent.valid? 136 | end 137 | end 138 | 139 | class MockModelParent 140 | include ActiveModel::Datastore::NestedAttr 141 | attr_accessor :name 142 | attr_accessor :mock_models 143 | end 144 | -------------------------------------------------------------------------------- /test/cases/property_values_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class PropertyValuesTest < ActiveSupport::TestCase 4 | test 'default property value' do 5 | mock_model = MockModel.new 6 | mock_model.name = nil 7 | mock_model.default_property_value(:name, 'Default Name') 8 | assert_equal 'Default Name', mock_model.name 9 | mock_model.name = 'A New Name' 10 | mock_model.default_property_value(:name, 'Default Name') 11 | assert_equal 'A New Name', mock_model.name 12 | mock_model.name = '' 13 | mock_model.default_property_value(:name, 'Default Name') 14 | assert_equal 'Default Name', mock_model.name 15 | end 16 | 17 | test 'format integer property value' do 18 | mock_model = MockModel.new(name: '34') 19 | mock_model.format_property_value(:name, :integer) 20 | assert_equal 34, mock_model.name 21 | end 22 | 23 | test 'format float property value' do 24 | mock_model = MockModel.new(name: '34') 25 | mock_model.format_property_value(:name, :float) 26 | assert_equal 34.0, mock_model.name 27 | end 28 | 29 | test 'format boolean property value' do 30 | mock_model = MockModel.new(role: '0') 31 | mock_model.format_property_value(:role, :boolean) 32 | refute mock_model.role 33 | mock_model.role = 0 34 | mock_model.format_property_value(:role, :boolean) 35 | refute mock_model.role 36 | mock_model.role = '1' 37 | mock_model.format_property_value(:role, :boolean) 38 | assert mock_model.role 39 | mock_model.role = 1 40 | mock_model.format_property_value(:role, :boolean) 41 | assert mock_model.role 42 | mock_model.role = true 43 | mock_model.format_property_value(:role, :boolean) 44 | assert mock_model.role 45 | mock_model.role = false 46 | mock_model.format_property_value(:role, :boolean) 47 | refute mock_model.role 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/cases/track_changes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TrackChangesTest < ActiveSupport::TestCase 4 | def setup 5 | super 6 | create(:mock_model, name: 25.5, role: 1) 7 | end 8 | 9 | test 'track changes with single attribute' do 10 | mock_model = MockModel.all.first 11 | refute mock_model.exclude_from_save? 12 | refute mock_model.values_changed? 13 | assert mock_model.exclude_from_save? 14 | 15 | mock_model.name = '25.5' 16 | assert mock_model.changed? 17 | mock_model.name = 25.5 18 | assert mock_model.changed? 19 | refute mock_model.values_changed? 20 | assert mock_model.exclude_from_save? 21 | 22 | mock_model.name = 25.4 23 | assert mock_model.values_changed? 24 | refute mock_model.exclude_from_save? 25 | end 26 | 27 | test 'track changes with multiple attributes' do 28 | mock_model = MockModel.all.first 29 | refute mock_model.exclude_from_save? 30 | refute mock_model.changed? 31 | mock_model.name = 20 32 | mock_model.role = '1' 33 | mock_model.role = 1 34 | assert mock_model.values_changed? 35 | refute mock_model.exclude_from_save? 36 | end 37 | 38 | test 'track changes with marked for destruction' do 39 | mock_model = MockModel.all.first 40 | mock_model.marked_for_destruction = true 41 | assert mock_model.values_changed? 42 | refute mock_model.exclude_from_save? 43 | mock_model.name = '75' 44 | mock_model.name = 75 45 | assert mock_model.values_changed? 46 | refute mock_model.exclude_from_save? 47 | end 48 | 49 | test 'remove unmodified children' do 50 | class MockModelParentWithTracking 51 | include ActiveModel::Datastore 52 | attr_accessor :name 53 | attr_accessor :mock_models 54 | enable_change_tracking :name 55 | end 56 | mock_model_parent = MockModelParentWithTracking.new(name: 'whatever') 57 | mock_model_parent.mock_models = [MockModel.new(name: 'M1'), MockModel.new(name: 'M2')] 58 | mock_model_parent.nested_attributes = [:mock_models] 59 | mock_model_parent.reload! 60 | mock_model_parent.mock_models.each(&:reload!) 61 | refute mock_model_parent.values_changed? 62 | mock_model_parent.remove_unmodified_children 63 | assert_equal 0, mock_model_parent.mock_models.size 64 | mock_model_parent.mock_models = [MockModel.new(name: 'M1'), MockModel.new(name: 'M2')] 65 | mock_model_parent.nested_attributes = [:mock_models] 66 | mock_model_parent.mock_models.each(&:reload!) 67 | mock_model_parent.mock_models.first.name = 'M1 Modified' 68 | mock_model_parent.remove_unmodified_children 69 | assert_equal 1, mock_model_parent.mock_models.size 70 | end 71 | 72 | test 'change tracking on new object' do 73 | mock_model = MockModel.new 74 | refute mock_model.changed? 75 | mock_model.name = 'Bryce' 76 | assert mock_model.changed? 77 | assert mock_model.name_changed? 78 | assert mock_model.name_changed?(from: nil, to: 'Bryce') 79 | assert_nil mock_model.name_was 80 | assert_equal [nil, 'Bryce'], mock_model.name_change 81 | mock_model.name = 'Billy' 82 | assert_equal [nil, 'Billy'], mock_model.name_change 83 | end 84 | 85 | test 'change tracking on existing object' do 86 | mock_model = MockModel.all.first 87 | refute mock_model.changed? 88 | mock_model.name = 'Billy' 89 | assert mock_model.changed? 90 | assert mock_model.name_changed? 91 | assert mock_model.name_changed?(from: 25.5, to: 'Billy') 92 | assert_equal 25.5, mock_model.name_was 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/entity_class_method_extensions.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Additional methods added for testing only. 3 | # 4 | module EntityClassMethodExtensions 5 | def all_test_entities 6 | query = CloudDatastore.dataset.query(name) 7 | CloudDatastore.dataset.run(query) 8 | end 9 | 10 | def count_test_entities 11 | all_test_entities.length 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :mock_model do 3 | sequence :name do |n| 4 | "Test Mock Model #{n}" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/images/test-image-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/images/test-image-1.jpg -------------------------------------------------------------------------------- /test/images/test-image-2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/images/test-image-2.jpeg -------------------------------------------------------------------------------- /test/images/test-image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/images/test-image-3.png -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/.ruby-gemset: -------------------------------------------------------------------------------- 1 | datastore-example-rails-app -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby File.read('.ruby-version').strip 3 | 4 | ########################################################################################### 5 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 6 | # gem 'foo_bar', :github => 'foo/bar' 7 | # Bundler would attempt to download the gem from https://github.com/foo/bar.git. 8 | ########################################################################################### 9 | 10 | gem 'rails', '~> 6.1.4' 11 | gem 'sass-rails', '>= 6' 12 | gem 'uglifier', '>= 1.3.0' 13 | gem 'coffee-rails', '~> 5.0' 14 | 15 | gem 'jquery-rails' 16 | gem 'turbolinks', '~> 5' 17 | 18 | gem 'puma', '~> 5.0' 19 | gem 'rack-timeout' 20 | 21 | gem 'activemodel-datastore', path: File.expand_path('../../../..', __FILE__) 22 | 23 | # Image storage 24 | gem 'carrierwave', '~> 2.1' 25 | gem 'mini_magick', '~> 4.7' 26 | gem 'fog-google', '~> 1.11' 27 | 28 | group :development, :test do 29 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 30 | gem 'capybara', '>= 2.15' 31 | gem 'selenium-webdriver' 32 | end 33 | 34 | group :development do 35 | gem 'web-console', '>= 3.3.0' 36 | gem 'listen' 37 | gem 'better_errors' 38 | end 39 | 40 | group :test do 41 | gem 'faker' 42 | gem 'factory_bot_rails' 43 | end 44 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: /Users/bryce/workspace/activemodel-datastore 3 | specs: 4 | activemodel-datastore (0.7.0) 5 | activemodel (>= 5.0.0) 6 | activesupport (>= 5.0.0) 7 | google-cloud-datastore (~> 2.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (6.1.7.7) 13 | actionpack (= 6.1.7.7) 14 | activesupport (= 6.1.7.7) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | actionmailbox (6.1.7.7) 18 | actionpack (= 6.1.7.7) 19 | activejob (= 6.1.7.7) 20 | activerecord (= 6.1.7.7) 21 | activestorage (= 6.1.7.7) 22 | activesupport (= 6.1.7.7) 23 | mail (>= 2.7.1) 24 | actionmailer (6.1.7.7) 25 | actionpack (= 6.1.7.7) 26 | actionview (= 6.1.7.7) 27 | activejob (= 6.1.7.7) 28 | activesupport (= 6.1.7.7) 29 | mail (~> 2.5, >= 2.5.4) 30 | rails-dom-testing (~> 2.0) 31 | actionpack (6.1.7.7) 32 | actionview (= 6.1.7.7) 33 | activesupport (= 6.1.7.7) 34 | rack (~> 2.0, >= 2.0.9) 35 | rack-test (>= 0.6.3) 36 | rails-dom-testing (~> 2.0) 37 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 38 | actiontext (6.1.7.7) 39 | actionpack (= 6.1.7.7) 40 | activerecord (= 6.1.7.7) 41 | activestorage (= 6.1.7.7) 42 | activesupport (= 6.1.7.7) 43 | nokogiri (>= 1.8.5) 44 | actionview (6.1.7.7) 45 | activesupport (= 6.1.7.7) 46 | builder (~> 3.1) 47 | erubi (~> 1.4) 48 | rails-dom-testing (~> 2.0) 49 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 50 | activejob (6.1.7.7) 51 | activesupport (= 6.1.7.7) 52 | globalid (>= 0.3.6) 53 | activemodel (6.1.7.7) 54 | activesupport (= 6.1.7.7) 55 | activerecord (6.1.7.7) 56 | activemodel (= 6.1.7.7) 57 | activesupport (= 6.1.7.7) 58 | activestorage (6.1.7.7) 59 | actionpack (= 6.1.7.7) 60 | activejob (= 6.1.7.7) 61 | activerecord (= 6.1.7.7) 62 | activesupport (= 6.1.7.7) 63 | marcel (~> 1.0) 64 | mini_mime (>= 1.1.0) 65 | activesupport (6.1.7.7) 66 | concurrent-ruby (~> 1.0, >= 1.0.2) 67 | i18n (>= 1.6, < 2) 68 | minitest (>= 5.1) 69 | tzinfo (~> 2.0) 70 | zeitwerk (~> 2.3) 71 | addressable (2.8.6) 72 | public_suffix (>= 2.0.2, < 6.0) 73 | base64 (0.2.0) 74 | better_errors (2.10.1) 75 | erubi (>= 1.0.0) 76 | rack (>= 0.9.0) 77 | rouge (>= 1.0.0) 78 | bindex (0.8.1) 79 | builder (3.2.4) 80 | byebug (11.1.3) 81 | capybara (3.40.0) 82 | addressable 83 | matrix 84 | mini_mime (>= 0.1.3) 85 | nokogiri (~> 1.11) 86 | rack (>= 1.6.0) 87 | rack-test (>= 0.6.3) 88 | regexp_parser (>= 1.5, < 3.0) 89 | xpath (~> 3.2) 90 | carrierwave (2.2.5) 91 | activemodel (>= 5.0.0) 92 | activesupport (>= 5.0.0) 93 | addressable (~> 2.6) 94 | image_processing (~> 1.1) 95 | marcel (~> 1.0.0) 96 | mini_mime (>= 0.1.3) 97 | ssrf_filter (~> 1.0) 98 | coffee-rails (5.0.0) 99 | coffee-script (>= 2.2.0) 100 | railties (>= 5.2.0) 101 | coffee-script (2.4.1) 102 | coffee-script-source 103 | execjs 104 | coffee-script-source (1.12.2) 105 | concurrent-ruby (1.2.3) 106 | crass (1.0.6) 107 | date (3.3.4) 108 | declarative (0.0.20) 109 | erubi (1.12.0) 110 | excon (0.109.0) 111 | execjs (2.9.1) 112 | factory_bot (6.4.6) 113 | activesupport (>= 5.0.0) 114 | factory_bot_rails (6.4.3) 115 | factory_bot (~> 6.4) 116 | railties (>= 5.0.0) 117 | faker (3.2.3) 118 | i18n (>= 1.8.11, < 2) 119 | faraday (2.9.0) 120 | faraday-net_http (>= 2.0, < 3.2) 121 | faraday-net_http (3.1.0) 122 | net-http 123 | faraday-retry (2.2.0) 124 | faraday (~> 2.0) 125 | ffi (1.16.3) 126 | fog-core (2.2.4) 127 | builder 128 | excon (~> 0.71) 129 | formatador (~> 0.2) 130 | mime-types 131 | fog-google (1.23.0) 132 | addressable (>= 2.7.0) 133 | fog-core (< 2.3) 134 | fog-json (~> 1.2) 135 | fog-xml (~> 0.1.0) 136 | google-apis-compute_v1 (~> 0.53) 137 | google-apis-dns_v1 (~> 0.28) 138 | google-apis-iamcredentials_v1 (~> 0.15) 139 | google-apis-monitoring_v3 (~> 0.37) 140 | google-apis-pubsub_v1 (~> 0.30) 141 | google-apis-sqladmin_v1beta4 (~> 0.38) 142 | google-apis-storage_v1 (>= 0.19, < 1) 143 | google-cloud-env (~> 1.2) 144 | fog-json (1.2.0) 145 | fog-core 146 | multi_json (~> 1.10) 147 | fog-xml (0.1.4) 148 | fog-core 149 | nokogiri (>= 1.5.11, < 2.0.0) 150 | formatador (0.3.0) 151 | gapic-common (0.20.0) 152 | faraday (>= 1.9, < 3.a) 153 | faraday-retry (>= 1.0, < 3.a) 154 | google-protobuf (~> 3.14) 155 | googleapis-common-protos (>= 1.3.12, < 2.a) 156 | googleapis-common-protos-types (>= 1.3.1, < 2.a) 157 | googleauth (~> 1.0) 158 | grpc (~> 1.36) 159 | globalid (1.2.1) 160 | activesupport (>= 6.1) 161 | google-apis-compute_v1 (0.86.0) 162 | google-apis-core (>= 0.11.0, < 2.a) 163 | google-apis-core (0.11.3) 164 | addressable (~> 2.5, >= 2.5.1) 165 | googleauth (>= 0.16.2, < 2.a) 166 | httpclient (>= 2.8.1, < 3.a) 167 | mini_mime (~> 1.0) 168 | representable (~> 3.0) 169 | retriable (>= 2.0, < 4.a) 170 | rexml 171 | google-apis-dns_v1 (0.36.0) 172 | google-apis-core (>= 0.11.0, < 2.a) 173 | google-apis-iamcredentials_v1 (0.17.0) 174 | google-apis-core (>= 0.11.0, < 2.a) 175 | google-apis-monitoring_v3 (0.54.0) 176 | google-apis-core (>= 0.11.0, < 2.a) 177 | google-apis-pubsub_v1 (0.45.0) 178 | google-apis-core (>= 0.11.0, < 2.a) 179 | google-apis-sqladmin_v1beta4 (0.61.0) 180 | google-apis-core (>= 0.11.0, < 2.a) 181 | google-apis-storage_v1 (0.32.0) 182 | google-apis-core (>= 0.11.0, < 2.a) 183 | google-cloud-core (1.6.1) 184 | google-cloud-env (>= 1.0, < 3.a) 185 | google-cloud-errors (~> 1.0) 186 | google-cloud-datastore (2.8.0) 187 | google-cloud-core (~> 1.5) 188 | google-cloud-datastore-v1 (~> 0.0) 189 | google-cloud-datastore-v1 (0.15.0) 190 | gapic-common (>= 0.20.0, < 2.a) 191 | google-cloud-errors (~> 1.0) 192 | google-cloud-env (1.6.0) 193 | faraday (>= 0.17.3, < 3.0) 194 | google-cloud-errors (1.3.1) 195 | google-protobuf (3.25.3) 196 | googleapis-common-protos (1.5.0) 197 | google-protobuf (~> 3.18) 198 | googleapis-common-protos-types (~> 1.7) 199 | grpc (~> 1.41) 200 | googleapis-common-protos-types (1.13.0) 201 | google-protobuf (~> 3.18) 202 | googleauth (1.8.1) 203 | faraday (>= 0.17.3, < 3.a) 204 | jwt (>= 1.4, < 3.0) 205 | multi_json (~> 1.11) 206 | os (>= 0.9, < 2.0) 207 | signet (>= 0.16, < 2.a) 208 | grpc (1.62.0) 209 | google-protobuf (~> 3.25) 210 | googleapis-common-protos-types (~> 1.0) 211 | httpclient (2.8.3) 212 | i18n (1.14.1) 213 | concurrent-ruby (~> 1.0) 214 | image_processing (1.12.2) 215 | mini_magick (>= 4.9.5, < 5) 216 | ruby-vips (>= 2.0.17, < 3) 217 | jquery-rails (4.6.0) 218 | rails-dom-testing (>= 1, < 3) 219 | railties (>= 4.2.0) 220 | thor (>= 0.14, < 2.0) 221 | jwt (2.8.1) 222 | base64 223 | listen (3.9.0) 224 | rb-fsevent (~> 0.10, >= 0.10.3) 225 | rb-inotify (~> 0.9, >= 0.9.10) 226 | loofah (2.22.0) 227 | crass (~> 1.0.2) 228 | nokogiri (>= 1.12.0) 229 | mail (2.8.1) 230 | mini_mime (>= 0.1.1) 231 | net-imap 232 | net-pop 233 | net-smtp 234 | marcel (1.0.4) 235 | matrix (0.4.2) 236 | method_source (1.0.0) 237 | mime-types (3.5.2) 238 | mime-types-data (~> 3.2015) 239 | mime-types-data (3.2024.0206) 240 | mini_magick (4.12.0) 241 | mini_mime (1.1.5) 242 | mini_portile2 (2.8.5) 243 | minitest (5.22.2) 244 | multi_json (1.15.0) 245 | net-http (0.4.1) 246 | uri 247 | net-imap (0.4.10) 248 | date 249 | net-protocol 250 | net-pop (0.1.2) 251 | net-protocol 252 | net-protocol (0.2.2) 253 | timeout 254 | net-smtp (0.4.0.1) 255 | net-protocol 256 | nio4r (2.7.0) 257 | nokogiri (1.16.2) 258 | mini_portile2 (~> 2.8.2) 259 | racc (~> 1.4) 260 | os (1.1.4) 261 | public_suffix (5.0.4) 262 | puma (5.6.8) 263 | nio4r (~> 2.0) 264 | racc (1.7.3) 265 | rack (2.2.8.1) 266 | rack-test (2.1.0) 267 | rack (>= 1.3) 268 | rack-timeout (0.6.3) 269 | rails (6.1.7.7) 270 | actioncable (= 6.1.7.7) 271 | actionmailbox (= 6.1.7.7) 272 | actionmailer (= 6.1.7.7) 273 | actionpack (= 6.1.7.7) 274 | actiontext (= 6.1.7.7) 275 | actionview (= 6.1.7.7) 276 | activejob (= 6.1.7.7) 277 | activemodel (= 6.1.7.7) 278 | activerecord (= 6.1.7.7) 279 | activestorage (= 6.1.7.7) 280 | activesupport (= 6.1.7.7) 281 | bundler (>= 1.15.0) 282 | railties (= 6.1.7.7) 283 | sprockets-rails (>= 2.0.0) 284 | rails-dom-testing (2.2.0) 285 | activesupport (>= 5.0.0) 286 | minitest 287 | nokogiri (>= 1.6) 288 | rails-html-sanitizer (1.6.0) 289 | loofah (~> 2.21) 290 | nokogiri (~> 1.14) 291 | railties (6.1.7.7) 292 | actionpack (= 6.1.7.7) 293 | activesupport (= 6.1.7.7) 294 | method_source 295 | rake (>= 12.2) 296 | thor (~> 1.0) 297 | rake (13.1.0) 298 | rb-fsevent (0.11.2) 299 | rb-inotify (0.10.1) 300 | ffi (~> 1.0) 301 | regexp_parser (2.9.0) 302 | representable (3.2.0) 303 | declarative (< 0.1.0) 304 | trailblazer-option (>= 0.1.1, < 0.2.0) 305 | uber (< 0.2.0) 306 | retriable (3.1.2) 307 | rexml (3.2.6) 308 | rouge (4.2.0) 309 | ruby-vips (2.2.1) 310 | ffi (~> 1.12) 311 | rubyzip (2.3.2) 312 | sass-rails (6.0.0) 313 | sassc-rails (~> 2.1, >= 2.1.1) 314 | sassc (2.4.0) 315 | ffi (~> 1.9) 316 | sassc-rails (2.1.2) 317 | railties (>= 4.0.0) 318 | sassc (>= 2.0) 319 | sprockets (> 3.0) 320 | sprockets-rails 321 | tilt 322 | selenium-webdriver (4.18.1) 323 | base64 (~> 0.2) 324 | rexml (~> 3.2, >= 3.2.5) 325 | rubyzip (>= 1.2.2, < 3.0) 326 | websocket (~> 1.0) 327 | signet (0.19.0) 328 | addressable (~> 2.8) 329 | faraday (>= 0.17.5, < 3.a) 330 | jwt (>= 1.5, < 3.0) 331 | multi_json (~> 1.10) 332 | sprockets (4.2.1) 333 | concurrent-ruby (~> 1.0) 334 | rack (>= 2.2.4, < 4) 335 | sprockets-rails (3.4.2) 336 | actionpack (>= 5.2) 337 | activesupport (>= 5.2) 338 | sprockets (>= 3.0.0) 339 | ssrf_filter (1.1.2) 340 | thor (1.3.1) 341 | tilt (2.3.0) 342 | timeout (0.4.1) 343 | trailblazer-option (0.1.2) 344 | turbolinks (5.2.1) 345 | turbolinks-source (~> 5.2) 346 | turbolinks-source (5.2.0) 347 | tzinfo (2.0.6) 348 | concurrent-ruby (~> 1.0) 349 | uber (0.1.0) 350 | uglifier (4.2.0) 351 | execjs (>= 0.3.0, < 3) 352 | uri (0.13.0) 353 | web-console (4.2.1) 354 | actionview (>= 6.0.0) 355 | activemodel (>= 6.0.0) 356 | bindex (>= 0.4.0) 357 | railties (>= 6.0.0) 358 | websocket (1.2.10) 359 | websocket-driver (0.7.6) 360 | websocket-extensions (>= 0.1.0) 361 | websocket-extensions (0.1.5) 362 | xpath (3.2.0) 363 | nokogiri (~> 1.8) 364 | zeitwerk (2.6.13) 365 | 366 | PLATFORMS 367 | ruby 368 | 369 | DEPENDENCIES 370 | activemodel-datastore! 371 | better_errors 372 | byebug 373 | capybara (>= 2.15) 374 | carrierwave (~> 2.1) 375 | coffee-rails (~> 5.0) 376 | factory_bot_rails 377 | faker 378 | fog-google (~> 1.11) 379 | jquery-rails 380 | listen 381 | mini_magick (~> 4.7) 382 | puma (~> 5.0) 383 | rack-timeout 384 | rails (~> 6.1.4) 385 | sass-rails (>= 6) 386 | selenium-webdriver 387 | turbolinks (~> 5) 388 | uglifier (>= 1.3.0) 389 | web-console (>= 3.3.0) 390 | 391 | RUBY VERSION 392 | ruby 3.3.0p0 393 | 394 | BUNDLED WITH 395 | 2.2.30 396 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('config/application', __dir__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/images/fallback_user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/app/assets/images/fallback_user.png -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/javascripts/active_model_nested_attr.coffee: -------------------------------------------------------------------------------- 1 | initializeNestedAttributes = (name) -> 2 | if $(".duplicatable_nested_#{name}_form").length 3 | forms_on_page = $(".duplicatable_nested_#{name}_form").length 4 | 5 | $('body').on 'click', ".destroy_nested_#{name}_form", (e) -> 6 | e.preventDefault() 7 | if $(".duplicatable_nested_#{name}_form:visible").length > 1 8 | $(this).closest(".duplicatable_nested_#{name}_form").slideUp().remove() 9 | 10 | $('body').on 'click', ".mark_nested_#{name}_form_as_destroyed", (e) -> 11 | e.preventDefault() 12 | form = $(this).closest(".duplicatable_nested_#{name}_form") 13 | form.find('input[id*="_destroy"]').val('true') 14 | form.slideUp().hide() 15 | 16 | $(".insert_nested_#{name}_form").on 'click', (e) -> 17 | e.preventDefault() 18 | last_nested_form = $(".duplicatable_nested_#{name}_form").last() 19 | new_nested_form = $(last_nested_form).clone(true) 20 | new_nested_form.show() 21 | forms_on_page += 1 22 | 23 | $(new_nested_form).find(".mark_nested_#{name}_form_as_destroyed").each -> 24 | $(this).toggleClass("mark_nested_#{name}_form_as_destroyed destroy_nested_#{name}_form") 25 | 26 | $(new_nested_form).find('label').each -> 27 | old_label = $(this).attr 'for' 28 | if old_label? 29 | new_label = old_label.replace(new RegExp(/_[0-9]+_/), "_#{forms_on_page - 1}_") 30 | $(this).attr 'for', new_label 31 | 32 | $(new_nested_form).find('select, input').each -> 33 | $(this).removeData() 34 | if $(this).is(':checkbox') 35 | $(this).prop('checked', false) 36 | else if $(this).is('select') 37 | $(this).find('option:eq(0)').prop('selected', true) 38 | else 39 | $(this).val('') 40 | old_id = $(this).attr 'id' 41 | if old_id? 42 | new_id = old_id.replace(new RegExp(/_[0-9]+_/), "_#{forms_on_page - 1}_") 43 | $(this).attr 'id', new_id 44 | 45 | old_name = $(this).attr 'name' 46 | new_name = old_name.replace(new RegExp(/\[[0-9]+]/), "[#{forms_on_page - 1}]") 47 | $(this).attr 'name', new_name 48 | 49 | $(new_nested_form).insertAfter(last_nested_form) 50 | else 51 | $('body').on 'click', ".destroy_nested_#{name}_form", (e) -> 52 | e.preventDefault() 53 | $('body').on 'click',".mark_nested_#{name}_form_as_destroyed", (e) -> 54 | e.preventDefault() 55 | $(".insert_nested_#{name}_form").on 'click', (e) -> 56 | e.preventDefault() 57 | window.initializeNestedAttributes = initializeNestedAttributes 58 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | 16 | // Active Model Nested Attributes 17 | //= require active_model_nested_attr 18 | 19 | //= require turbolinks 20 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | */ 13 | 14 | @import "scaffolds"; -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/assets/stylesheets/scaffolds.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | color: #333; 4 | font-family: verdana, arial, helvetica, sans-serif; 5 | font-size: 13px; 6 | line-height: 18px; 7 | } 8 | 9 | p, ol, ul, td { 10 | font-family: verdana, arial, helvetica, sans-serif; 11 | font-size: 13px; 12 | line-height: 18px; 13 | } 14 | 15 | pre { 16 | background-color: #eee; 17 | padding: 10px; 18 | font-size: 11px; 19 | } 20 | 21 | a { 22 | color: #000; 23 | 24 | &:visited { 25 | color: #666; 26 | } 27 | 28 | &:hover { 29 | color: #fff; 30 | background-color: #000; 31 | } 32 | } 33 | 34 | div { 35 | &.field, &.actions { 36 | margin-bottom: 10px; 37 | } 38 | } 39 | 40 | #notice { 41 | color: green; 42 | } 43 | 44 | .field_with_errors { 45 | padding: 2px; 46 | background-color: red; 47 | display: table; 48 | } 49 | 50 | #error_explanation { 51 | width: 450px; 52 | border: 2px solid red; 53 | padding: 7px; 54 | padding-bottom: 0; 55 | margin-bottom: 20px; 56 | background-color: #f0f0f0; 57 | 58 | h2 { 59 | text-align: left; 60 | font-weight: bold; 61 | padding: 5px 5px 5px 15px; 62 | font-size: 12px; 63 | margin: -7px; 64 | margin-bottom: 0px; 65 | background-color: #c00; 66 | color: #fff; 67 | } 68 | 69 | ul li { 70 | font-size: 12px; 71 | list-style: square; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | ## 7 | # This could be the id of an Account, Company, etc. 8 | # 9 | def fake_ancestor 10 | 12345 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | def index 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | before_action :set_user, only: [:show, :edit, :update, :destroy] 3 | 4 | def index 5 | @users = User.all(ancestor: User.parent_key(fake_ancestor)) 6 | end 7 | 8 | def show 9 | end 10 | 11 | def new 12 | @user = User.new 13 | end 14 | 15 | def edit 16 | end 17 | 18 | def create 19 | @user = User.new(user_params) 20 | @user.parent_key_id = fake_ancestor 21 | respond_to do |format| 22 | if @user.save 23 | format.html { redirect_to @user, notice: 'User was successfully created.' } 24 | else 25 | format.html { render :new } 26 | end 27 | end 28 | end 29 | 30 | def update 31 | respond_to do |format| 32 | if @user.update(user_params) 33 | format.html { redirect_to @user, notice: 'User was successfully updated.' } 34 | else 35 | format.html { render :edit } 36 | end 37 | end 38 | end 39 | 40 | def destroy 41 | @user.destroy 42 | respond_to do |format| 43 | format.html { redirect_to users_url, notice: 'User was successfully destroyed.' } 44 | end 45 | end 46 | 47 | private 48 | 49 | def set_user 50 | @user = User.find(params[:id], parent: User.parent_key(fake_ancestor)) 51 | end 52 | 53 | def user_params 54 | params.require(:user).permit(:email, :name, :profile_image) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/app/mailers/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/models/recipe.rb: -------------------------------------------------------------------------------- 1 | class Recipe 2 | include ActiveModel::Datastore 3 | 4 | attr_accessor :amount # Float 5 | attr_accessor :name # String 6 | attr_accessor :ingredients # gives us 'accepts_nested_attributes_for' like functionality 7 | 8 | before_validation :set_default_values, :set_nested_recipe_ids 9 | after_validation :format_values 10 | 11 | validates :amount, numericality: { greater_than_or_equal_to: 1 } 12 | validates :name, presence: true 13 | 14 | validates :ingredients, presence: true # Recipes must have at least one RecipeContent. 15 | validates_associated :ingredients 16 | 17 | enable_change_tracking :amount, :name 18 | 19 | def entity_properties 20 | %w[amount name] 21 | end 22 | 23 | def set_default_values 24 | default_property_value :amount, 100.0 25 | end 26 | 27 | def format_values 28 | format_property_value :amount, :float 29 | end 30 | 31 | def ingredients_attributes=(attributes) 32 | assign_nested_attributes(:ingredients, attributes) 33 | end 34 | 35 | def build_ingredients 36 | return unless ingredients.nil? || ingredients.empty? 37 | 38 | self.ingredients = [Ingredient.new(order: 1)] 39 | end 40 | 41 | def set_ingredient 42 | content = Ingredient.find_latest(account_id, where: ['recipeId', '=', id]) 43 | content.sort! { |a, b| a.order <=> b.order } 44 | self.ingredients = content 45 | end 46 | 47 | private 48 | 49 | ## 50 | # For each associated Ingredient sets the recipeId to the id of the Recipe. 51 | # 52 | def set_nested_recipe_ids 53 | nested_models.each { |ingredient| ingredient.recipeId = id } 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/models/user.rb: -------------------------------------------------------------------------------- 1 | require 'active_model/datastore/carrier_wave_uploader' 2 | 3 | class User 4 | include ActiveModel::Datastore 5 | extend CarrierWaveUploader 6 | 7 | attr_accessor :email, :enabled, :name, :profile_image, :role, :state 8 | 9 | mount_uploader :profile_image, ProfileImageUploader 10 | 11 | before_validation :set_default_values 12 | after_validation :format_values 13 | 14 | validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } 15 | validates :name, presence: true, length: { maximum: 30 } 16 | validates :role, presence: true 17 | 18 | def entity_properties 19 | %w[email enabled name profile_image role] 20 | end 21 | 22 | def set_default_values 23 | default_property_value :enabled, true 24 | default_property_value :role, 1 25 | end 26 | 27 | def format_values 28 | format_property_value :role, :integer 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/uploaders/profile_image_uploader.rb: -------------------------------------------------------------------------------- 1 | class ProfileImageUploader < CarrierWave::Uploader::Base 2 | include CarrierWave::MiniMagick 3 | 4 | storage :fog if Rails.env.production? 5 | 6 | # Override the directory where uploaded files will be stored. 7 | # This is a sensible default for uploaders that are meant to be mounted: 8 | def store_dir 9 | "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" 10 | end 11 | 12 | def default_url(*) 13 | ActionController::Base.helpers.asset_path([version_name, 'fallback_user.png'].compact.join('_')) 14 | end 15 | 16 | # Override as we don't want the files deleted from Cloud Storage. 17 | def remove! 18 | return unless model.respond_to?(:keep_file) && model.keep_file 19 | 20 | super 21 | end 22 | 23 | # Process files as they are uploaded: 24 | # Resize the image to fit within the specified dimensions while retaining the original aspect 25 | # ratio. The image may be shorter or narrower than specified in the smaller dimension but will 26 | # not be larger than the specified values. 27 | process resize_to_fit: [300, 200] 28 | 29 | def extension_whitelist 30 | %w[jpg jpeg gif png] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |

ActiveModel Datastore Demo

2 |

Home page

3 | 4 | <%= link_to('Click here to view Users in datastore', users_path) %> -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cloud Datastore Example 5 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 6 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/users/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@user) do |f| %> 2 | <% if @user.errors.any? %> 3 |
4 |

<%= pluralize(@user.errors.count, 'error') %> prohibited this user from being saved:

5 | 6 |
    7 | <% @user.errors.full_messages.each do |message| %> 8 |
  • <%= message %>
  • 9 | <% end %> 10 |
11 |
12 | <% end %> 13 | 14 |
15 | <%= f.label :name, 'Name:' %> 16 | <%= f.text_field :name, size: 40 %> 17 |
18 | 19 |
20 | <%= f.label :email, 'Email:' %> 21 | <%= f.text_field :email, size: 40 %> 22 |
23 | 24 |
25 | <%= f.label :profile_image, class: 'form-control-label' %> 26 | <%= f.file_field :profile_image %> 27 |
28 | <% if f.object.profile_image? && f.object.errors[:profile_image].blank? %> 29 | <%= image_tag f.object.profile_image.url %> 30 | <% end %> 31 | <%= f.hidden_field :profile_image_cache unless f.object.errors[:profile_image].any? %> 32 | 33 |
34 | <%= f.submit %> 35 |
36 | <% end %> 37 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/users/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Editing User

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Show', @user %> | 6 | <%= link_to 'Back', users_path %> 7 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/users/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 |

Listing Users

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <% @users.each do |user| %> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <% end %> 23 | 24 |
<%= image_tag user.profile_image.url, style: 'width: 50px;' %><%= user.name %><%= user.email %><%= link_to 'Show', user %><%= link_to 'Edit', edit_user_path(user) %><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %>
25 | 26 |
27 | 28 | <%= link_to 'Create User', new_user_path %> 29 | 30 |
31 |
32 | 33 | <%= link_to 'Back to Home', root_path %> 34 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/users/new.html.erb: -------------------------------------------------------------------------------- 1 |

New User

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Back', users_path %> 6 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/app/views/users/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 |

4 | Name: 5 | <%= @user.name %> 6 |

7 |

8 | Email: 9 | <%= @user.email %> 10 |

11 | 12 | <%= link_to 'Edit', edit_user_path(@user) %> | 13 | <%= link_to 'Back', users_path %> 14 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies 21 | system! 'bin/yarn' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | if (match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m)) 11 | Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq.join(Gem.path_separator) } 12 | gem 'spring', match[1] 13 | require 'spring/binstub' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR). 5 | select { |dir| File.expand_path(dir) != __dir__ }. 6 | product(["yarn", "yarn.cmd", "yarn.ps1"]). 7 | map { |dir, file| File.expand_path(file, dir) }. 8 | find { |file| File.executable?(file) } 9 | 10 | if yarn 11 | exec yarn, *ARGV 12 | else 13 | $stderr.puts "Yarn executable was not detected in the system." 14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 15 | exit 1 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails' 4 | # Pick the frameworks you want: 5 | require 'active_model/railtie' 6 | require 'active_job/railtie' 7 | # require 'active_record/railtie' 8 | # require 'active_storage/engine' 9 | require 'action_controller/railtie' 10 | require 'action_mailer/railtie' 11 | # require 'action_mailbox/engine' 12 | # require 'action_text/engine' 13 | require 'action_view/railtie' 14 | require 'action_cable/engine' 15 | require 'sprockets/railtie' 16 | require 'rails/test_unit/railtie' 17 | 18 | # Require the gems listed in Gemfile, including any gems 19 | # you've limited to :test, :development, or :production. 20 | Bundler.require(*Rails.groups) 21 | 22 | module DatastoreExampleRailsApp 23 | class Application < Rails::Application 24 | # Initialize configuration defaults for originally generated Rails version. 25 | config.load_defaults 6.1 26 | 27 | # Configuration for the application, engines, and railties goes here. 28 | # 29 | # These settings can be overridden in specific environments using the files 30 | # in config/environments, which are processed later. 31 | # 32 | # config.time_zone = 'Central Time (US & Canada)' 33 | # config.eager_load_paths << Rails.root.join('extras') 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: datastore_example_rails_app_production 11 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 20 | config.action_controller.perform_caching = true 21 | config.action_controller.enable_fragment_cache_logging = true 22 | 23 | config.cache_store = :memory_store 24 | config.public_file_server.headers = { 25 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 26 | } 27 | else 28 | config.action_controller.perform_caching = false 29 | 30 | config.cache_store = :null_store 31 | end 32 | 33 | # Don't care if the mailer can't send. 34 | config.action_mailer.raise_delivery_errors = false 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Print deprecation notices to the Rails logger. 39 | config.active_support.deprecation = :log 40 | 41 | # Raise exceptions for disallowed deprecations. 42 | config.active_support.disallowed_deprecation = :raise 43 | 44 | # Tell Active Support which deprecation messages to disallow. 45 | config.active_support.disallowed_deprecation_warnings = [] 46 | 47 | # Debug mode disables concatenation and preprocessing of assets. 48 | # This option may cause significant delays in view rendering with a large 49 | # number of complex assets. 50 | config.assets.debug = true 51 | 52 | # Suppress logger output for asset requests. 53 | config.assets.quiet = true 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | 61 | # Use an evented file watcher to asynchronously detect changes in source code, 62 | # routes, locales, etc. This feature depends on the listen gem. 63 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 64 | 65 | # Uncomment if you wish to allow Action Cable access from any origin. 66 | # config.action_cable.disable_request_forgery_protection = true 67 | end 68 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = 'http://assets.example.com' 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 38 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 39 | 40 | # Mount Action Cable outside main process or domain. 41 | # config.action_cable.mount_path = nil 42 | # config.action_cable.url = 'wss://example.com/cable' 43 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 44 | 45 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 46 | config.force_ssl = true 47 | 48 | # Include generic and useful information about system operation, but avoid logging too much 49 | # information to avoid inadvertent exposure of personally identifiable information (PII). 50 | config.log_level = :info 51 | 52 | # Prepend all log lines with the following tags. 53 | config.log_tags = [ :request_id ] 54 | 55 | # Use a different cache store in production. 56 | # config.cache_store = :mem_cache_store 57 | 58 | # Use a real queuing backend for Active Job (and separate queues per environment). 59 | # config.active_job.queue_adapter = :resque 60 | # config.active_job.queue_name_prefix = "datastore_example_rails_app_production" 61 | 62 | config.action_mailer.perform_caching = false 63 | 64 | # Ignore bad email addresses and do not raise email delivery errors. 65 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 66 | # config.action_mailer.raise_delivery_errors = false 67 | 68 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 69 | # the I18n.default_locale when a translation cannot be found). 70 | config.i18n.fallbacks = true 71 | 72 | # Send deprecation notices to registered listeners. 73 | config.active_support.deprecation = :notify 74 | 75 | # Log disallowed deprecations. 76 | config.active_support.disallowed_deprecation = :log 77 | 78 | # Tell Active Support which deprecation messages to disallow. 79 | config.active_support.disallowed_deprecation_warnings = [] 80 | 81 | # Use default logging formatter so that PID and timestamp are not suppressed. 82 | config.log_formatter = ::Logger::Formatter.new 83 | 84 | # Use a different logger for distributed setups. 85 | # require "syslog/logger" 86 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 87 | 88 | if ENV["RAILS_LOG_TO_STDOUT"].present? 89 | logger = ActiveSupport::Logger.new(STDOUT) 90 | logger.formatter = config.log_formatter 91 | config.logger = ActiveSupport::TaggedLogging.new(logger) 92 | end 93 | 94 | # Inserts middleware to perform automatic connection switching. 95 | # The `database_selector` hash is used to pass options to the DatabaseSelector 96 | # middleware. The `delay` is used to determine how long to wait after a write 97 | # to send a subsequent read to the primary. 98 | # 99 | # The `database_resolver` class is used by the middleware to determine which 100 | # database is appropriate to use based on the time delay. 101 | # 102 | # The `database_resolver_context` class is used by the middleware to set 103 | # timestamps for the last write to the primary. The resolver uses the context 104 | # class timestamps to determine how long to wait before reading from the 105 | # replica. 106 | # 107 | # By default Rails will store a last write timestamp in the session. The 108 | # DatabaseSelector middleware is designed as such you can define your own 109 | # strategy for connection switching and pass that into the middleware through 110 | # these configuration options. 111 | # config.active_record.database_selector = { delay: 2.seconds } 112 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 113 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 114 | end 115 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = false 12 | config.action_view.cache_template_loading = true 13 | 14 | # Do not eager load code on boot. This avoids loading your whole application 15 | # just for the purpose of running a single test. If you are using a tool that 16 | # preloads Rails for running tests, you may have to set it to true. 17 | config.eager_load = false 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Tell Action Mailer not to deliver emails to the real world. 39 | # The :test delivery method accumulates sent emails in the 40 | # ActionMailer::Base.deliveries array. 41 | config.action_mailer.delivery_method = :test 42 | 43 | # Print deprecation notices to the stderr. 44 | config.active_support.deprecation = :stderr 45 | 46 | # Raise exceptions for disallowed deprecations. 47 | config.active_support.disallowed_deprecation = :raise 48 | 49 | # Tell Active Support which deprecation messages to disallow. 50 | config.active_support.disallowed_deprecation_warnings = [] 51 | 52 | # Raises error for missing translations. 53 | # config.i18n.raise_on_missing_translations = true 54 | 55 | # Annotate rendered view with file names. 56 | # config.action_view.annotate_rendered_view_with_filenames = true 57 | end 58 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/carrierwave.rb: -------------------------------------------------------------------------------- 1 | if Rails.env.development? 2 | # SERVICE_ACCOUNT = YAML.load_file(Rails.root.join('config', 'service_account.yml'))[Rails.env] 3 | # 4 | # CarrierWave.configure do |config| 5 | # config.fog_provider = 'fog/google' 6 | # config.fog_credentials = { 7 | # provider: 'Google', 8 | # google_project: SERVICE_ACCOUNT['gcloud_project'], 9 | # google_client_email: SERVICE_ACCOUNT['client_email'], 10 | # google_json_key_string: '{"private_key": "' + SERVICE_ACCOUNT['private_key'] + '", 11 | # "client_email": "' + SERVICE_ACCOUNT['client_email'] + '"}' 12 | # } 13 | # config.fog_directory = SERVICE_ACCOUNT['cloud_storage_bucket_name'] 14 | # config.fog_public = false 15 | # config.fog_attributes = { 'Cache-Control' => 'max-age=31536000' } # one year 16 | # end 17 | CarrierWave.configure do |config| 18 | config.storage = :file 19 | config.root = Rails.root.join('tmp') 20 | config.cache_dir = 'carrierwave-cache' 21 | end 22 | 23 | elsif Rails.env.test? 24 | CarrierWave.configure do |config| 25 | config.storage = :file 26 | config.enable_processing = false 27 | config.root = Rails.root.join('tmp') 28 | config.cache_dir = 'carrierwave-cache' 29 | end 30 | 31 | elsif Rails.env.production? 32 | CarrierWave.configure do |config| 33 | config.fog_provider = 'fog/google' 34 | config.fog_credentials = { 35 | provider: 'Google', 36 | google_project: ENV['GCLOUD_PROJECT'], 37 | google_client_email: ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'], 38 | google_json_key_string: '{"private_key": "' + ENV['SERVICE_ACCOUNT_PRIVATE_KEY'] + '", 39 | "client_email": "' + ENV['SERVICE_ACCOUNT_CLIENT_EMAIL'] + '"}' 40 | } 41 | config.fog_directory = ENV['CLOUD_STORAGE_BUCKET_NAME'] 42 | config.asset_host = "https://storage.googleapis.com/#{ENV['CLOUD_STORAGE_BUCKET_NAME']}" 43 | config.fog_public = true 44 | config.fog_attributes = { cache_control: 'max-age=31536000' } # one year 45 | config.root = Rails.root.join('tmp') 46 | config.cache_dir = 'carrierwave-cache' 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | # # If you are using webpack-dev-server then specify webpack-dev-server host 15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 6 | ] 7 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/new_framework_defaults_5_1.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 5.1 upgrade. 4 | # 5 | # Once upgraded flip defaults one by one to migrate to the new default. 6 | # 7 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 8 | 9 | # Make `form_with` generate non-remote forms. 10 | Rails.application.config.action_view.form_with_generates_remote_forms = false 11 | 12 | # Unknown asset fallback will return the path passed in when the given 13 | # asset is not present in the asset pipeline. 14 | # Rails.application.config.assets.unknown_asset_fallback = false 15 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/rack_timeout.rb: -------------------------------------------------------------------------------- 1 | # Timeout 2 | # Is the time taken from when a request first enters rack to when its response is sent back. When 3 | # the application takes longer than the time specified below to process a request, the request's 4 | # status is logged as timed_out and Rack::Timeout::RequestTimeoutException or 5 | # Rack::Timeout::RequestTimeoutError is raised on the application thread. 6 | Rack::Timeout::Logger.disable 7 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_datastore_example_rails_app_session' 4 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers a minimum and maximum. 3 | # On MRI, there is a Global Interpreter Lock (GIL) that ensures only one 4 | # thread can be run at any time. IO operations such as database calls, 5 | # interacting with the file system, or making external http calls will not 6 | # lock the GIL. Most Rails applications heavily use IO, so adding additional 7 | # threads will allow Puma to process multiple threads. 8 | # 9 | threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 10 | threads threads_count, threads_count 11 | 12 | rackup DefaultRackup 13 | port ENV.fetch('PORT', 3000) 14 | environment ENV.fetch('RAILS_ENV', 'development') 15 | 16 | if ENV.fetch('WEB_CONCURRENCY', 0).to_i > 1 17 | # Puma can fork multiple OS processes within each instance to allow Rails 18 | # to support multiple concurrent requests (Cluster Mode). In Puma terminology 19 | # these are referred to as worker processes. Worker processes are isolated from 20 | # one another at the OS level, therefore not needing to be thread safe. Rule of 21 | # thumb is to set the number of workers equal to the number of CPU cores. 22 | # 23 | workers ENV.fetch('WEB_CONCURRENCY').to_i 24 | 25 | # Use the `preload_app!` method when specifying a `workers` number. 26 | # This directive tells Puma to first boot the application and load code 27 | # before forking the application. This takes advantage of Copy On Write 28 | # process behavior so workers use less memory. When you use preload_app, 29 | # your new code goes all in the master process, and is then copied to 30 | # the workers (meaning preload_app is only compatible with cluster mode). 31 | # If you use this option you need to make sure to reconnect any threads in 32 | # the `on_worker_boot` block. 33 | # 34 | preload_app! 35 | 36 | # Code to run in the master immediately before the master starts workers. As the master process 37 | # boots the rails application (and executes the initializers) before forking workers, it's 38 | # recommended to close any connections that were automatically established in the master to 39 | # prevent connection leakage. 40 | # 41 | before_fork do 42 | CloudDatastore.reset_dataset 43 | end 44 | 45 | # The code in the `on_worker_boot` will be called if you are using 46 | # clustered mode by specifying a number of `workers`. After each worker 47 | # process is booted this block will be run, if you are using `preload_app!` 48 | # option you will want to use this block to reconnect to any threads 49 | # or connections that may have been created at application boot, Ruby 50 | # cannot share connections between processes. Code in the block is run before 51 | # it starts serving requests. This is called every time a worker is to be started. 52 | # 53 | on_worker_boot do 54 | CloudDatastore.dataset 55 | end 56 | end 57 | 58 | # Code to run before doing a restart. This code should close log files, database connections, etc 59 | # so that their file descriptors don't leak into the restarted process. 60 | # 61 | on_restart do 62 | CloudDatastore.reset_dataset 63 | end 64 | 65 | # Allow puma to be restarted by `rails restart` command. 66 | plugin :tmp_restart 67 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get 'home/index' 3 | resources :users 4 | 5 | # You can have the root of your site routed with "root" 6 | root 'home#index' 7 | end 8 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Shared secrets are available across all environments. 14 | 15 | # shared: 16 | # api_key: a1B2c3D4e5F6 17 | 18 | # Environmental secrets are only available for that specific environment. 19 | 20 | development: 21 | secret_key_base: 321cd3431e9d85306ff737cfe960d0b806b2abd9e14e532393ac59ed1ac70f82791e0fcb0fccb1faec75db5e6a95c0050d6e4093576278a6560ea41602a21f40 22 | 23 | test: 24 | secret_key_base: a8d233a823b5120651c07a19d6c51d13e4b9f248ad38b949af9524b06bde12c9e841bafedb643deb46f3e4e81508572215d2acfe00543824d59197c2d5804dd8 25 | 26 | # Do not keep production secrets in the unencrypted secrets file. 27 | # Instead, either read values from the environment. 28 | # Or, use `bin/rails secrets:setup` to configure encrypted secrets 29 | # and move the `production:` environment over there. 30 | 31 | production: 32 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 33 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/lib/assets/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/lib/tasks/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/log/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/public/favicon.ico -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/start-local-datastore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cloud_datastore_emulator start --port=8180 tmp/local_datastore -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/controllers/users_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UsersControllerTest < ActionDispatch::IntegrationTest 4 | def setup 5 | super 6 | @user = create(:user, parent_key_id: 12345) 7 | end 8 | 9 | test 'should get index' do 10 | get users_url 11 | assert_response :success 12 | end 13 | 14 | test 'should get new' do 15 | get new_user_url 16 | assert_response :success 17 | end 18 | 19 | test 'should create user' do 20 | assert_difference('User.count_test_entities') do 21 | post users_url, params: { user: { name: 'User 2', email: 'user_2@test.com' } } 22 | end 23 | assert_redirected_to user_url(@user.id + 1) 24 | end 25 | 26 | test 'should show user' do 27 | get user_url(@user) 28 | assert_response :success 29 | end 30 | 31 | test 'should get edit' do 32 | get edit_user_url(@user) 33 | assert_response :success 34 | end 35 | 36 | test 'should update user' do 37 | patch user_url(@user), params: { user: { name: 'Updated User' } } 38 | assert_redirected_to user_url(@user) 39 | end 40 | 41 | test 'should destroy user' do 42 | assert_difference('User.count_test_entities', -1) do 43 | delete user_url(@user) 44 | end 45 | assert_redirected_to users_url 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/entity_class_method_extensions.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Additional methods added for testing only. 3 | # 4 | module EntityClassMethodExtensions 5 | def all_test_entities 6 | query = CloudDatastore.dataset.query(name) 7 | CloudDatastore.dataset.run(query) 8 | end 9 | 10 | def count_test_entities 11 | all_test_entities.length 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/factories/user_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :user do 3 | name { 'A Test User' } 4 | email { Faker::Internet.email } 5 | role { 1 } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/test/helpers/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/test/integration/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/test/mailers/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | test 'user attributes must not be empty' do 5 | attr = attributes_for(:user).except(:email, :enabled, :name, :role) 6 | user = User.new(attr) 7 | assert user.invalid? 8 | assert user.errors[:name].any? 9 | assert user.errors[:email].any? 10 | assert_equal 2, user.errors.messages.size 11 | assert user.enabled 12 | assert_equal 1, user.role 13 | end 14 | 15 | test 'user values should be formatted correctly' do 16 | user = User.new(attributes_for(:user)) 17 | assert user.valid?, user.errors.messages 18 | user.role = 1.to_s 19 | assert user.valid? 20 | assert_equal 1, user.role 21 | assert user.role.is_a?(Integer) 22 | end 23 | 24 | test 'user entity properties include' do 25 | assert User.method_defined? :entity_properties 26 | user = User.new 27 | assert user.entity_properties.include? 'email' 28 | assert user.entity_properties.include? 'enabled' 29 | assert user.entity_properties.include? 'name' 30 | assert user.entity_properties.include? 'role' 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require File.expand_path('../config/environment', __dir__) 3 | require 'entity_class_method_extensions' 4 | require 'rails/test_help' 5 | 6 | MOCK_ACCOUNT_ID = 1010101010101010 7 | 8 | class MockModel 9 | include ActiveModel::Datastore 10 | attr_accessor :name, :role 11 | validates :name, presence: true 12 | enable_change_tracking :name, :role 13 | 14 | def entity_properties 15 | %w[name role] 16 | end 17 | end 18 | 19 | class MockModelParent 20 | include ActiveModel::Datastore::NestedAttr 21 | attr_accessor :name 22 | attr_accessor :mock_models 23 | end 24 | 25 | # Make the methods within EntityTestExtensions available as class methods. 26 | MockModel.send :extend, EntityClassMethodExtensions 27 | MockModelParent.send :extend, EntityClassMethodExtensions 28 | User.send :extend, EntityClassMethodExtensions 29 | 30 | class ActiveSupport::TestCase 31 | include FactoryBot::Syntax::Methods 32 | 33 | def setup 34 | if `lsof -t -i TCP:8181`.to_i.zero? 35 | puts 'Starting the cloud datastore emulator in test mode.' 36 | data_dir = Rails.root.join('tmp', 'test_datastore') 37 | spawn "cloud_datastore_emulator start --port=8181 --testing #{data_dir} > /dev/null 2>&1" 38 | loop do 39 | Net::HTTP.get('localhost', '/', '8181').include? 'Ok' 40 | break 41 | rescue Errno::ECONNREFUSED 42 | sleep 0.2 43 | end 44 | end 45 | CloudDatastore.dataset 46 | end 47 | 48 | def teardown 49 | delete_all_test_entities! 50 | end 51 | 52 | def delete_all_test_entities! 53 | entity_kinds = %w[MockModelParent MockModel User] 54 | entity_kinds.each do |kind| 55 | query = CloudDatastore.dataset.query(kind) 56 | loop do 57 | entities = CloudDatastore.dataset.run(query) 58 | break if entities.empty? 59 | 60 | CloudDatastore.dataset.delete(*entities) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/tmp/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/vendor/assets/javascripts/.keep -------------------------------------------------------------------------------- /test/support/datastore_example_rails_app/vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/test/support/datastore_example_rails_app/vendor/assets/stylesheets/.keep -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'active_support' 3 | require 'active_support/testing/autorun' 4 | require 'entity_class_method_extensions' 5 | require 'factory_bot' 6 | require 'faker' 7 | 8 | require 'google/cloud/datastore' 9 | require 'active_model' 10 | require 'carrierwave' 11 | require 'active_model/datastore/carrier_wave_uploader' 12 | require 'active_model/datastore/connection' 13 | require 'active_model/datastore/errors' 14 | require 'active_model/datastore/excluded_indexes' 15 | require 'active_model/datastore/nested_attr' 16 | require 'active_model/datastore/property_values' 17 | require 'active_model/datastore/track_changes' 18 | require 'active_model/datastore' 19 | require 'action_controller/metal/strong_parameters' 20 | 21 | FactoryBot.find_definitions 22 | 23 | MOCK_PARENT_ID = 1010101010101010 24 | 25 | class MockModel 26 | include ActiveModel::Datastore 27 | attr_accessor :name, :role, :image, :images 28 | validates :name, presence: true 29 | enable_change_tracking :name, :role 30 | 31 | def entity_properties 32 | %w[name role image images] 33 | end 34 | end 35 | 36 | class MockModelParent 37 | include ActiveModel::Datastore::NestedAttr 38 | attr_accessor :name 39 | attr_accessor :mock_models 40 | end 41 | 42 | # Make the methods within EntityTestExtensions available as class methods. 43 | MockModel.send :extend, EntityClassMethodExtensions 44 | MockModelParent.send :extend, EntityClassMethodExtensions 45 | 46 | class ActiveSupport::TestCase 47 | include FactoryBot::Syntax::Methods 48 | 49 | def setup 50 | if `lsof -t -i TCP:8181`.to_i.zero? 51 | puts 'Starting the cloud datastore emulator in test mode.' 52 | data_dir = File.join(File.expand_path('..', __dir__), 'tmp', 'test_datastore') 53 | spawn "cloud_datastore_emulator start --port=8181 --testing #{data_dir} > /dev/null 2>&1" 54 | loop do 55 | Net::HTTP.get('localhost', '/', '8181').include? 'Ok' 56 | break 57 | rescue Errno::ECONNREFUSED 58 | sleep 0.2 59 | end 60 | end 61 | if defined?(Rails) != 'constant' 62 | ENV['DATASTORE_EMULATOR_HOST'] = 'localhost:8181' 63 | ENV['GCLOUD_PROJECT'] = 'test-datastore' 64 | end 65 | CloudDatastore.dataset 66 | carrierwave_init 67 | end 68 | 69 | def carrierwave_init 70 | CarrierWave.configure do |config| 71 | config.reset_config 72 | config.storage = :file 73 | config.enable_processing = false 74 | config.root = File.join(Dir.pwd, 'tmp', 'carrierwave-tests') 75 | config.cache_dir = 'carrierwave-cache' 76 | end 77 | end 78 | 79 | def teardown 80 | delete_all_test_entities! 81 | FileUtils.rm_rf(CarrierWave::Uploader::Base.root) 82 | CarrierWave.configure(&:reset_config) 83 | MockModel.clear_index_exclusions! 84 | end 85 | 86 | def delete_all_test_entities! 87 | entity_kinds = %w[MockModelParent MockModel] 88 | entity_kinds.each do |kind| 89 | query = CloudDatastore.dataset.query(kind) 90 | loop do 91 | entities = CloudDatastore.dataset.run(query) 92 | break if entities.empty? 93 | 94 | CloudDatastore.dataset.delete(*entities) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agrimatics/activemodel-datastore/107c0d32c1c61c58a5595e0313e9a0b5f9b1c509/tmp/.keep --------------------------------------------------------------------------------