├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── Dockerfile ├── Gemfile ├── Gemfile.base ├── Gemfile.docker ├── Gemfile.rails-5-2 ├── Gemfile.rails-6-0 ├── Gemfile.rails-6-1 ├── Gemfile.rails-7-0 ├── Gemfile.rails-7-0-ruby-3-1 ├── Gemfile.rails-7-1 ├── Gemfile.rails-7-2 ├── Gemfile.rails-8-0 ├── Gemfile.rails-main ├── LICENSE ├── README.md ├── Rakefile ├── active_record_upsert.gemspec ├── bin ├── console ├── run_docker_test.sh ├── run_rails.sh └── setup ├── docker-compose.yml ├── lib ├── active_record_upsert.rb └── active_record_upsert │ ├── active_record.rb │ ├── active_record │ ├── connection_adapters │ │ ├── abstract │ │ │ └── database_statements.rb │ │ └── postgresql │ │ │ └── database_statements.rb │ ├── persistence.rb │ ├── timestamp.rb │ └── transactions.rb │ ├── arel.rb │ ├── arel │ ├── insert_manager.rb │ ├── nodes.rb │ ├── nodes │ │ ├── do_nothing.rb │ │ ├── do_update_set.rb │ │ ├── excluded_column.rb │ │ ├── insert_statement.rb │ │ ├── on_conflict.rb │ │ └── on_conflict_action.rb │ ├── on_conflict_do_update_manager.rb │ ├── table_extensions.rb │ └── visitors │ │ └── to_sql.rb │ ├── compatibility │ ├── rails60.rb │ └── rails70.rb │ └── version.rb └── spec ├── active_record ├── base_spec.rb ├── inheritance_spec.rb ├── key_spec.rb └── notifications_spec.rb ├── active_record_upsert_spec.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── application.js │ │ │ ├── cable.coffee │ │ │ └── channels │ │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── account.rb │ │ ├── application_record.rb │ │ ├── bicycle.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── my_record.rb │ │ └── vehicle.rb │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ └── update ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── active_record_belongs_to_required_by_default.rb │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── per_form_csrf_tokens.rb │ │ ├── request_forgery_protection.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── secrets.yml ├── db │ ├── development.sqlite3 │ ├── migrate │ │ ├── 20160419103547_create_vehicles.rb │ │ ├── 20160419124138_create_my_records.rb │ │ ├── 20160419124140_create_accounts.rb │ │ └── 20190428142610_add_year_to_vehicles.rb │ ├── schema.rb │ ├── structure.sql │ └── test.sqlite3 ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ └── favicon.ico └── tmp │ └── .keep └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, reopened, synchronize ] 6 | push: 7 | branches: 8 | - 'main' 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | env: 15 | POSTGRES_DB: upsert_test 16 | POSTGRES_PASSWORD: postgres 17 | POSTGRES_USER: postgres 18 | continue-on-error: ${{ matrix.experimental }} 19 | services: 20 | postgres: 21 | image: postgres 22 | env: 23 | POSTGRES_DB: upsert_test 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_USER: postgres 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | ports: 32 | - 5432:5432 33 | 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | ruby-version: ["2.6", "2.7", "3.0"] 38 | gemfile: [Gemfile.rails-5-2, Gemfile.rails-6-0, Gemfile.rails-6-1] 39 | experimental: [false] 40 | include: 41 | - ruby-version: "2.7" 42 | gemfile: Gemfile.rails-7-0 43 | experimental: false 44 | - ruby-version: "3.0" 45 | gemfile: Gemfile.rails-7-0 46 | experimental: false 47 | - ruby-version: "3.1" 48 | gemfile: Gemfile.rails-7-0-ruby-3-1 49 | experimental: false 50 | - ruby-version: "3.2" 51 | gemfile: Gemfile.rails-7-1 52 | experimental: false 53 | - ruby-version: "3.3" 54 | gemfile: Gemfile.rails-7-2 55 | experimental: false 56 | - ruby-version: "3.3" 57 | gemfile: Gemfile.rails-8-0 58 | experimental: false 59 | - ruby-version: "3.3" 60 | gemfile: Gemfile.rails-main 61 | experimental: true 62 | 63 | exclude: 64 | - ruby-version: "3.0" # https://github.com/rails/rails/issues/40938 65 | gemfile: Gemfile.rails-5-2 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Prepare database 70 | run: | 71 | psql postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB < spec/dummy/db/structure.sql 72 | 73 | - name: Copy over Gemfile 74 | env: 75 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 76 | run: | 77 | mv $BUNDLE_GEMFILE Gemfile 78 | 79 | - name: Set up Ruby 80 | uses: ruby/setup-ruby@v1 81 | with: 82 | ruby-version: ${{ matrix.ruby-version }} 83 | bundler-cache: true 84 | rubygems: 3.2.3 85 | 86 | - name: Run Tests 87 | run: | 88 | export DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB 89 | bundle exec rake spec 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /spec/dummy/log/*.log 11 | /Gemfile.rails-5-0.lock 12 | /Gemfile.rails-5-1.lock 13 | /Gemfile.rails-5-2.lock 14 | /Gemfile.rails-main.lock 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | --color 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:latest 2 | RUN ruby --version 3 | 4 | ENV BUNDLE_GEMFILE=/app/Gemfile.docker 5 | RUN gem install bundler nokogiri 6 | COPY Gemfile* *.gemspec /app/ 7 | RUN mkdir -p /app/lib/active_record_upsert 8 | COPY lib/active_record_upsert/version.rb /app/lib/active_record_upsert/ 9 | WORKDIR /app 10 | RUN bundle install 11 | COPY . /app 12 | CMD bin/run_docker_test.sh 13 | 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile "#{__dir__}/Gemfile.rails-8-0" 2 | -------------------------------------------------------------------------------- /Gemfile.base: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem 'bundler', '>= 1.13' 7 | gem 'database_cleaner', '~> 1.6' 8 | gem 'pg', '~> 1.1' 9 | gem 'pry', '> 0' 10 | gem 'rake', '>= 10.0' 11 | gem 'rspec', '>= 3.0', '< 4' 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile.docker: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.rails-5-2: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 5.2.1' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-6-0: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 6.0.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-6-1: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 6.1.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-7-0: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 7.0.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-7-0-ruby-3-1: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 7.0.0', '>= 7.0.1' # 7.0.1 is the first version compatible with ruby 3.1 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-7-1: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 7.1.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-7-2: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 7.2.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-8-0: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', '~> 8.0.0' 3 | end 4 | 5 | eval_gemfile "#{__dir__}/Gemfile.base" 6 | -------------------------------------------------------------------------------- /Gemfile.rails-main: -------------------------------------------------------------------------------- 1 | group :development, :test do 2 | gem 'rails', github: 'rails/rails' 3 | end 4 | 5 | eval_gemfile "./Gemfile.base" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jesper Josefsson 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/active_record_upsert.svg)](https://badge.fury.io/rb/active_record_upsert) 2 | [![CI](https://github.com/jesjos/active_record_upsert/actions/workflows/ci.yml/badge.svg)](https://github.com/jesjos/active_record_upsert/actions/workflows/ci.yml) 3 | # ActiveRecordUpsert 4 | 5 | Real upsert for PostgreSQL 9.5+ and Rails 5.2+ / ActiveRecord 5.2+. Uses [ON CONFLICT DO UPDATE](http://www.postgresql.org/docs/9.5/static/sql-insert.html). 6 | 7 | ## Main points 8 | 9 | - Does upsert on a single record using `ON CONFLICT DO UPDATE` 10 | - Updates timestamps as you would expect in ActiveRecord 11 | - For partial upserts, loads any existing data from the database 12 | 13 | ## Prerequisites 14 | 15 | - PostgreSQL 9.5+ (that's when UPSERT support was added; see Wikipedia's [PostgreSQL Release History](https://en.wikipedia.org/wiki/PostgreSQL#Release_history)) 16 | - ActiveRecord >= 5.2 17 | - Ruby MRI, with the `pg` gem 18 | - _JRuby is currently not supported_ 19 | 20 | ## Alternatives 21 | 22 | This library was written at a time in history when Rails did not support any `#upsert` method. 23 | 24 | Instead of using this library, if you are using a current version of Rails, you may want to [use its `#upsert`](https://api.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-upsert). You may want to investigate how [newer PostgreSQL versions support `MERGE` statement](https://www.postgresql.org/docs/current/sql-merge.html). 25 | 26 | ### NB: Releases to avoid 27 | 28 | Due to a broken build matrix, v0.9.2 and v0.9.3 are incompatible with Rails 29 | < 5.2.1. [v0.9.4](https://github.com/jesjos/active_record_upsert/releases/tag/v0.9.4) fixed this issue. 30 | 31 | ### Supported Rails versions 32 | 33 | This library is compatible with all major Rails versions covered by the Rails ["Severe Security Issues" maintenance policy](https://guides.rubyonrails.org/maintenance_policy.html). 34 | 35 | ### Supported Ruby versions 36 | 37 | This library may be compatible with older versions of Ruby, however we only run automated tests using the [officially supported Ruby versions](https://www.ruby-lang.org/en/downloads/branches/). 38 | 39 | ## Installation 40 | 41 | Add this line to your application's Gemfile: 42 | 43 | ```ruby 44 | gem 'active_record_upsert' 45 | ``` 46 | 47 | And then execute: 48 | 49 | ```console 50 | bundle 51 | ``` 52 | 53 | Or install it yourself as: 54 | 55 | ```console 56 | gem install active_record_upsert 57 | ``` 58 | 59 | ## Usage 60 | 61 | ### Create 62 | 63 | Use `ActiveRecord.upsert` or `ActiveRecord#upsert`. _ActiveRecordUpsert_ respects timestamps. 64 | 65 | ```ruby 66 | class MyRecord < ActiveRecord::Base 67 | end 68 | 69 | MyRecord.create(name: 'foo', wisdom: 1) 70 | # => # 71 | 72 | MyRecord.upsert(id: 1, wisdom: 3) 73 | # => # 74 | 75 | r = MyRecord.new(id: 1) 76 | r.name = 'bar' 77 | r.upsert 78 | # => # 79 | ``` 80 | 81 | ### Update 82 | 83 | If you need to specify a condition for the update, pass it as an Arel query: 84 | 85 | ```ruby 86 | MyRecord.upsert({id: 1, wisdom: 3}, arel_condition: MyRecord.arel_table[:updated_at].lt(1.day.ago)) 87 | ``` 88 | 89 | The instance method `#upsert` can also take keyword arguments to specify a condition, or to limit which attributes to upsert (by default, all `changed` attributes will be passed to the upsert): 90 | 91 | ```ruby 92 | r = MyRecord.new(id: 1) 93 | r.name = 'bar' 94 | r.color = 'blue' 95 | r.upsert(attributes: [:name], arel_condition: MyRecord.arel_table[:updated_at].lt(1.day.ago)) 96 | # will only update :name, and only if the record is older than 1 day; 97 | # but if the record does not exist, will insert with both :name and :colors 98 | ``` 99 | 100 | ### Create with specific Attributes 101 | 102 | If you want to create a record with the specific attributes, but update only a limited set of attributes, similar to how `ActiveRecord::Base.create_with` works, you can do the following: 103 | 104 | ```ruby 105 | existing_record = MyRecord.create(id: 1, name: 'lemon', color: 'green') 106 | r = MyRecord.new(id: 1, name: 'banana', color: 'yellow') 107 | r.upsert(attributes: [:color]) 108 | # => # 109 | 110 | r = MyRecord.new(id: 2, name: 'banana', color: 'yellow') 111 | r.upsert(attributes: [:color]) 112 | 113 | # => # 114 | 115 | # This is similar to: 116 | 117 | MyRecord.create_with(name: 'banana').find_or_initialize_by(id: 2).update(color: 'yellow') 118 | 119 | ``` 120 | 121 | ### Validations 122 | 123 | Upsert will perform validation on the object, and return false if it is not valid. To skip validation, pass `validate: false`: 124 | 125 | ```ruby 126 | MyRecord.upsert({id: 1, wisdom: 3}, validate: false) 127 | ``` 128 | 129 | If you want validations to raise `ActiveRecord::RecordInvalid`, use `upsert!`: 130 | 131 | ```ruby 132 | MyRecord.upsert!(id: 1, wisdom: 3) 133 | ``` 134 | 135 | Or using the instance method: 136 | 137 | ```ruby 138 | r = MyRecord.new(id: 1, name: 'bar') 139 | r.upsert! 140 | ``` 141 | 142 | ### Gotcha with database defaults 143 | 144 | When a table is defined with a database default for a field, this gotcha can occur when trying to explicitly upsert a record _to_ the default value (from a non-default value). 145 | 146 | **Example**: a table called `hardwares` has a `prio` column with a default value. 147 | 148 | ```text 149 | ┌─────────┬─────────┬─────────┬ 150 | │ Column │ Type │ Default │ 151 | ├─────────┼─────────┼─────────┼ 152 | │ id │ integer │ ... | 153 | │ prio │ integer │ 999 | 154 | ``` 155 | 156 | And `hardwares` has a record with a non-default value for `prio`. Say, the record with `id` 1 has a `prio` of `998`. 157 | 158 | In this situation, upserting like: 159 | 160 | ```ruby 161 | hw = { id: 1, prio: 999 } 162 | Hardware.new(prio: hw[:prio]).upsert 163 | ``` 164 | 165 | will not mention the `prio` column in the `ON CONFLICT` clause, resulting in no update. 166 | 167 | However, upserting like so: 168 | 169 | ```ruby 170 | Hardware.upsert(prio: hw[:prio]).id 171 | ``` 172 | 173 | will indeed update the record in the database back to its default value, `999`. 174 | 175 | ### Conflict Clauses 176 | 177 | It's possible to specify which columns should be used for the conflict clause. **These must comprise a unique index in Postgres.** 178 | 179 | ```ruby 180 | class Vehicle < ActiveRecord::Base 181 | upsert_keys [:make, :name] 182 | end 183 | 184 | Vehicle.upsert(make: 'Ford', name: 'F-150', doors: 4) 185 | # => # 186 | 187 | Vehicle.create(make: 'Ford', name: 'Focus', doors: 4) 188 | # => # 189 | 190 | r = Vehicle.new(make: 'Ford', name: 'F-150') 191 | r.doors = 2 192 | r.upsert 193 | # => # 194 | ``` 195 | 196 | Partial indexes can be supported with the addition of a `where` clause. 197 | 198 | ```ruby 199 | class Account < ApplicationRecord 200 | upsert_keys :name, where: 'active is TRUE' 201 | end 202 | ``` 203 | 204 | Custom index can be handled with a Hash containing a literal key : 205 | 206 | ```ruby 207 | class Account < ApplicationRecord 208 | upsert_keys literal: 'md5(my_long_field)' 209 | end 210 | ``` 211 | 212 | Overriding the models' `upsert_keys` when calling `#upsert` or `.upsert`: 213 | 214 | ```ruby 215 | Account.upsert(attrs, opts: { upsert_keys: [:foo, :bar] }) 216 | # Or, on an instance: 217 | account = Account.new(attrs) 218 | account.upsert(opts: { upsert_keys: [:foo, :bar] }) 219 | ``` 220 | 221 | Overriding the models' `upsert_options` (partial index) when calling `#upsert` or `.upsert`: 222 | 223 | ```ruby 224 | Account.upsert(attrs, opts: { upsert_options: { where: 'foo IS NOT NULL' } }) 225 | # Or, on an instance: 226 | account = Account.new(attrs) 227 | account.upsert(opts: { upsert_options: { where: 'foo IS NOT NULL' } }) 228 | ``` 229 | 230 | ## Comparing to native Rails 6 Upsert 231 | 232 | Rails 6 (via the ["Add insert_many to ActiveRecord models" PR #35077](https://github.com/rails/rails/pull/35077)) added the ability to create or update individual records through `#insert` and `#upsert` and similarly the ability to create or update multiple records through `#insert_all` and `#upsert_all`. 233 | 234 | Here is a quick comparison of how the Rails native `ActiveRecord::Persistence#upsert` feature compares to what's offered in this gem: 235 | 236 | | Feature | `active_record_upsert` | Rails native `ActiveRecord::Persistence#upsert` | 237 | | ------------------------------------------------------- | ------------------------------- | -------------------------------------------------------- | 238 | | Set model level conflict clause | Yes, through `#upsert_keys` | No, but can be passed in through the `:unique_by` option | 239 | | Ability to invoke validations and callbacks | Yes | No | 240 | | Automatically sets `created_at`/`updated_at` timestamps | Yes | Yes (Rails 7.0+) | 241 | | Checks for unique index on the database | No[^1] | Yes | 242 | | Use associations in upsert calls | Yes | No | 243 | | Return object type | Instantiated ActiveRecord model | `ActiveRecord::Result` | 244 | 245 | [^1]: Though the gem does not check for the index first, the upsert will still fail due to the database constraint. 246 | 247 | ## Tests 248 | 249 | Make sure to have an upsert_test database: 250 | 251 | ```shell 252 | bin/run_rails.sh db:create db:migrate DATABASE_URL=postgresql://localhost/upsert_test 253 | ``` 254 | 255 | Then run `rspec`. 256 | 257 | ## Contributing 258 | 259 | Bug reports and pull requests are welcome on GitHub at . 260 | 261 | ## Contributors 262 | 263 | - Jesper Josefsson 264 | - Aurora Nockert 265 | - Olle Jonsson 266 | - Simon Dahlbacka 267 | - Paul Hoffer 268 | - Ivan ([@me](https://github.com/me)) 269 | - Leon Miller-Out ([@sbleon](https://github.com/sbleon)) 270 | - Andrii Dmytrenko ([@Antti](https://github.com/Antti)) 271 | - Alexia McDonald ([@alexiamcdonald](https://github.com/alexiamcdonald)) 272 | - Timo Schilling ([@timoschilling](https://github.com/timoschilling)) 273 | - Benedikt Deicke ([@benedikt](https://github.com/benedikt)) 274 | - Daniel Cooper ([@danielcooper](https://github.com/danielcooper)) 275 | - Laurent Vallar ([@val](https://github.com/val)) 276 | - Emmanuel Quentin ([@manuquentin](https://github.com/manuquentin)) 277 | - Jeff Wallace ([@tjwallace](https://github.com/tjwallace)) 278 | - Kirill Zaitsev ([@Bugagazavr](https://github.com/Bugagazavr)) 279 | - Nick Campbell ([@nickcampbell18](https://github.com/nickcampbell18)) 280 | - Mikhail Doronin ([@misdoro](https://github.com/misdoro)) 281 | - Jan Graichen ([@jgraichen](https://github.com/jgraichen)) 282 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'logger' # Fix concurrent-ruby removing "logger" dependency which Rails itself does not have 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | require 'active_record' 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :setup_and_run_spec do |rake_task| 8 | puts "<:#{rake_task.name}> Ensuring database is prepared..." 9 | 10 | # Configure Rails Environment 11 | ENV['RAILS_ENV'] = 'test' 12 | ENV['DATABASE_URL'] ||= 'postgresql://localhost/upsert_test' 13 | require 'active_record/connection_adapters/postgresql_adapter' 14 | 15 | require File.expand_path('../spec/dummy/config/environment.rb', __FILE__) 16 | 17 | if Rails.version >= '5.2.0' 18 | ActiveRecord::Base.connection.migrations_paths << 'spec/dummy/db/migrate' 19 | end 20 | 21 | include ActiveRecord::Tasks 22 | DatabaseTasks.db_dir = 'spec/dummy/db' 23 | DatabaseTasks.drop_current 24 | DatabaseTasks.create_current 25 | DatabaseTasks.migrate 26 | 27 | Rake::Task['spec'].invoke 28 | end 29 | 30 | task default: :setup_and_run_spec 31 | -------------------------------------------------------------------------------- /active_record_upsert.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_record_upsert/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "active_record_upsert" 8 | spec.version = ActiveRecordUpsert::VERSION 9 | spec.authors = ["Jesper Josefsson", "Olle Jonsson"] 10 | spec.email = ["jesper.josefsson@gmail.com", "olle.jonsson@gmail.com"] 11 | spec.homepage = "https://github.com/jesjos/active_record_upsert/" 12 | spec.license = 'MIT' 13 | 14 | spec.summary = %q{Real PostgreSQL 9.5+ upserts using ON CONFLICT for ActiveRecord} 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(.github|bin|test|spec|features)/}) } - 17 | %w[.gitignore .rspec Dockerfile Gemfile Gemfile.docker docker-compose.yml] 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.platform = Gem::Platform::RUBY 23 | 24 | spec.add_runtime_dependency 'activerecord', '>= 5.2', '< 8.1' 25 | spec.add_runtime_dependency 'pg', '>= 0.18', '< 2.0' 26 | end 27 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require 'active_record' 5 | require 'active_record/connection_adapters/postgresql_adapter' 6 | require "active_record_upsert" 7 | require File.join(__dir__, '../spec/setup') 8 | ActiveRecord::Base.logger = Logger.new(STDOUT) 9 | 10 | # You can add fixtures and/or initialization code here to make experimenting 11 | # with your gem easier. You can also use a different console, if you like. 12 | 13 | # (If you use this, don't forget to add pry to your Gemfile!) 14 | # require "pry" 15 | # Pry.start 16 | 17 | require "irb" 18 | IRB.start 19 | -------------------------------------------------------------------------------- /bin/run_docker_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | pushd spec/dummy 3 | DATABASE_URL=postgresql://localhost/upsert_test RAILS_ENV=test rails db:migrate 4 | popd 5 | RAILS_ENV=test bundle exec rspec 6 | -------------------------------------------------------------------------------- /bin/run_rails.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | pushd spec/dummy 3 | RAILS_ENV=test bundle exec rails $@ 4 | popd 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | db: 4 | image: postgres:9.5 5 | ports: 6 | - "5432:5432" 7 | app: 8 | environment: 9 | - DATABASE_URL=postgresql://postgres@db/active_record_upsert_test 10 | build: . 11 | depends_on: 12 | - db 13 | links: 14 | - db -------------------------------------------------------------------------------- /lib/active_record_upsert.rb: -------------------------------------------------------------------------------- 1 | require 'active_record_upsert/version' 2 | 3 | unless defined?(Arel) 4 | raise 'ActiveRecordUpsert has to be required after ActiveRecord/Arel' 5 | end 6 | 7 | unless defined?(ActiveRecord) 8 | raise 'ActiveRecordUpsert has to be required after ActiveRecord' 9 | end 10 | 11 | require 'active_record_upsert/arel' 12 | require 'active_record_upsert/active_record' 13 | 14 | version = defined?(Rails) ? Rails.version : ActiveRecord.version.to_s 15 | 16 | if version >= '7.0.0' 17 | require 'active_record_upsert/compatibility/rails70.rb' 18 | elsif version >= '6.0.0' && version < '6.2.0' 19 | require 'active_record_upsert/compatibility/rails60.rb' 20 | end 21 | 22 | module ActiveRecordUpsert 23 | # Your code goes here... 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record.rb: -------------------------------------------------------------------------------- 1 | unless defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) 2 | require 'active_record/connection_adapters/postgresql_adapter' 3 | end 4 | Dir.glob(File.join(__dir__, 'active_record/**/*.rb')) do |f| 5 | require f 6 | end 7 | 8 | module ActiveRecord 9 | RecordSavedError = Class.new(ActiveRecordError) 10 | end 11 | 12 | ::ActiveRecord::Base.prepend(ActiveRecordUpsert::ActiveRecord::PersistenceExtensions) 13 | ::ActiveRecord::Base.extend(ActiveRecordUpsert::ActiveRecord::PersistenceExtensions::ClassMethods) 14 | ::ActiveRecord::Base.prepend(ActiveRecordUpsert::ActiveRecord::TimestampExtensions) 15 | ::ActiveRecord::Base.prepend(ActiveRecordUpsert::ActiveRecord::TransactionsExtensions) 16 | 17 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(ActiveRecordUpsert::ActiveRecord::ConnectionAdapters::Abstract::DatabaseStatementsExtensions) 18 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(ActiveRecordUpsert::ActiveRecord::ConnectionAdapters::Postgresql::DatabaseStatementsExtensions) 19 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record/connection_adapters/abstract/database_statements.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module ConnectionAdapters 4 | module Abstract 5 | module DatabaseStatementsExtensions 6 | def exec_upsert(_sql, _name, _binds, _pk) 7 | raise NotImplementedError 8 | end 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record/connection_adapters/postgresql/database_statements.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module ConnectionAdapters 4 | module Postgresql 5 | module DatabaseStatementsExtensions 6 | def upsert(arel, name = nil, binds = []) 7 | sql, binds = to_sql_and_binds(arel, binds) 8 | exec_upsert(sql, name, binds) 9 | end 10 | 11 | def exec_upsert(sql, name, binds) 12 | exec_query("#{sql} RETURNING *, (xmax = 0) AS _upsert_created_record", name, binds) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record/persistence.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module PersistenceExtensions 4 | def upsert!(attributes: nil, arel_condition: nil, validate: true, opts: {}) 5 | raise ::ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? 6 | raise ::ActiveRecord::RecordSavedError, "Can't upsert a record that has already been saved" if persisted? 7 | validate == false || perform_validations || raise_validation_error 8 | run_callbacks(:save) { 9 | run_callbacks(:create) { 10 | attributes ||= changed 11 | attributes = attributes + 12 | timestamp_attributes_for_create_in_model + 13 | timestamp_attributes_for_update_in_model 14 | _upsert_record(attributes.map(&:to_s).uniq, arel_condition, opts) 15 | } 16 | } 17 | 18 | self 19 | end 20 | 21 | def upsert(**kwargs) 22 | upsert!(**kwargs) 23 | rescue ::ActiveRecord::RecordInvalid 24 | false 25 | end 26 | 27 | def _upsert_record(upsert_attribute_names = changed, arel_condition = nil, opts = {}) 28 | existing_attribute_names = attributes_for_create(attributes.keys) 29 | existing_attributes = attributes_with_values(existing_attribute_names) 30 | values = self.class._upsert_record(existing_attributes, upsert_attribute_names, [arel_condition].compact, opts) 31 | @attributes = self.class.attributes_builder.build_from_database(values.first.to_h) 32 | @new_record = false 33 | changes_applied 34 | values 35 | end 36 | 37 | def upsert_operation 38 | created_record = self['_upsert_created_record'] 39 | return if created_record.nil? 40 | created_record ? :create : :update 41 | end 42 | 43 | module ClassMethods 44 | def upsert!(attributes, arel_condition: nil, validate: true, opts: {}, &block) 45 | if attributes.is_a?(Array) 46 | attributes.collect { |hash| upsert(hash, &block) } 47 | else 48 | new(attributes, &block).upsert!( 49 | attributes: attributes.keys, arel_condition: arel_condition, validate: validate, opts: opts 50 | ) 51 | end 52 | end 53 | 54 | def upsert(attributes, **kwargs, &block) 55 | upsert!(attributes, **kwargs, &block) 56 | rescue ::ActiveRecord::RecordInvalid 57 | false 58 | end 59 | 60 | def _upsert_record(existing_attributes, upsert_attributes_names, wheres, opts) # :nodoc: 61 | upsert_keys = opts[:upsert_keys] || self.upsert_keys || [primary_key] 62 | upsert_options = opts[:upsert_options] || self.upsert_options 63 | upsert_attributes_names = upsert_attributes_names - [*upsert_keys, 'created_at'] 64 | 65 | existing_attributes = existing_attributes 66 | .transform_keys { |name| _prepare_column(name) } 67 | .reject { |key, _| key.nil? } 68 | 69 | upsert_attributes_names = upsert_attributes_names 70 | .map { |name| _prepare_column(name) } 71 | .compact 72 | 73 | values_for_upsert = existing_attributes.select { |(name, _value)| upsert_attributes_names.include?(name) } 74 | 75 | insert_manager = arel_table.compile_upsert( 76 | upsert_keys, 77 | upsert_options, 78 | _substitute_values(values_for_upsert), 79 | _substitute_values(existing_attributes), 80 | wheres 81 | ) 82 | 83 | connection.upsert(insert_manager, "#{self} Upsert") 84 | end 85 | 86 | def _prepare_column(column) 87 | column = attribute_alias(column) if attribute_alias?(column) 88 | 89 | if columns_hash.key?(column) 90 | column 91 | elsif reflections.key?(column) 92 | reflections[column].foreign_key 93 | end 94 | end 95 | 96 | def upsert_keys(*keys) 97 | return @_upsert_keys if keys.empty? 98 | options = keys.extract_options! 99 | keys = keys.first if keys.size == 1 # support single string/symbol, multiple string/symbols, and array 100 | return if keys.nil? 101 | @_upsert_keys = Array(keys) 102 | @_upsert_options = options 103 | end 104 | 105 | def upsert_options 106 | @_upsert_options || {} 107 | end 108 | 109 | def inherited(subclass) 110 | super 111 | subclass.upsert_keys(upsert_keys, upsert_options) 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record/timestamp.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module TimestampExtensions 4 | def _upsert_record(*args) 5 | if self.record_timestamps 6 | current_time = current_time_from_proper_timezone 7 | 8 | all_timestamp_attributes_in_model.each do |column| 9 | column = column.to_s 10 | if has_attribute?(column) && !attribute_present?(column) 11 | write_attribute(column, current_time) 12 | end 13 | end 14 | end 15 | 16 | super 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/active_record_upsert/active_record/transactions.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module TransactionsExtensions 4 | def upsert(*args) 5 | rollback_active_record_state! do 6 | with_transaction_returning_status { super } 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel.rb: -------------------------------------------------------------------------------- 1 | require 'active_record_upsert/arel/nodes/on_conflict_action' 2 | require 'active_record_upsert/arel/nodes/insert_statement' 3 | 4 | Dir.glob(File.join(__dir__, 'arel/**/*.rb')) do |f| 5 | require f 6 | end 7 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/insert_manager.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module Arel 3 | module InsertManagerExtensions 4 | def on_conflict= node 5 | @ast.on_conflict = node 6 | end 7 | 8 | def do_nothing_on_conflict(target) 9 | @ast.on_conflict = Nodes::OnConflict.new.tap do |on_conflict| 10 | on_conflict.target = target 11 | on_conflict.action = Nodes::DoNothing.new 12 | end 13 | end 14 | end 15 | 16 | ::Arel::InsertManager.include(InsertManagerExtensions) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes.rb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/do_nothing.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | DoNothing = Class.new(OnConflictAction) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/do_update_set.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class DoUpdateSet < OnConflictAction 4 | attr_accessor :wheres, :values 5 | attr_accessor :key 6 | 7 | def initialize 8 | @wheres = [] 9 | @values = [] 10 | @key = nil 11 | end 12 | 13 | def initialize_copy other 14 | super 15 | @wheres = @wheres.clone 16 | @values = @values.clone 17 | end 18 | 19 | def hash 20 | [@relation, @wheres, @values, @key].hash 21 | end 22 | 23 | def eql? other 24 | self.class == other.class && 25 | self.relation == other.relation && 26 | self.wheres == other.wheres && 27 | self.values == other.values && 28 | self.key == other.key 29 | end 30 | alias :== :eql? 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/excluded_column.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class ExcludedColumn < Arel::Nodes::Node 4 | attr_reader :column 5 | def initialize(column) 6 | @column = column 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/insert_statement.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class InsertStatement 4 | attr_accessor :on_conflict 5 | 6 | def hash 7 | [@relation, @columns, @values, @select, @on_conflict].hash 8 | end 9 | 10 | def eql? other 11 | self.class == other.class && 12 | self.relation == other.relation && 13 | self.columns == other.columns && 14 | self.select == other.select && 15 | self.values == other.values && 16 | self.on_conflict == other.on_conflict 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/on_conflict.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class OnConflict < Node 4 | attr_accessor :target, :where, :action 5 | 6 | def initialize 7 | super 8 | @target = nil 9 | @action = nil 10 | @where = nil 11 | end 12 | 13 | def hash 14 | [@target, @action].hash 15 | end 16 | 17 | def eql? other 18 | self.class == other.class && 19 | self.target == other.target && 20 | self.update_statement == other.update_statement 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/nodes/on_conflict_action.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class OnConflictAction < Node 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/on_conflict_do_update_manager.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | class OnConflictDoUpdateManager < Arel::TreeManager 3 | def initialize 4 | super 5 | @ast = Nodes::OnConflict.new 6 | @action = Nodes::DoUpdateSet.new 7 | @ast.action = @action 8 | @ctx = @ast 9 | end 10 | 11 | def target_condition= where 12 | @ast.where = where 13 | end 14 | 15 | def target= column 16 | @ast.target = column 17 | end 18 | 19 | def target(column) 20 | @ast.target = column 21 | self 22 | end 23 | 24 | def wheres= exprs 25 | @action.wheres = exprs 26 | end 27 | 28 | def where expr 29 | @action.wheres << expr 30 | self 31 | end 32 | 33 | def to_node 34 | @ast 35 | end 36 | 37 | def set values 38 | if String === values 39 | @action.values = [values] 40 | else 41 | @action.values = values.map { |column,value| 42 | Nodes::Assignment.new( 43 | Nodes::UnqualifiedColumn.new(column), 44 | value 45 | ) 46 | } 47 | end 48 | self 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/table_extensions.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module Arel 3 | module TableExtensions 4 | def compile_upsert(upsert_keys, upsert_options, upsert_values, insert_values, wheres) 5 | # Support non-attribute key (like `md5(my_attribute)``) 6 | target = self[upsert_options.key?(:literal) ? ::Arel::Nodes::SqlLiteral.new(upsert_options[:literal]) : upsert_keys.join(',')] 7 | on_conflict_do_update = ::Arel::OnConflictDoUpdateManager.new 8 | 9 | on_conflict_do_update.target = target 10 | on_conflict_do_update.target_condition = upsert_options[:where] 11 | on_conflict_do_update.wheres = wheres 12 | on_conflict_do_update.set(upsert_values) 13 | 14 | insert_manager = ::Arel::InsertManager.new 15 | insert_manager.on_conflict = on_conflict_do_update.to_node 16 | insert_manager.into insert_values.first.first.relation 17 | insert_manager.insert(insert_values) 18 | insert_manager 19 | end 20 | end 21 | ::Arel::Table.prepend(TableExtensions) 22 | end 23 | end -------------------------------------------------------------------------------- /lib/active_record_upsert/arel/visitors/to_sql.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module Arel 3 | module Visitors 4 | module ToSqlExtensions 5 | def visit_Arel_Nodes_InsertStatement(o, collector) 6 | collector = super 7 | if o.on_conflict 8 | maybe_visit o.on_conflict, collector 9 | else 10 | collector 11 | end 12 | end 13 | 14 | def visit_Arel_Nodes_OnConflict o, collector 15 | collector << "ON CONFLICT " 16 | collector << " (#{quote_column_name o.target.name}) ".gsub(',', '","') 17 | collector << " WHERE #{o.where}" if o.where 18 | maybe_visit o.action, collector 19 | end 20 | 21 | def visit_Arel_Nodes_DoNothing _o, collector 22 | collector << "DO NOTHING" 23 | end 24 | 25 | def visit_Arel_Nodes_DoUpdateSet o, collector 26 | wheres = o.wheres 27 | 28 | collector << "DO UPDATE " 29 | unless o.values.empty? 30 | collector << " SET " 31 | collector = inject_join o.values, collector, ", " 32 | end 33 | 34 | unless wheres.empty? 35 | collector << " WHERE " 36 | collector = inject_join wheres, collector, " AND " 37 | end 38 | 39 | collector 40 | end 41 | 42 | def visit_Arel_Nodes_ExcludedColumn o, collector 43 | collector << "EXCLUDED.#{quote_column_name o.column}" 44 | collector 45 | end 46 | 47 | def table_exists?(name) 48 | schema_cache.data_source_exists?(name) 49 | end 50 | end 51 | 52 | ::Arel::Visitors::ToSql.prepend(ToSqlExtensions) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/active_record_upsert/compatibility/rails60.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module TransactionsExtensions 4 | def upsert(*args, **kwargs) 5 | with_transaction_returning_status { super } 6 | end 7 | end 8 | 9 | module ConnectAdapterExtension 10 | def upsert(*args, **kwargs) 11 | ::ActiveRecord::Base.clear_query_caches_for_current_thread 12 | super 13 | end 14 | 15 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(self) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/active_record_upsert/compatibility/rails70.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | module ActiveRecord 3 | module PersistenceExtensions 4 | module ClassMethods 5 | def __substitute_values(values, table) 6 | values.map do |name, value| 7 | attr = table[name] 8 | unless ::Arel.arel_node?(value) || value.is_a?(::ActiveModel::Attribute) 9 | type = type_for_attribute(attr.name) 10 | value = predicate_builder.build_bind_attribute(attr.name, type.cast(value)) 11 | end 12 | [attr, value] 13 | end 14 | end 15 | 16 | def _upsert_record(existing_attributes, upsert_attributes_names, wheres, opts) # :nodoc: 17 | upsert_keys = opts[:upsert_keys] || self.upsert_keys || [primary_key] 18 | upsert_options = opts[:upsert_options] || self.upsert_options 19 | upsert_attributes_names = upsert_attributes_names - [*upsert_keys, 'created_at'] 20 | 21 | existing_attributes = existing_attributes 22 | .transform_keys { |name| _prepare_column(name) } 23 | .reject { |key, _| key.nil? } 24 | 25 | upsert_attributes_names = upsert_attributes_names 26 | .map { |name| _prepare_column(name) } 27 | .compact 28 | 29 | values_for_upsert = existing_attributes.select { |(name, _value)| upsert_attributes_names.include?(name) } 30 | 31 | insert_manager = arel_table.compile_upsert( 32 | upsert_keys, 33 | upsert_options, 34 | __substitute_values(values_for_upsert, arel_table), 35 | __substitute_values(existing_attributes, arel_table), 36 | wheres 37 | ) 38 | 39 | connection.upsert(insert_manager, "#{self} Upsert") 40 | end 41 | end 42 | end 43 | 44 | module TransactionsExtensions 45 | def upsert(*args, **kwargs) 46 | with_transaction_returning_status { super } 47 | end 48 | end 49 | 50 | module ConnectAdapterExtension 51 | def upsert(*args, **kwargs) 52 | ::ActiveRecord::Base.clear_query_caches_for_current_thread 53 | super 54 | end 55 | 56 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(self) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/active_record_upsert/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordUpsert 2 | VERSION = "0.11.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/active_record/base_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | RSpec.describe 'Base' do 3 | describe '#upsert' do 4 | let(:record) { MyRecord.new(id: 'some_id') } 5 | it 'calls save/create/commit callbacks' do 6 | expect(record).to receive(:before_s) 7 | expect(record).to receive(:after_s) 8 | expect(record).to receive(:after_c) 9 | expect(record).to receive(:before_c) 10 | expect(record).to receive(:after_com) 11 | record.upsert 12 | end 13 | 14 | it 'updates the attribute before calling after callbacks' do 15 | MyRecord.create(id: 'some_id', name: 'Some name') 16 | 17 | allow(record).to receive(:after_s) { expect(record.name).to eq('Some name') } 18 | allow(record).to receive(:after_c) { expect(record.name).to eq('Some name') } 19 | allow(record).to receive(:after_com) { expect(record.name).to eq('Some name') } 20 | 21 | record.upsert 22 | end 23 | 24 | context 'when the record does not exist' do 25 | it 'sets timestamps' do 26 | record.upsert 27 | expect(record.created_at).not_to be_nil 28 | expect(record.updated_at).not_to be_nil 29 | end 30 | 31 | it 'creates record with all the attributes it is initialized with' do 32 | record = MyRecord.new(id: 25, name: 'Some name', wisdom: 3) 33 | record.upsert(attributes: [:id, :name]) 34 | expect(record.reload.wisdom).to eq(3) 35 | end 36 | 37 | it 'clears any changes state on the instance' do 38 | record.upsert 39 | expect(record.changes).to be_empty 40 | expect(record.changed?).to be false 41 | end 42 | end 43 | 44 | context 'when the record already exists' do 45 | let(:key) { 1 } 46 | before { MyRecord.create(id: key, name: 'somename') } 47 | 48 | it 'sets the updated_at timestamp' do 49 | first_updated_at = MyRecord.find(key).updated_at 50 | upserted = MyRecord.new(id: key) 51 | upserted.upsert 52 | expect(upserted.reload.updated_at).to be > first_updated_at 53 | end 54 | 55 | it 'does not reset the created_at timestamp' do 56 | first_created_at = MyRecord.find(key).created_at 57 | upserted = MyRecord.new(id: key) 58 | upserted.upsert 59 | expect(upserted.created_at).to eq(first_created_at) 60 | end 61 | 62 | it 'loads the data from the db' do 63 | upserted = MyRecord.new(id: key) 64 | upserted.upsert 65 | expect(upserted.name).to eq('somename') 66 | end 67 | 68 | it 'clears any changes' do 69 | upserted = MyRecord.new(id: key, name: 'other') 70 | upserted.upsert 71 | expect(upserted.changes).to be_empty 72 | expect(upserted.changed?).to be false 73 | end 74 | 75 | context 'when specifying attributes' do 76 | it 'sets all the specified attributes' do 77 | upserted = MyRecord.new(id: key) 78 | upserted.upsert(attributes: [:id, :name]) 79 | expect(upserted.name).to eq(nil) 80 | end 81 | end 82 | 83 | context 'with opts' do 84 | let(:attrs) { {make: 'Ford', name: 'Focus', year: 2017 } } 85 | let!(:vehicle) { Vehicle.create(attrs) } 86 | 87 | context 'with upsert_keys' do 88 | it 'allows upsert_keys to be set when #upsert is called' do 89 | upserted = Vehicle.new({ make: 'Volkswagen', name: 'Golf', year: attrs[:year] }) 90 | expect { upserted.upsert(opts: { upsert_keys: [:year] }) }.not_to change { Vehicle.count }.from(1) 91 | expect(upserted.id).to eq(vehicle.id) 92 | end 93 | end 94 | 95 | context 'with upsert_options' do 96 | it 'allows upsert_options to be set when #upsert is called' do 97 | upserted = Vehicle.new({ make: attrs[:make], name: 'GT', wheels_count: 4 }) 98 | expect { upserted.upsert(opts: { upsert_keys: [:make], upsert_options: { where: 'year IS NULL' } }) }.to change { Vehicle.count }.from(1).to(2) 99 | expect(upserted.id).not_to eq(vehicle.id) 100 | end 101 | end 102 | end 103 | end 104 | 105 | context 'when the record is not new' do 106 | it 'raises an error' do 107 | record = MyRecord.create(name: 'somename') 108 | record.save 109 | expect { record.upsert }.to raise_error(RecordSavedError) 110 | end 111 | end 112 | 113 | context 'with validation' do 114 | it 'does not upsert if the object is invalid' do 115 | record = Vehicle.new(wheels_count: 4) 116 | expect { record.upsert }.to_not change{ Vehicle.count } 117 | expect(record.upsert).to eq(false) 118 | end 119 | 120 | it 'saves the object if validate: false is passed' do 121 | record = Vehicle.new(wheels_count: 4) 122 | expect { record.upsert(validate: false) }.to change{ Vehicle.count }.by(1) 123 | end 124 | end 125 | 126 | context "when supporting a partial index" do 127 | before { Account.create(name: 'somename', active: true) } 128 | 129 | context 'when the record matches the partial index' do 130 | it 'raises an error' do 131 | expect{ Account.upsert!({ name: 'somename', active: true }) }.not_to change{ Account.count }.from(1) 132 | end 133 | end 134 | 135 | context 'when the record does meet the where clause' do 136 | it 'raises an error' do 137 | expect{ Account.upsert!({ name: 'somename', active: false }) }.to change{ Account.count }.from(1).to(2) 138 | end 139 | end 140 | end 141 | end 142 | 143 | describe '#upsert!' do 144 | it 'raises ActiveRecord::RecordInvalid if the object is invalid' do 145 | record = Vehicle.new(wheels_count: 4) 146 | expect { record.upsert! }.to raise_error(ActiveRecord::RecordInvalid) 147 | end 148 | end 149 | 150 | describe '#upsert_operation' do 151 | let(:attributes) { { id: 1 } } 152 | 153 | context 'when no upsert has been tried' do 154 | it 'returns nil' do 155 | record = MyRecord.new(attributes) 156 | expect(record.upsert_operation).to_not be 157 | end 158 | end 159 | 160 | context 'when the record does not exist' do 161 | it 'returns create' do 162 | record = MyRecord.upsert(attributes) 163 | expect(record.upsert_operation).to eq(:create) 164 | end 165 | end 166 | 167 | context 'when the record already exists' do 168 | before { MyRecord.create(attributes) } 169 | 170 | it 'returns update' do 171 | record = MyRecord.upsert(attributes) 172 | expect(record.upsert_operation).to eq(:update) 173 | end 174 | end 175 | end 176 | 177 | describe '.upsert' do 178 | context 'when the record already exists' do 179 | let(:key) { 1 } 180 | let(:attributes) { {id: key, name: 'othername', wisdom: nil} } 181 | let(:existing_updated_at) { Time.new(2017, 1, 1) } 182 | let!(:existing) { MyRecord.create(id: key, name: 'somename', wisdom: 2, updated_at: existing_updated_at) } 183 | 184 | it 'updates all passed attributes' do 185 | record = MyRecord.upsert(attributes) 186 | expect(record.name).to eq(attributes[:name]) 187 | expect(record.wisdom).to eq(attributes[:wisdom]) 188 | end 189 | 190 | it 'sets the updated_at timestamp' do 191 | record = MyRecord.upsert(attributes) 192 | expect(record.reload.updated_at).to be > existing_updated_at 193 | end 194 | 195 | context 'with conditions' do 196 | it 'does not update the record if the condition does not match' do 197 | expect { 198 | MyRecord.upsert(attributes, arel_condition: MyRecord.arel_table[:wisdom].gt(3)) 199 | }.to_not change { existing.reload.name } 200 | end 201 | 202 | it 'updates the record if the condition matches' do 203 | expect { 204 | MyRecord.upsert(attributes, arel_condition: MyRecord.arel_table[:wisdom].lt(3)) 205 | }.to change { existing.reload.wisdom }.to(nil) 206 | expect(existing.reload.updated_at).to be > existing_updated_at 207 | end 208 | end 209 | 210 | context 'with opts' do 211 | let(:attrs) { {make: 'Ford', name: 'Focus', year: 2017 } } 212 | let!(:vehicle) { Vehicle.create(attrs) } 213 | 214 | context 'with upsert_keys' do 215 | it 'allows upsert_keys to be set when .upsert is called' do 216 | expect { Vehicle.upsert({ make: 'Volkswagen', name: 'Golf', year: attrs[:year] }, opts: { upsert_keys: [:year] }) }.not_to change { Vehicle.count }.from(1) 217 | expect(vehicle.reload.make).to eq('Volkswagen') 218 | end 219 | end 220 | 221 | context 'with upsert_options' do 222 | it 'allows upsert_options to be set when #upsert is called' do 223 | expect { Vehicle.upsert({ make: attrs[:make], name: 'GT', wheels_count: 4 }, opts: { upsert_keys: [:make], upsert_options: { where: 'year IS NULL' } }) }.to change { Vehicle.count }.from(1).to(2) 224 | expect(vehicle.reload.wheels_count).to be_nil 225 | end 226 | end 227 | end 228 | end 229 | 230 | context 'with assocations' do 231 | let!(:existing) { Vehicle.create!(make: 'Make', name: 'Name') } 232 | let(:account) { Account.create! } 233 | 234 | it 'updates the foreign keys' do 235 | expect { 236 | Vehicle.upsert!({ make: existing.make, name: existing.name, account: account }) 237 | }.to change { existing.reload.account_id }.from(nil).to(account.id) 238 | end 239 | end 240 | 241 | context 'when another index violation is made' do 242 | it 'raises an error' do 243 | record = MyRecord.create(name: 'somename', wisdom: 1) 244 | MyRecord.create(name: 'other', wisdom: 2) 245 | expect { MyRecord.upsert({ id: record.id, wisdom: 2 }) }.to raise_error(ActiveRecord::RecordNotUnique) 246 | end 247 | end 248 | 249 | context 'when updating attributes from the database' do 250 | it 'does not call setter methods' do 251 | record = MyRecord.new(name: 'somename', wisdom: 1) 252 | expect(record).to_not receive(:name=).with('somename') 253 | record.upsert 254 | end 255 | end 256 | end 257 | 258 | describe '.upsert!' do 259 | it 'raises ActiveRecord::RecordInvalid if the object is invalid' do 260 | expect { Vehicle.upsert!({ wheels_count: 4 }) }.to raise_error(ActiveRecord::RecordInvalid) 261 | end 262 | end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /spec/active_record/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | RSpec.describe 'Base' do 3 | describe '.upsert_keys' do 4 | context 'when using inheritance' do 5 | context 'and not setting subclass upsert keys' do 6 | it 'returns the superclass upsert keys' do 7 | expect(Bicycle.upsert_keys).to eq(Vehicle.upsert_keys) 8 | end 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/active_record/key_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | RSpec.describe 'Alernate conflict keys' do 3 | describe '#upsert' do 4 | let(:record) { Vehicle.new(make: 'Ford', name: 'Focus') } 5 | it 'calls save/create/commit callbacks' do 6 | expect(record).to receive(:before_s) 7 | expect(record).to receive(:after_s) 8 | expect(record).to receive(:after_c) 9 | expect(record).to receive(:before_c) 10 | expect(record).to receive(:after_com) 11 | record.upsert 12 | end 13 | 14 | context 'when the record does not exist' do 15 | it 'sets timestamps' do 16 | record.upsert 17 | expect(record.created_at).not_to be_nil 18 | expect(record.updated_at).not_to be_nil 19 | end 20 | 21 | it 'sets id' do 22 | record.wheels_count = 1 23 | expect(record.id).to be_nil 24 | record.upsert(attributes: [:wheels_count]) 25 | expect(record.id).not_to be_nil 26 | end 27 | end 28 | 29 | context 'when the record already exists' do 30 | let(:attrs) { {make: 'Ford', name: 'Focus'} } 31 | before { Vehicle.create(attrs) } 32 | it 'sets the updated_at timestamp' do 33 | first_updated_at = Vehicle.find_by(attrs).updated_at 34 | upserted = Vehicle.new(attrs) 35 | upserted.upsert 36 | expect(upserted.updated_at).to be > first_updated_at 37 | end 38 | 39 | it 'does not reset the created_at timestamp' do 40 | first_created_at = Vehicle.find_by(attrs).created_at 41 | upserted = Vehicle.new(attrs) 42 | upserted.upsert 43 | expect(upserted.created_at).to eq(first_created_at) 44 | end 45 | 46 | it 'loads the data from the db' do 47 | upserted = Vehicle.new(**attrs, wheels_count: 1) 48 | upserted.upsert 49 | expect(upserted.wheels_count).to eq(1) 50 | end 51 | end 52 | 53 | context 'different ways of setting keys' do 54 | let(:attrs) { {make: 'Ford', name: 'Focus', long_field: SecureRandom.uuid} } 55 | let!(:vehicule) { Vehicle.create(attrs) } 56 | 57 | it 'works with multiple symbol args' do 58 | Vehicle.upsert_keys :make, :name 59 | upserted = Vehicle.new(**attrs, wheels_count: 1) 60 | upserted.upsert 61 | expect(upserted.wheels_count).to eq(1) 62 | end 63 | it 'works with multiple string args' do 64 | Vehicle.upsert_keys 'make', 'name' 65 | upserted = Vehicle.new(**attrs, wheels_count: 1) 66 | upserted.upsert 67 | expect(upserted.wheels_count).to eq(1) 68 | end 69 | it 'works with array of symbols' do 70 | Vehicle.upsert_keys [:make, :name] 71 | upserted = Vehicle.new(**attrs, wheels_count: 1) 72 | upserted.upsert 73 | expect(upserted.wheels_count).to eq(1) 74 | end 75 | it 'works with array of strings' do 76 | Vehicle.upsert_keys ['make', 'name'] 77 | upserted = Vehicle.new(**attrs, wheels_count: 1) 78 | upserted.upsert 79 | expect(upserted.wheels_count).to eq(1) 80 | end 81 | it 'works with a single symbol' do 82 | Vehicle.upsert_keys :id 83 | upserted = Vehicle.new(id: vehicule.id, name: 'ford', wheels_count: 1) 84 | result = upserted.upsert 85 | 86 | expect(result).to be_truthy 87 | expect(upserted.wheels_count).to eq(1) 88 | expect(upserted.id).to eq(vehicule.id) 89 | end 90 | it 'works with a single string' do 91 | Vehicle.upsert_keys 'id' 92 | upserted = Vehicle.new(id: vehicule.id, name: 'ford', wheels_count: 1) 93 | result = upserted.upsert 94 | 95 | expect(result).to be_truthy 96 | expect(upserted.wheels_count).to eq(1) 97 | expect(upserted.id).to eq(vehicule.id) 98 | end 99 | it 'works with a literal' do 100 | Vehicle.upsert_keys literal: 'md5(long_field)' 101 | upserted = Vehicle.new(id: vehicule.id, name: 'ford', long_field: attrs[:long_field]) 102 | result = upserted.upsert 103 | 104 | expect(result).to be_truthy 105 | expect(upserted.long_field).to eq(attrs[:long_field]) 106 | expect(upserted.id).to eq(vehicule.id) 107 | end 108 | end 109 | 110 | context 'when the record is not new' do 111 | let(:attrs) { {make: 'Ford', name: 'Focus'} } 112 | it 'raises an error' do 113 | record = Vehicle.create(attrs) 114 | record.save 115 | expect { record.upsert }.to raise_error(RecordSavedError) 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/active_record/notifications_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | RSpec.describe 'Base' do 3 | 4 | describe '#upsert' do 5 | 6 | let(:events) { [] } 7 | 8 | before(:each) do 9 | @subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*args| 10 | events << args 11 | end 12 | end 13 | 14 | after(:each) do 15 | ActiveSupport::Notifications.unsubscribe(@subscriber) 16 | end 17 | 18 | it 'emits an ActiveSupport notification with an appropriate name' do 19 | MyRecord.upsert({ wisdom: 2 }) 20 | 21 | payload = events[-1][-1] 22 | expect(payload[:name]).to eq('MyRecord Upsert') 23 | end 24 | end 25 | 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/active_record_upsert_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe ActiveRecordUpsert do 4 | it 'has a version number' do 5 | expect(ActiveRecordUpsert::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/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', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /spec/dummy/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. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/cable.coffee: -------------------------------------------------------------------------------- 1 | # Action Cable provides the framework to deal with WebSockets in Rails. 2 | # You can generate new channels where WebSocket features live using the rails generate channel command. 3 | # 4 | # Turn on the cable connection by removing the comments after the require statements (and ensure it's also on in config/routes.rb). 5 | # 6 | #= require action_cable 7 | #= require_self 8 | #= require_tree ./channels 9 | # 10 | # @App ||= {} 11 | # App.cable = ActionCable.createConsumer() 12 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 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 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading. 2 | module ApplicationCable 3 | class Channel < ActionCable::Channel::Base 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading. 2 | module ApplicationCable 3 | class Connection < ActionCable::Connection::Base 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/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 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ApplicationRecord 2 | upsert_keys :name, where: 'active is TRUE' 3 | 4 | has_many :vehicles 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bicycle.rb: -------------------------------------------------------------------------------- 1 | class Bicycle < Vehicle 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/my_record.rb: -------------------------------------------------------------------------------- 1 | class MyRecord < ActiveRecord::Base 2 | before_save :before_s 3 | after_save :after_s 4 | before_create :before_c 5 | after_create :after_c 6 | after_commit :after_com 7 | 8 | def before_s 9 | end 10 | 11 | def after_s 12 | end 13 | 14 | def before_c 15 | end 16 | 17 | def after_c 18 | end 19 | 20 | def after_com 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dummy/app/models/vehicle.rb: -------------------------------------------------------------------------------- 1 | class Vehicle < ApplicationRecord 2 | upsert_keys [:make, :name] 3 | 4 | before_save :before_s 5 | after_save :after_s 6 | before_create :before_c 7 | after_create :after_c 8 | after_commit :after_com 9 | 10 | validates :name, presence: true 11 | 12 | attribute :license, :string, default: 'Unknown' 13 | belongs_to :account 14 | 15 | def before_s 16 | end 17 | 18 | def after_s 19 | end 20 | 21 | def before_c 22 | end 23 | 24 | def after_c 25 | end 26 | 27 | def after_com 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | <%= action_cable_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 9 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # puts "\n== Copying sample files ==" 22 | # unless File.exist?('config/database.yml') 23 | # cp 'config/database.yml.sample', 'config/database.yml' 24 | # end 25 | 26 | puts "\n== Preparing database ==" 27 | system! 'bin/rails db:setup' 28 | 29 | puts "\n== Removing old logs and tempfiles ==" 30 | system! 'bin/rails log:clear tmp:clear' 31 | 32 | puts "\n== Restarting application server ==" 33 | system! 'bin/rails restart' 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | 5 | # Action Cable requires that all classes are loaded in advance 6 | Rails.application.eager_load! 7 | 8 | run Rails.application 9 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | Bundler.require(*Rails.groups) 6 | 7 | module Dummy 8 | class Application < Rails::Application 9 | config.active_record.schema_format = :sql 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | require "logger" # Fix concurrent-ruby removing "logger" dependency which Rails itself does not have 6 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 7 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | # Action Cable uses Redis by default to administer connections, channels, and sending/receiving messages over the WebSocket. 2 | production: 3 | adapter: redis 4 | url: redis://localhost:6379/1 5 | 6 | development: 7 | adapter: async 8 | 9 | test: 10 | adapter: async 11 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/config/database.yml -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join('tmp/caching-dev.txt').exist? 17 | config.action_controller.perform_caching = true 18 | config.cache_store = :memory_store 19 | config.public_file_server.headers = { 20 | 'Cache-Control' => 'public, max-age=172800' 21 | } 22 | else 23 | config.action_controller.perform_caching = false 24 | config.cache_store = :null_store 25 | end 26 | 27 | # Don't care if the mailer can't send. 28 | config.action_mailer.raise_delivery_errors = false 29 | 30 | # Print deprecation notices to the Rails logger. 31 | config.active_support.deprecation = :log 32 | 33 | # Raise an error on page load if there are pending migrations. 34 | config.active_record.migration_error = :page_load 35 | 36 | # Debug mode disables concatenation and preprocessing of assets. 37 | # This option may cause significant delays in view rendering with a large 38 | # number of complex assets. 39 | config.assets.debug = true 40 | 41 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 42 | # yet still be able to expire them through the digest params. 43 | config.assets.digest = true 44 | 45 | # Adds additional error checking when serving assets at runtime. 46 | # Checks for improperly declared sprockets dependencies. 47 | # Raises helpful error messages. 48 | config.assets.raise_runtime_errors = true 49 | 50 | # Raises error for missing translations 51 | # config.action_view.raise_on_missing_translations = true 52 | 53 | # Use an evented file watcher to asynchronously detect changes in source code, 54 | # routes, locales, etc. This feature depends on the listen gem. 55 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 56 | end 57 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Disable serving static files from the `/public` folder by default since 18 | # Apache or NGINX already handles this. 19 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 20 | 21 | # Compress JavaScripts and CSS. 22 | config.assets.js_compressor = :uglifier 23 | # config.assets.css_compressor = :sass 24 | 25 | # Do not fallback to assets pipeline if a precompiled asset is missed. 26 | config.assets.compile = false 27 | 28 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 29 | # yet still be able to expire them through the digest params. 30 | config.assets.digest = true 31 | 32 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 33 | 34 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 35 | # config.action_controller.asset_host = 'http://assets.example.com' 36 | 37 | # Specifies the header that your server uses for sending files. 38 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 39 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 40 | 41 | # Action Cable endpoint configuration 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 | # Use the lowest log level to ensure availability of diagnostic information 49 | # when problems arise. 50 | config.log_level = :debug 51 | 52 | # Prepend all log lines with the following tags. 53 | config.log_tags = [:request_id] 54 | 55 | # Use a different logger for distributed setups. 56 | # require 'syslog/logger' 57 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 58 | 59 | # Use a different cache store in production. 60 | # config.cache_store = :mem_cache_store 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment) 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}" 65 | 66 | # Ignore bad email addresses and do not raise email delivery errors. 67 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 68 | # config.action_mailer.raise_delivery_errors = false 69 | 70 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 71 | # the I18n.default_locale when a translation cannot be found). 72 | config.i18n.fallbacks = true 73 | 74 | # Send deprecation notices to registered listeners. 75 | config.active_support.deprecation = :notify 76 | 77 | # Use default logging formatter so that PID and timestamp are not suppressed. 78 | config.log_formatter = ::Logger::Formatter.new 79 | 80 | # Do not dump schema after migrations. 81 | config.active_record.dump_schema_after_migration = false 82 | end 83 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => 'public, max-age=3600' 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Tell Action Mailer not to deliver emails to the real world. 32 | # The :test delivery method accumulates sent emails in the 33 | # ActionMailer::Base.deliveries array. 34 | config.action_mailer.delivery_method = :test 35 | 36 | # Randomize the order test cases are executed. 37 | config.active_support.test_order = :random 38 | 39 | # Print deprecation notices to the stderr. 40 | config.active_support.deprecation = :stderr 41 | 42 | # Raises error for missing translations 43 | # config.action_view.raise_on_missing_translations = true 44 | end 45 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/active_record_belongs_to_required_by_default.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Require `belongs_to` associations by default. This is a new Rails 5.0 4 | # default, so it is introduced as a configuration option to ensure that apps 5 | # made on earlier versions of Rails are not affected when upgrading. 6 | Rails.application.config.active_record.belongs_to_required_by_default = true 7 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ApplicationController.renderer.defaults.merge!( 4 | # http_host: 'example.org', 5 | # https: false 6 | # ) 7 | -------------------------------------------------------------------------------- /spec/dummy/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| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/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 += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/per_form_csrf_tokens.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Enable per-form CSRF tokens. 4 | Rails.application.config.action_controller.per_form_csrf_tokens = true 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/request_forgery_protection.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Enable origin-checking CSRF mitigation. 4 | Rails.application.config.action_controller.forgery_protection_origin_check = true 5 | -------------------------------------------------------------------------------- /spec/dummy/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: '_dummy_session' 4 | -------------------------------------------------------------------------------- /spec/dummy/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 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/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 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/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 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum, this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }.to_i 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000. 11 | # 12 | port ENV.fetch('PORT') { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch('RAILS_ENV') { 'development' } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # The code in the `on_worker_boot` will be called if you are using 36 | # clustered mode by specifying a number of `workers`. After each worker 37 | # process is booted this block will be run, if you are using `preload_app!` 38 | # option you will want to use this block to reconnect to any threads 39 | # or connections that may have been created at application boot, Ruby 40 | # cannot share connections between processes. 41 | # 42 | # on_worker_boot do 43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 44 | # end 45 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | 4 | # Serve websocket cable requests in-process 5 | # mount ActionCable.server => '/cable' 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/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 | development: 14 | secret_key_base: a21e079224591a456d6b5acac866f9012ded3341ae6d37e9fed69a66f21d6a2f47487b9728e4ca73f25b4cebe530c938a3eb4208810ef24209c5cb40e7279ccf 15 | 16 | test: 17 | secret_key_base: 0279b07bdb1dcd2abe567125bef40fe98236ecfd004934fe1f7d20b77687eaf0d0227aea2d366f7b50582d8fd49a893a3003d246636ef08d2dd86916c6dfd3db 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /spec/dummy/db/development.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/db/development.sqlite3 -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20160419103547_create_vehicles.rb: -------------------------------------------------------------------------------- 1 | class CreateVehicles < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :vehicles do |t| 4 | t.integer :wheels_count 5 | t.string :name 6 | t.string :make 7 | t.string :long_field 8 | 9 | t.timestamps 10 | 11 | t.index [:make, :name], unique: true 12 | t.index 'md5(long_field)', unique: true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20160419124138_create_my_records.rb: -------------------------------------------------------------------------------- 1 | class CreateMyRecords < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :my_records do |t| 4 | t.string :name 5 | t.integer :wisdom 6 | t.timestamps 7 | 8 | t.index :wisdom, unique: true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20160419124140_create_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateAccounts < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :accounts do |t| 4 | t.string :name 5 | t.boolean :active 6 | t.timestamps 7 | end 8 | 9 | add_index :accounts, :name, unique: true, where: "active IS TRUE" 10 | 11 | add_reference :vehicles, :account 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20190428142610_add_year_to_vehicles.rb: -------------------------------------------------------------------------------- 1 | class AddYearToVehicles < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :vehicles, :year, :integer 4 | add_index :vehicles, :year, unique: true 5 | add_index :vehicles, [:make], unique: true, where: "year IS NULL", name: 'partial_index_vehicles_on_make_without_year' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20160419124138) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | 19 | create_table "my_records", force: :cascade do |t| 20 | t.string "name" 21 | t.integer "wisdom" 22 | t.datetime "created_at", null: false 23 | t.datetime "updated_at", null: false 24 | end 25 | 26 | add_index "my_records", ["wisdom"], name: "index_my_records_on_wisdom", unique: true, using: :btree 27 | 28 | create_table "vehicles", force: :cascade do |t| 29 | t.integer "wheels_count" 30 | t.string "name" 31 | t.datetime "created_at", null: false 32 | t.datetime "updated_at", null: false 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /spec/dummy/db/structure.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SET check_function_bodies = false; 7 | SET client_min_messages = warning; 8 | SET row_security = off; 9 | 10 | -- 11 | -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - 12 | -- 13 | 14 | CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; 15 | 16 | 17 | -- 18 | -- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: - 19 | -- 20 | 21 | COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; 22 | 23 | 24 | SET search_path = public, pg_catalog; 25 | 26 | SET default_tablespace = ''; 27 | 28 | SET default_with_oids = false; 29 | 30 | -- 31 | -- Name: accounts; Type: TABLE; Schema: public; Owner: - 32 | -- 33 | 34 | CREATE TABLE accounts ( 35 | id integer NOT NULL, 36 | name character varying, 37 | active boolean, 38 | created_at timestamp without time zone NOT NULL, 39 | updated_at timestamp without time zone NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: accounts_id_seq; Type: SEQUENCE; Schema: public; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE accounts_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: accounts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE accounts_id_seq OWNED BY accounts.id; 61 | 62 | 63 | -- 64 | -- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - 65 | -- 66 | 67 | CREATE TABLE ar_internal_metadata ( 68 | key character varying NOT NULL, 69 | value character varying, 70 | created_at timestamp without time zone NOT NULL, 71 | updated_at timestamp without time zone NOT NULL 72 | ); 73 | 74 | 75 | -- 76 | -- Name: my_records; Type: TABLE; Schema: public; Owner: - 77 | -- 78 | 79 | CREATE TABLE my_records ( 80 | id integer NOT NULL, 81 | name character varying, 82 | wisdom integer, 83 | created_at timestamp without time zone NOT NULL, 84 | updated_at timestamp without time zone NOT NULL 85 | ); 86 | 87 | 88 | -- 89 | -- Name: my_records_id_seq; Type: SEQUENCE; Schema: public; Owner: - 90 | -- 91 | 92 | CREATE SEQUENCE my_records_id_seq 93 | AS integer 94 | START WITH 1 95 | INCREMENT BY 1 96 | NO MINVALUE 97 | NO MAXVALUE 98 | CACHE 1; 99 | 100 | 101 | -- 102 | -- Name: my_records_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 103 | -- 104 | 105 | ALTER SEQUENCE my_records_id_seq OWNED BY my_records.id; 106 | 107 | 108 | -- 109 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - 110 | -- 111 | 112 | CREATE TABLE schema_migrations ( 113 | version character varying NOT NULL 114 | ); 115 | 116 | 117 | -- 118 | -- Name: vehicles; Type: TABLE; Schema: public; Owner: - 119 | -- 120 | 121 | CREATE TABLE vehicles ( 122 | id integer NOT NULL, 123 | wheels_count integer, 124 | name character varying, 125 | make character varying, 126 | long_field character varying, 127 | created_at timestamp without time zone NOT NULL, 128 | updated_at timestamp without time zone NOT NULL, 129 | year integer, 130 | account_id integer 131 | ); 132 | 133 | 134 | -- 135 | -- Name: vehicles_id_seq; Type: SEQUENCE; Schema: public; Owner: - 136 | -- 137 | 138 | CREATE SEQUENCE vehicles_id_seq 139 | AS integer 140 | START WITH 1 141 | INCREMENT BY 1 142 | NO MINVALUE 143 | NO MAXVALUE 144 | CACHE 1; 145 | 146 | 147 | -- 148 | -- Name: vehicles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 149 | -- 150 | 151 | ALTER SEQUENCE vehicles_id_seq OWNED BY vehicles.id; 152 | 153 | 154 | -- 155 | -- Name: accounts id; Type: DEFAULT; Schema: public; Owner: - 156 | -- 157 | 158 | ALTER TABLE ONLY accounts ALTER COLUMN id SET DEFAULT nextval('accounts_id_seq'::regclass); 159 | 160 | 161 | -- 162 | -- Name: my_records id; Type: DEFAULT; Schema: public; Owner: - 163 | -- 164 | 165 | ALTER TABLE ONLY my_records ALTER COLUMN id SET DEFAULT nextval('my_records_id_seq'::regclass); 166 | 167 | 168 | -- 169 | -- Name: vehicles id; Type: DEFAULT; Schema: public; Owner: - 170 | -- 171 | 172 | ALTER TABLE ONLY vehicles ALTER COLUMN id SET DEFAULT nextval('vehicles_id_seq'::regclass); 173 | 174 | 175 | -- 176 | -- Name: accounts accounts_pkey; Type: CONSTRAINT; Schema: public; Owner: - 177 | -- 178 | 179 | ALTER TABLE ONLY accounts 180 | ADD CONSTRAINT accounts_pkey PRIMARY KEY (id); 181 | 182 | 183 | -- 184 | -- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - 185 | -- 186 | 187 | ALTER TABLE ONLY ar_internal_metadata 188 | ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); 189 | 190 | 191 | -- 192 | -- Name: my_records my_records_pkey; Type: CONSTRAINT; Schema: public; Owner: - 193 | -- 194 | 195 | ALTER TABLE ONLY my_records 196 | ADD CONSTRAINT my_records_pkey PRIMARY KEY (id); 197 | 198 | 199 | -- 200 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - 201 | -- 202 | 203 | ALTER TABLE ONLY schema_migrations 204 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); 205 | 206 | 207 | -- 208 | -- Name: vehicles vehicles_pkey; Type: CONSTRAINT; Schema: public; Owner: - 209 | -- 210 | 211 | ALTER TABLE ONLY vehicles 212 | ADD CONSTRAINT vehicles_pkey PRIMARY KEY (id); 213 | 214 | 215 | -- 216 | -- Name: index_accounts_on_name; Type: INDEX; Schema: public; Owner: - 217 | -- 218 | 219 | CREATE UNIQUE INDEX index_accounts_on_name ON accounts USING btree (name) WHERE (active IS TRUE); 220 | 221 | 222 | -- 223 | -- Name: index_my_records_on_wisdom; Type: INDEX; Schema: public; Owner: - 224 | -- 225 | 226 | CREATE UNIQUE INDEX index_my_records_on_wisdom ON my_records USING btree (wisdom); 227 | 228 | 229 | -- 230 | -- Name: index_vehicles_on_make_and_name; Type: INDEX; Schema: public; Owner: - 231 | -- 232 | 233 | CREATE UNIQUE INDEX index_vehicles_on_make_and_name ON vehicles USING btree (make, name); 234 | 235 | 236 | -- 237 | -- Name: index_vehicles_on_md5_long_field; Type: INDEX; Schema: public; Owner: - 238 | -- 239 | 240 | CREATE UNIQUE INDEX index_vehicles_on_md5_long_field ON vehicles USING btree (md5((long_field)::text)); 241 | 242 | 243 | -- 244 | -- Name: index_vehicles_on_year; Type: INDEX; Schema: public; Owner: - 245 | -- 246 | 247 | CREATE UNIQUE INDEX index_vehicles_on_year ON vehicles USING btree (year); 248 | 249 | 250 | -- 251 | -- Name: partial_index_vehicles_on_make_without_year; Type: INDEX; Schema: public; Owner: - 252 | -- 253 | 254 | CREATE UNIQUE INDEX partial_index_vehicles_on_make_without_year ON vehicles USING btree (make) WHERE (year IS NULL); 255 | 256 | 257 | -- 258 | -- PostgreSQL database dump complete 259 | -- 260 | 261 | SET search_path TO "$user", public; 262 | 263 | INSERT INTO "schema_migrations" (version) VALUES 264 | ('20160419103547'), 265 | ('20160419124138'), 266 | ('20160419124140'), 267 | ('20190428142610'); 268 | 269 | 270 | -------------------------------------------------------------------------------- /spec/dummy/db/test.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/db/test.sqlite3 -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/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 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/dummy/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jesjos/active_record_upsert/d19fa2709749de501b351d430fd17d6832c31963/spec/dummy/tmp/.keep -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'logger' # Fix concurrent-ruby removing "logger" dependency which Rails itself does not have 3 | require 'active_record' 4 | require 'database_cleaner' 5 | require 'securerandom' 6 | 7 | # Configure Rails Environment 8 | ENV['RAILS_ENV'] = 'test' 9 | require 'active_record/connection_adapters/postgresql_adapter' 10 | ENV['DATABASE_URL'] ||= 'postgresql://localhost/upsert_test' 11 | 12 | require File.expand_path('../../spec/dummy/config/environment.rb', __FILE__) 13 | 14 | RSpec.configure do |config| 15 | config.disable_monkey_patching! 16 | if Rails.version.is_a?(String) && Rails.version.chars.first.to_i < 6 17 | config.before(:suite) do 18 | DatabaseCleaner.strategy = :transaction 19 | DatabaseCleaner.clean_with(:truncation) 20 | end 21 | 22 | config.around(:each) do |example| 23 | DatabaseCleaner.cleaning do 24 | example.run 25 | end 26 | end 27 | else 28 | config.after do 29 | ActiveRecord::Tasks::DatabaseTasks.truncate_all 30 | end 31 | end 32 | end 33 | --------------------------------------------------------------------------------