├── spec
├── dummy
│ ├── log
│ │ └── .keep
│ ├── tmp
│ │ └── .keep
│ ├── db
│ │ ├── test.sqlite3
│ │ ├── development.sqlite3
│ │ ├── migrate
│ │ │ ├── 20160419124138_create_my_records.rb
│ │ │ ├── 20190428142610_add_year_to_vehicles.rb
│ │ │ ├── 20160419124140_create_accounts.rb
│ │ │ └── 20160419103547_create_vehicles.rb
│ │ ├── schema.rb
│ │ └── structure.sql
│ ├── lib
│ │ └── assets
│ │ │ └── .keep
│ ├── config
│ │ ├── database.yml
│ │ ├── initializers
│ │ │ ├── session_store.rb
│ │ │ ├── mime_types.rb
│ │ │ ├── per_form_csrf_tokens.rb
│ │ │ ├── application_controller_renderer.rb
│ │ │ ├── request_forgery_protection.rb
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── cookies_serializer.rb
│ │ │ ├── active_record_belongs_to_required_by_default.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── wrap_parameters.rb
│ │ │ └── inflections.rb
│ │ ├── environment.rb
│ │ ├── routes.rb
│ │ ├── application.rb
│ │ ├── cable.yml
│ │ ├── boot.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── secrets.yml
│ │ ├── environments
│ │ │ ├── test.rb
│ │ │ ├── development.rb
│ │ │ └── production.rb
│ │ └── puma.rb
│ ├── public
│ │ ├── favicon.ico
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ ├── app
│ │ ├── assets
│ │ │ ├── images
│ │ │ │ └── .keep
│ │ │ ├── javascripts
│ │ │ │ ├── channels
│ │ │ │ │ └── .keep
│ │ │ │ ├── cable.coffee
│ │ │ │ └── application.js
│ │ │ ├── config
│ │ │ │ └── manifest.js
│ │ │ └── stylesheets
│ │ │ │ └── application.css
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── bicycle.rb
│ │ │ ├── application_record.rb
│ │ │ ├── account.rb
│ │ │ ├── my_record.rb
│ │ │ └── vehicle.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ └── application_controller.rb
│ │ ├── views
│ │ │ └── layouts
│ │ │ │ ├── mailer.text.erb
│ │ │ │ ├── mailer.html.erb
│ │ │ │ └── application.html.erb
│ │ ├── helpers
│ │ │ └── application_helper.rb
│ │ ├── jobs
│ │ │ └── application_job.rb
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ └── channels
│ │ │ └── application_cable
│ │ │ ├── channel.rb
│ │ │ └── connection.rb
│ ├── bin
│ │ ├── rake
│ │ ├── bundle
│ │ ├── rails
│ │ ├── update
│ │ └── setup
│ ├── Rakefile
│ └── config.ru
├── active_record_upsert_spec.rb
├── active_record
│ ├── inheritance_spec.rb
│ ├── notifications_spec.rb
│ ├── key_spec.rb
│ └── base_spec.rb
└── spec_helper.rb
├── lib
├── active_record_upsert
│ ├── arel
│ │ ├── nodes.rb
│ │ ├── nodes
│ │ │ ├── do_nothing.rb
│ │ │ ├── on_conflict_action.rb
│ │ │ ├── excluded_column.rb
│ │ │ ├── on_conflict.rb
│ │ │ ├── insert_statement.rb
│ │ │ └── do_update_set.rb
│ │ ├── insert_manager.rb
│ │ ├── table_extensions.rb
│ │ ├── on_conflict_do_update_manager.rb
│ │ └── visitors
│ │ │ └── to_sql.rb
│ ├── version.rb
│ ├── arel.rb
│ ├── active_record
│ │ ├── transactions.rb
│ │ ├── connection_adapters
│ │ │ ├── abstract
│ │ │ │ └── database_statements.rb
│ │ │ └── postgresql
│ │ │ │ └── database_statements.rb
│ │ ├── timestamp.rb
│ │ └── persistence.rb
│ ├── compatibility
│ │ ├── rails60.rb
│ │ └── rails70.rb
│ └── active_record.rb
└── active_record_upsert.rb
├── Gemfile
├── Gemfile.docker
├── .rspec
├── bin
├── run_rails.sh
├── setup
├── run_docker_test.sh
└── console
├── Gemfile.rails-5-2
├── Gemfile.rails-6-0
├── Gemfile.rails-6-1
├── Gemfile.rails-7-0
├── Gemfile.rails-7-1
├── Gemfile.rails-7-2
├── Gemfile.rails-8-0
├── Gemfile.rails-8-1
├── Gemfile.rails-main
├── Gemfile.rails-7-0-ruby-3-1
├── .gitignore
├── Gemfile.base
├── docker-compose.yml
├── Dockerfile
├── Rakefile
├── LICENSE
├── active_record_upsert.gemspec
├── .github
└── workflows
│ └── ci.yml
└── README.md
/spec/dummy/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/tmp/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/db/test.sqlite3:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/db/development.sqlite3:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/active_record_upsert/arel/nodes.rb:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/assets/javascripts/channels/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | eval_gemfile "#{__dir__}/Gemfile.rails-8-1"
2 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/Gemfile.docker:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
4 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/bicycle.rb:
--------------------------------------------------------------------------------
1 | class Bicycle < Vehicle
2 | end
3 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --require spec_helper
2 | --format documentation
3 | --color
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/active_record_upsert/version.rb:
--------------------------------------------------------------------------------
1 | module ActiveRecordUpsert
2 | VERSION = "0.12.0"
3 | end
4 |
--------------------------------------------------------------------------------
/bin/run_rails.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | pushd spec/dummy
3 | RAILS_ENV=test bundle exec rails $@
4 | popd
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/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
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-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-8-1:
--------------------------------------------------------------------------------
1 | group :development, :test do
2 | gem 'rails', '~> 8.1.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/models/account.rb:
--------------------------------------------------------------------------------
1 | class Account < ApplicationRecord
2 | upsert_keys :name, where: 'active is TRUE'
3 |
4 | has_many :vehicles
5 | end
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
3 | load Gem.bin_path('bundler', 'bundle')
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
33 | if ENV.key?('GITHUB_ACTIONS')
34 | config.color = true
35 | config.tty = true
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/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.2'
25 | spec.add_runtime_dependency 'pg', '>= 0.18', '< 2.0'
26 | end
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | workflow_dispatch:
10 |
11 | jobs:
12 | test:
13 | name: Test
14 | runs-on: ubuntu-latest
15 | env:
16 | POSTGRES_DB: upsert_test
17 | POSTGRES_PASSWORD: postgres
18 | POSTGRES_USER: postgres
19 | continue-on-error: ${{ matrix.experimental }}
20 | services:
21 | postgres:
22 | image: postgres
23 | env:
24 | POSTGRES_DB: upsert_test
25 | POSTGRES_PASSWORD: postgres
26 | POSTGRES_USER: postgres
27 | options: >-
28 | --health-cmd pg_isready
29 | --health-interval 10s
30 | --health-timeout 5s
31 | --health-retries 5
32 | ports:
33 | - 5432:5432
34 |
35 | strategy:
36 | fail-fast: false
37 | matrix:
38 | ruby-version: ["2.6", "2.7", "3.0"]
39 | gemfile: [Gemfile.rails-5-2, Gemfile.rails-6-0, Gemfile.rails-6-1]
40 | experimental: [false]
41 | include:
42 | - ruby-version: "2.7"
43 | gemfile: Gemfile.rails-7-0
44 | experimental: false
45 | - ruby-version: "3.0"
46 | gemfile: Gemfile.rails-7-0
47 | experimental: false
48 | - ruby-version: "3.1"
49 | gemfile: Gemfile.rails-7-0-ruby-3-1
50 | experimental: false
51 | - ruby-version: "3.2"
52 | gemfile: Gemfile.rails-7-1
53 | experimental: false
54 | - ruby-version: "3.3"
55 | gemfile: Gemfile.rails-7-2
56 | experimental: false
57 | - ruby-version: "3.3"
58 | gemfile: Gemfile.rails-8-0
59 | experimental: false
60 | - ruby-version: "3.4"
61 | gemfile: Gemfile.rails-8-1
62 | experimental: false
63 | - ruby-version: "3.4"
64 | gemfile: Gemfile.rails-main
65 | experimental: true
66 |
67 | exclude:
68 | - ruby-version: "3.0" # https://github.com/rails/rails/issues/40938
69 | gemfile: Gemfile.rails-5-2
70 |
71 | steps:
72 | - uses: actions/checkout@v4
73 | - name: Prepare database
74 | run: |
75 | psql postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB < spec/dummy/db/structure.sql
76 |
77 | - name: Copy over Gemfile
78 | env:
79 | BUNDLE_GEMFILE: ${{ matrix.gemfile }}
80 | run: |
81 | mv $BUNDLE_GEMFILE Gemfile
82 |
83 | - name: Set up Ruby
84 | uses: ruby/setup-ruby@v1
85 | with:
86 | ruby-version: ${{ matrix.ruby-version }}
87 | bundler-cache: true
88 | rubygems: 3.2.3
89 |
90 | - name: Run Tests
91 | run: |
92 | export DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB
93 | bundle exec rake spec
94 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://badge.fury.io/rb/active_record_upsert)
2 | [](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 |
--------------------------------------------------------------------------------