diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000..09cf087da094f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,180 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - ha/migrate-to-github-actions
+
+env:
+ CI: true
+ BUILDKITE: true
+ RAILS_ENV: test
+ RACK_ENV: test
+ MYSQL_HOST: 127.0.0.1
+ MYSQL_PORT: 3306
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ PGHOST: 127.0.0.1
+ PGUSER: postgres
+ PGPORT: 5432
+ BUNDLE_DEPLOYMENT: false
+ BEANSTALK_URL: "beanstalk://127.0.0.1:11300"
+ RABBITMQ_URL: "amqp://guest:guest@127.0.0.1:5672"
+ QC_DATABASE_URL: "postgres://postgres@127.0.0.1/active_jobs_qc_int_test"
+ # Sauce Labs username and access key. Obfuscated, purposefully not encrypted.
+ ENCODED: "U0FVQ0VfQUNDRVNTX0tFWT1hMDM1MzQzZi1lOTIyLTQwYjMtYWEzYy0wNmIzZWE2MzVjNDggU0FVQ0VfVVNFUk5BTUU9cnVieW9ucmFpbHM="
+
+jobs:
+ test:
+ name: Ruby ${{ matrix.ruby }} - ${{ matrix.framework }}
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:latest
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
+ postgres:
+ image: postgres:alpine
+ env:
+ POSTGRES_HOST_AUTH_METHOD: trust
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 3
+ selenium:
+ image: selenium/standalone-chrome:latest
+ ports:
+ - 4444:4444
+ strategy:
+ fail-fast: false
+ matrix:
+ experimental: [false]
+ ruby: [3.2, 3.1, 3.0, 2.7]
+ framework: [actioncable, actionmailbox, actionmailer, actionpack, actiontext, actionview, activemodel, activerecord_sqlite3, activerecord_mysql2, activerecord_trilogy, activerecord_postgresql, activestorage, activesupport, railties, actioncable_integration, actionview_ujs, activejob_integration, actiontext_integration, guides]
+ include:
+ - ruby: head
+ experimental: true
+ outputs:
+ data: ${{ steps.create_steps.outputs.data }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Create steps
+ id: create_steps
+ run: |
+ declare -A data=(
+ ["actioncable"]="test,default"
+ ["actionmailbox "]="test,default"
+ ["actionmailer"]="test,default"
+ ["actionpack"]="test,default"
+ ["actiontext"]="test,default"
+ ["actionview"]="test,default"
+ ["activejob"]="test,default"
+ ["activemodel"]="test,default"
+ ["activerecord_sqlite3"]="sqlite3:test,default"
+ ["activerecord_mysql2"]="db:mysql:rebuild,mysql2:test,mysqldb"
+ ["activerecord_trilogy"]="db:mysql:rebuild,trilogy:test,mysqldb"
+ ["activerecord_postgresql"]="db:postgresql:rebuild,postgresql:test,postgresdb"
+ ["activestorage"]="test,default"
+ ["activesupport"]="test,default"
+ ["railties"]="test,default"
+ ["actioncable_integration"]="test:integration,default"
+ ["actionview_ujs"]="test:ujs,default"
+ ["activejob_integration"]="test:integration,default"
+ ["actiontext_integration"]="test:system,default"
+ ["guides"]="test,default"
+ )
+
+ echo "frameworks_data=${data[${{ matrix.framework }}]}" >> "$GITHUB_ENV"
+
+ - name: Install dependencies
+ run: |
+ codename="$(. /etc/os-release; x="${VERSION_CODENAME-${VERSION#*(}}"; echo "${x%%[ )]*}")"
+ if ! which gpg || ! which curl; then \
+ apt-get update \
+ && apt-get install -y --no-install-recommends \
+ gnupg curl; \
+ fi
+ # Postgres apt sources
+ sudo curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 sudo apt-key add -
+ sudo add-apt-repository "deb http://apt.postgresql.org/pub/repos/apt/ ${codename}-pgdg main"
+ # Node apt sources
+ sudo curl -sS https://deb.nodesource.com/gpgkey/nodesource.gpg.key | APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 sudo apt-key add -
+ sudo add-apt-repository "deb http://deb.nodesource.com/node_18.x ${codename} main"
+ # Yarn apt sources
+ sudo curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 sudo apt-key add -
+ sudo add-apt-repository "deb http://dl.yarnpkg.com/debian/ stable main"
+ # Install dependencies
+ sudo apt-get install -y --no-install-recommends \
+ imagemagick \
+ postgresql-client \
+ libmysqlclient-dev \
+ default-mysql-client \
+ sqlite3 \
+ nodejs \
+ yarn \
+ ffmpeg \
+ mupdf \
+ mupdf-tools \
+ poppler-utils \
+ libvips-dev \
+ libwebp-dev \
+ beanstalkd
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: false
+ rubygems: latest
+ bundler: latest
+
+ - name: Install bundler dependencies
+ run: bundle config unset deployment && bundle install --jobs 4 --retry 3
+ timeout-minutes: 3
+
+ - name: Set up Redis
+ uses: supercharge/redis-github-action@1.2.0
+
+ - name: Set up Memcached
+ uses: niden/actions-memcached@v7
+
+ - name: Start RabbitMQ container
+ if: ${{ matrix.framework == 'activejob_integration'}}
+ run: |
+ docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 -e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest rabbitmq:alpine
+
+ - name: MYSQL - Grant all privileges to rails user
+ run: |
+ mysql -h 127.0.0.1 -P 3306 -u root -e "
+ create user 'rails'@'%';
+ grant all privileges on activerecord_unittest.* to rails@'%';
+ grant all privileges on activerecord_unittest2.* to rails@'%';
+ grant all privileges on inexistent_activerecord_unittest.* to rails@'%';
+ create database activerecord_unittest default character set utf8mb4;
+ create database activerecord_unittest2 default character set utf8mb4;
+ "
+
+ - name: npm install
+ timeout-minutes: 3
+ run: npm install
+
+ - name: ${{ matrix.framework }}
+ run: |
+ framework=$(echo "${{ matrix.framework }}" | cut -d_ -f1)
+ cd $framework
+ echo "frameworks_data: $frameworks_data"
+ instructions=$(echo $frameworks_data | sed 's/,[^,]*$//')
+ if [[ $instructions == *,* ]]; then
+ instructions=$(echo "$instructions" | sed 's/,/ \&\& bundle exec rake /g')
+ fi
+ instructions="bundle exec rake $instructions"
+ echo "instructions: $instructions"
+ eval $instructions
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 81632ee621bcb..b01d005cc1928 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -12,7 +12,7 @@ jobs:
BUNDLE_WITHOUT: db:job:cable:storage:ujs
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Ruby 3.2
uses: ruby/setup-ruby@v1
@@ -27,6 +27,7 @@ jobs:
uses: actions/setup-python@v3
with:
python-version: "3.10"
+
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -34,19 +35,8 @@ jobs:
- name: Check spelling with codespell
run: codespell --ignore-words=codespell.txt --skip="./vendor/bundle,./actionview/test/ujs/public/vendor/qunit.js,./actiontext/app/assets/javascripts/trix.js,./yarn.lock" || exit 1
- - uses: actions/checkout@v3
- with:
- repository: skipkayhil/rails-bin
- ref: 748f4673a5fe5686b5859e89f814166280e51781
- - uses: ruby/setup-ruby@v1
- with:
- ruby-version: 3.2
- bundler-cache: true
- - uses: actions/checkout@v3
- with:
- path: rails
- - run: bin/check-changelogs ./rails
- - run: bin/check-config-docs ./rails
+ - run: tools/railspect changelogs .
+ - run: tools/railspect configuration .
- uses: zzak/action-discord@v8
continue-on-error: true
diff --git a/.github/workflows/rail_inspector.yml b/.github/workflows/rail_inspector.yml
new file mode 100644
index 0000000000000..ac7dd091faff5
--- /dev/null
+++ b/.github/workflows/rail_inspector.yml
@@ -0,0 +1,24 @@
+name: Rail Inspector
+
+on:
+ pull_request:
+ paths:
+ - "tools/rail_inspector/**"
+ push:
+ paths:
+ - "tools/rail_inspector/**"
+
+permissions:
+ contents: read
+
+jobs:
+ rail_inspector:
+ name: rail_inspector tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.2
+ bundler-cache: true
+ - run: cd tools/rail_inspector && bundle exec rake
diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml
index 01a7e8687015c..a04be5fcc1c9b 100644
--- a/.github/workflows/rails-new-docker.yml
+++ b/.github/workflows/rails-new-docker.yml
@@ -11,7 +11,7 @@ jobs:
rails-new-docker:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml
index 5e893f8f4743f..4fc0fa328c5c8 100644
--- a/.github/workflows/rubocop.yml
+++ b/.github/workflows/rubocop.yml
@@ -12,7 +12,7 @@ jobs:
BUNDLE_ONLY: rubocop
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Ruby 3.2
uses: ruby/setup-ruby@v1
diff --git a/.rubocop.yml b/.rubocop.yml
index 92d113e380d7c..af44780c12c9c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -19,6 +19,7 @@ AllCops:
- 'actionmailbox/test/dummy/**/*'
- 'activestorage/test/dummy/**/*'
- 'actiontext/test/dummy/**/*'
+ - 'tools/rail_inspector/test/fixtures/*'
- '**/node_modules/**/*'
- '**/CHANGELOG.md'
- '**/2_*_release_notes.md'
@@ -242,6 +243,9 @@ Lint/RequireParentheses:
Lint/RedundantStringCoercion:
Enabled: true
+Lint/RedundantSafeNavigation:
+ Enabled: true
+
Lint/UriEscapeUnescape:
Enabled: true
@@ -350,6 +354,9 @@ Minitest/AssertRaisesWithRegexpArgument:
Minitest/AssertWithExpectedArgument:
Enabled: true
+Minitest/LiteralAsActualArgument:
+ Enabled: true
+
Minitest/SkipEnsure:
Enabled: true
diff --git a/Gemfile b/Gemfile
index 91314b6abd1db..b208b3e9b18e0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -45,6 +45,10 @@ gem "json", ">= 2.0.0"
# Workaround until Ruby ships with cgi version 0.3.6 or higher.
gem "cgi", ">= 0.3.6", require: false
+group :lint do
+ gem "syntax_tree", "6.1.1", require: false
+end
+
group :rubocop do
gem "rubocop", ">= 1.25.1", require: false
gem "rubocop-minitest", require: false
@@ -150,12 +154,12 @@ platforms :ruby, :windows do
gem "racc", ">=1.4.6", require: false
# Active Record.
- gem "sqlite3", "< 1.6.4"
+ gem "sqlite3", "~> 1.6", ">= 1.6.6"
group :db do
gem "pg", "~> 1.3"
gem "mysql2", "~> 0.5"
- gem "trilogy", github: "github/trilogy", branch: "main", glob: "contrib/ruby/*.gemspec"
+ gem "trilogy", ">= 2.5.0"
end
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 4417647c22113..8c7e6ea082224 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,11 +1,3 @@
-GIT
- remote: https://github.com/github/trilogy.git
- revision: a1f46bbb79f866c157ead6d483474670bdb17334
- branch: main
- glob: contrib/ruby/*.gemspec
- specs:
- trilogy (2.4.1)
-
GIT
remote: https://github.com/matthewd/websocket-client-simple.git
revision: e161305f1a466b9398d86df3b1731b03362da91b
@@ -28,70 +20,70 @@ GIT
PATH
remote: .
specs:
- actioncable (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actioncable (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- activejob (= 7.1.0.alpha)
- activerecord (= 7.1.0.alpha)
- activestorage (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actionmailbox (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ activejob (= 7.2.0.alpha)
+ activerecord (= 7.2.0.alpha)
+ activestorage (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
- actionmailer (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- actionview (= 7.1.0.alpha)
- activejob (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actionmailer (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ actionview (= 7.2.0.alpha)
+ activejob (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
- actionpack (7.1.0.alpha)
- actionview (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actionpack (7.2.0.alpha)
+ actionview (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- actiontext (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- activerecord (= 7.1.0.alpha)
- activestorage (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actiontext (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ activerecord (= 7.2.0.alpha)
+ activestorage (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ actionview (7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ activejob (7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
globalid (>= 0.3.6)
- activemodel (7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
- activerecord (7.1.0.alpha)
- activemodel (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ activemodel (7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
+ activerecord (7.2.0.alpha)
+ activemodel (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
timeout (>= 0.4.0)
- activestorage (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- activejob (= 7.1.0.alpha)
- activerecord (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ activestorage (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ activejob (= 7.2.0.alpha)
+ activerecord (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
marcel (~> 1.0)
- activesupport (7.1.0.alpha)
+ activesupport (7.2.0.alpha)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -101,23 +93,23 @@ PATH
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
- rails (7.1.0.alpha)
- actioncable (= 7.1.0.alpha)
- actionmailbox (= 7.1.0.alpha)
- actionmailer (= 7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- actiontext (= 7.1.0.alpha)
- actionview (= 7.1.0.alpha)
- activejob (= 7.1.0.alpha)
- activemodel (= 7.1.0.alpha)
- activerecord (= 7.1.0.alpha)
- activestorage (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ rails (7.2.0.alpha)
+ actioncable (= 7.2.0.alpha)
+ actionmailbox (= 7.2.0.alpha)
+ actionmailer (= 7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ actiontext (= 7.2.0.alpha)
+ actionview (= 7.2.0.alpha)
+ activejob (= 7.2.0.alpha)
+ activemodel (= 7.2.0.alpha)
+ activerecord (= 7.2.0.alpha)
+ activestorage (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
bundler (>= 1.15.0)
- railties (= 7.1.0.alpha)
- railties (7.1.0.alpha)
- actionpack (= 7.1.0.alpha)
- activesupport (= 7.1.0.alpha)
+ railties (= 7.2.0.alpha)
+ railties (7.2.0.alpha)
+ actionpack (= 7.2.0.alpha)
+ activesupport (= 7.2.0.alpha)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -246,8 +238,8 @@ GEM
fugit (1.8.0)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
- globalid (1.0.0)
- activesupport (>= 5.0)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
google-apis-core (0.9.4)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
@@ -284,7 +276,7 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
httpclient (2.8.3)
- i18n (1.12.0)
+ i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -314,7 +306,7 @@ GEM
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.8.0.1)
+ mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
@@ -351,17 +343,17 @@ GEM
multi_json (1.15.0)
multipart-post (2.2.3)
mutex_m (0.1.2)
- mysql2 (0.5.4)
+ mysql2 (0.5.5)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
- net-imap (0.3.6)
+ net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
timeout
- net-smtp (0.3.3)
+ net-smtp (0.4.0)
net-protocol
nio4r (2.5.9)
nokogiri (1.15.2)
@@ -377,6 +369,7 @@ GEM
ast (~> 2.4.1)
path_expander (1.1.1)
pg (1.5.3)
+ prettier_print (1.2.1)
propshaft (0.6.4)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -511,16 +504,18 @@ GEM
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
- sqlite3 (1.6.3)
+ sqlite3 (1.6.6)
mini_portile2 (~> 2.8.0)
- sqlite3 (1.6.3-x86_64-darwin)
- sqlite3 (1.6.3-x86_64-linux)
+ sqlite3 (1.6.6-x86_64-darwin)
+ sqlite3 (1.6.6-x86_64-linux)
stackprof (0.2.23)
stimulus-rails (1.2.1)
railties (>= 6.0.0)
stringio (3.0.7)
sucker_punch (3.1.0)
concurrent-ruby (~> 1.0)
+ syntax_tree (6.1.1)
+ prettier_print (>= 1.2.0)
tailwindcss-rails (2.0.21)
railties (>= 6.0.0)
tailwindcss-rails (2.0.21-x86_64-darwin)
@@ -533,6 +528,7 @@ GEM
timeout (0.4.0)
tomlrb (2.0.3)
trailblazer-option (0.1.2)
+ trilogy (2.5.0)
turbo-rails (1.3.2)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
@@ -556,12 +552,12 @@ GEM
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
websocket (1.2.9)
- websocket-driver (0.7.5)
+ websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.6.6)
+ zeitwerk (2.6.12)
PLATFORMS
ruby
@@ -634,13 +630,14 @@ DEPENDENCIES
sidekiq
sneakers
sprockets-rails (>= 2.0.0)
- sqlite3 (< 1.6.4)
+ sqlite3 (~> 1.6, >= 1.6.6)
stackprof
stimulus-rails
sucker_punch
+ syntax_tree (= 6.1.1)
tailwindcss-rails
terser (>= 1.1.4)
- trilogy!
+ trilogy (>= 2.5.0)
turbo-rails
tzinfo-data
w3c_validators (~> 1.3.6)
diff --git a/RAILS_VERSION b/RAILS_VERSION
index d35a9b83a9c8d..2031da2af56c9 100644
--- a/RAILS_VERSION
+++ b/RAILS_VERSION
@@ -1 +1 @@
-7.1.0.alpha
+7.2.0.alpha
diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md
index e269585f1419f..8de6b43a8432e 100644
--- a/actioncable/CHANGELOG.md
+++ b/actioncable/CHANGELOG.md
@@ -1,99 +1,2 @@
-* Add a `@server` instance variable referencing the `ActionCable.server`
- singleton to `ActionCable::Channel::ConnectionStub`
- This lets us delegate the `pubsub` and `config` method calls
- to the server. This fixes `NoMethodError` errors when testing
- channel logic that call `pubsub` (e.g. `stop_stream_for`).
-
- *Julian Foo*
-
-* Added `health_check_path` and `health_check_application` config to
- mount a given health check rack app on a given path.
- Useful when mounting Action Cable standalone.
-
- *Joé Dupuis*
-
-* Introduce the `capture_broadcasts` test helper.
-
- Returns all messages broadcast in a block.
-
- ```ruby
- messages = capture_broadcasts("test") do
- ActionCable.server.broadcast "test", { message: "one" }
- ActionCable.server.broadcast "test", { message: "two" }
- end
- assert_equal 2, messages.length
- assert_equal({ "message" => "one" }, messages.first)
- assert_equal({ "message" => "two" }, messages.last)
- ```
-
- *Alex Ghiculescu*
-
-* Display broadcasted messages on error message when using `assert_broadcast_on`
-
- *Stéphane Robino*
-
-* The Action Cable client now supports subprotocols to allow passing arbitrary data
- to the server.
-
- ```js
- const consumer = ActionCable.createConsumer()
-
- consumer.addSubProtocol('custom-protocol')
-
- consumer.connect()
- ```
-
- See also:
-
- * https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#subprotocols
-
- *Guillaume Hain*
-
-* Redis pub/sub adapter now automatically reconnects when Redis connection is lost.
-
- *Vladimir Dementyev*
-
-* The `connected()` callback can now take a `{reconnected}` parameter to differentiate
- connections from reconnections.
-
- ```js
- import consumer from "./consumer"
-
- consumer.subscriptions.create("ExampleChannel", {
- connected({reconnected}) {
- if (reconnected) {
- ...
- } else {
- ...
- }
- }
- })
- ```
-
- *Mansa Keïta*
-
-* The Redis adapter is now compatible with redis-rb 5.0
-
- Compatibility with redis-rb 3.x was dropped.
-
- *Jean Boussier*
-
-* The Action Cable server is now mounted with `anchor: true`.
-
- This means that routes that also start with `/cable` will no longer clash with Action Cable.
-
- *Alex Ghiculescu*
-
-* `ActionCable.server.remote_connections.where(...).disconnect` now sends `disconnect` message
- before closing the connection with the reconnection strategy specified (defaults to `true`).
-
- *Vladimir Dementyev*
-
-* Added command callbacks to `ActionCable::Connection::Base`.
-
- Now you can define `before_command`, `after_command`, and `around_command` to be invoked before, after or around any command received by a client respectively.
-
- *Vladimir Dementyev*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actioncable/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actioncable/CHANGELOG.md) for previous changes.
diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb
index 80d3265ca1c78..f1e674f4da727 100644
--- a/actioncable/lib/action_cable/gem_version.rb
+++ b/actioncable/lib/action_cable/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb
index d61b6e02f61c9..9f48f15b2aed2 100644
--- a/actioncable/lib/rails/generators/channel/channel_generator.rb
+++ b/actioncable/lib/rails/generators/channel/channel_generator.rb
@@ -24,7 +24,7 @@ def create_channel_files
if using_importmap?
pin_javascript_dependencies
- elsif using_node?
+ elsif using_js_runtime?
install_javascript_dependencies
end
end
@@ -57,22 +57,26 @@ def create_shared_channel_javascript_files
def create_channel_javascript_file
channel_js_path = File.join("app/javascript/channels", class_path, "#{file_name}_channel")
js_template "javascript/channel", channel_js_path
- gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" unless using_node?
+ gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" unless using_js_runtime?
end
def import_channels_in_javascript_entrypoint
append_to_file "app/javascript/application.js",
- using_node? ? %(import "./channels"\n) : %(import "channels"\n)
+ using_js_runtime? ? %(import "./channels"\n) : %(import "channels"\n)
end
def import_channel_in_javascript_entrypoint
append_to_file "app/javascript/channels/index.js",
- using_node? ? %(import "./#{file_name}_channel"\n) : %(import "channels/#{file_name}_channel"\n)
+ using_js_runtime? ? %(import "./#{file_name}_channel"\n) : %(import "channels/#{file_name}_channel"\n)
end
def install_javascript_dependencies
say "Installing JavaScript dependencies", :green
- run "yarn add @rails/actioncable"
+ if using_bun?
+ run "bun add @rails/actioncable"
+ elsif using_node?
+ run "yarn add @rails/actioncable"
+ end
end
def pin_javascript_dependencies
@@ -82,7 +86,6 @@ def pin_javascript_dependencies
RUBY
end
-
def file_name
@_file_name ||= super.sub(/_channel\z/i, "")
end
@@ -95,8 +98,19 @@ def using_javascript?
@using_javascript ||= options[:assets] && root.join("app/javascript").exist?
end
+ def using_js_runtime?
+ @using_js_runtime ||= root.join("package.json").exist?
+ end
+
+ def using_bun?
+ # Cannot assume bun.lockb has been generated yet so we look for
+ # a file known to be generated by the jsbundling-rails gem
+ @using_bun ||= using_js_runtime? && root.join("bun.config.js").exist?
+ end
+
def using_node?
- @using_node ||= root.join("package.json").exist?
+ # Bun is the only runtime that _isn't_ node.
+ @using_node ||= using_js_runtime? && !root.join("bun.config.js").exist?
end
def using_importmap?
diff --git a/actioncable/package.json b/actioncable/package.json
index 91f1f2b6c4196..fa95b242757b0 100644
--- a/actioncable/package.json
+++ b/actioncable/package.json
@@ -1,6 +1,6 @@
{
"name": "@rails/actioncable",
- "version": "7.1.0-alpha",
+ "version": "7.2.0-alpha",
"description": "WebSocket framework for Ruby on Rails.",
"module": "app/assets/javascripts/actioncable.esm.js",
"main": "app/assets/javascripts/actioncable.js",
diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md
index 6914abf8e0478..56638356a0f5b 100644
--- a/actionmailbox/CHANGELOG.md
+++ b/actionmailbox/CHANGELOG.md
@@ -1,19 +1,2 @@
-* Added `bounce_now_with` to send the bounce email without going through a mailer queue.
- *Ronan Limon Duparcmeur*
-
-* Support configured primary key types in generated migrations.
-
- *Nishiki Liu*
-
-* Fixed ingress controllers' ability to accept emails that contain no UTF-8 encoded parts.
-
- Fixes #46297.
-
- *Jan Honza Sterba*
-
-* Add X-Forwarded-To addresses to recipients.
-
- *Andrew Stewart*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actionmailbox/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actionmailbox/CHANGELOG.md) for previous changes.
diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb
index 846add480e343..c3151ec7f4cb9 100644
--- a/actionmailbox/lib/action_mailbox/gem_version.rb
+++ b/actionmailbox/lib/action_mailbox/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb
index 3bd779d57060d..e9f71a95036b3 100644
--- a/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb
+++ b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb
@@ -32,7 +32,7 @@ def x_forwarded_to_addresses
private
def address_list(obj)
- if obj&.respond_to?(:element)
+ if obj.respond_to?(:element)
# Mail 2.8+
obj.element
else
diff --git a/actionmailbox/test/dummy/db/schema.rb b/actionmailbox/test/dummy/db/schema.rb
index 87f8fde0b9a84..34b275b341dcc 100644
--- a/actionmailbox/test/dummy/db/schema.rb
+++ b/actionmailbox/test/dummy/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2018_02_12_164506) do
+ActiveRecord::Schema[7.2].define(version: 2018_02_12_164506) do
create_table "action_mailbox_inbound_emails", force: :cascade do |t|
t.integer "status", default: 0, null: false
t.string "message_id", null: false
diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md
index dc5f8581bff20..cff1fbb9c121a 100644
--- a/actionmailer/CHANGELOG.md
+++ b/actionmailer/CHANGELOG.md
@@ -1,121 +1,2 @@
-* Mailers are listed in alphabetical order on the mailer preview page now.
- *Martin Spickermann*
-
-* Deprecate passing params to `assert_enqueued_email_with` via the `:args`
- kwarg. `assert_enqueued_email_with` now supports a `:params` kwarg, so use
- that to pass params:
-
- ```ruby
- # BEFORE
- assert_enqueued_email_with MyMailer, :my_method, args: { my_param: "value" }
-
- # AFTER
- assert_enqueued_email_with MyMailer, :my_method, params: { my_param: "value" }
- ```
-
- To specify named mailer args as a Hash, wrap the Hash in an array:
-
- ```ruby
- assert_enqueued_email_with MyMailer, :my_method, args: [{ my_arg: "value" }]
- # OR
- assert_enqueued_email_with MyMailer, :my_method, args: [my_arg: "value"]
- ```
-
- *Jonathan Hefner*
-
-* Accept procs for args and params in `assert_enqueued_email_with`
-
- ```ruby
- assert_enqueued_email_with DeliveryJob, params: -> p { p[:token] =~ /\w+/ } do
- UserMailer.with(token: user.generate_token).email_verification.deliver_later
- end
- ```
-
- *Max Chernyak*
-
-* Added `*_deliver` callbacks to `ActionMailer::Base` that wrap mail message delivery.
-
- Example:
-
- ```ruby
- class EventsMailer < ApplicationMailer
- after_deliver do
- User.find_by(email: message.to.first).update(email_provider_id: message.message_id, emailed_at: Time.current)
- end
- end
- ```
-
- *Ben Sheldon*
-
-* Added `deliver_enqueued_emails` to `ActionMailer::TestHelper`. This method
- delivers all enqueued email jobs.
-
- Example:
-
- ```ruby
- def test_deliver_enqueued_emails
- deliver_enqueued_emails do
- ContactMailer.welcome.deliver_later
- end
- assert_emails 1
- end
- ```
-
- *Andrew Novoselac*
-
-* The `deliver_later_queue_name` used by the default mailer job can now be
- configured on a per-mailer basis. Previously this was only configurable
- for all mailers via `ActionMailer::Base`.
-
- Example:
-
- ```ruby
- class EventsMailer < ApplicationMailer
- self.deliver_later_queue_name = :throttled_mailer
- end
- ```
-
- *Jeffrey Hardy*
-
-* Email previews now include an expandable section to show all headers.
-
- Headers like `Message-ID` for threading or email service provider specific
- features like analytics tags or account metadata can now be viewed directly
- in the mailer preview.
-
- *Matt Swanson*
-
-* Default `ActionMailer::Parameterized#params` to an empty `Hash`
-
- *Sean Doyle*
-
-* Introduce the `capture_emails` test helper.
-
- Returns all emails that are sent in a block.
-
- ```ruby
- def test_emails
- emails = capture_emails do
- ContactMailer.welcome.deliver_now
- ContactMailer.welcome.deliver_later
- end
- assert_email "Hi there", emails.first.subject
- end
- ```
-
- *Alex Ghiculescu*
-
-* Added ability to download `.eml` file for the email preview.
-
- *Igor Kasyanchuk*
-
-* Support multiple preview paths for mailers.
-
- Option `config.action_mailer.preview_path` is deprecated in favor of
- `config.action_mailer.preview_paths`. Appending paths to this configuration option
- will cause those paths to be used in the search for mailer previews.
-
- *fatkodima*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actionmailer/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actionmailer/CHANGELOG.md) for previous changes.
diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb
index 891fe1110133c..ebc9f9594f064 100644
--- a/actionmailer/lib/action_mailer.rb
+++ b/actionmailer/lib/action_mailer.rb
@@ -56,6 +56,7 @@ module ActionMailer
autoload :MessageDelivery
autoload :MailDeliveryJob
autoload :QueuedDelivery
+ autoload :FormBuilder
def self.eager_load!
super
diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb
index efbf79e71cdb7..34f54db720783 100644
--- a/actionmailer/lib/action_mailer/base.rb
+++ b/actionmailer/lib/action_mailer/base.rb
@@ -480,6 +480,7 @@ class Base < AbstractController::Base
include Rescuable
include Parameterized
include Previews
+ include FormBuilder
abstract!
diff --git a/actionmailer/lib/action_mailer/form_builder.rb b/actionmailer/lib/action_mailer/form_builder.rb
new file mode 100644
index 0000000000000..c90de17103183
--- /dev/null
+++ b/actionmailer/lib/action_mailer/form_builder.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module ActionMailer
+ # = Action Mailer Form Builder
+ #
+ # Override the default form builder for all views rendered by this
+ # mailer and any of its descendants. Accepts a subclass of
+ # ActionView::Helpers::FormBuilder.
+ #
+ # While emails typically will not include forms, this can be used
+ # by views that are shared between controllers and mailers.
+ #
+ # For more information, see +ActionController::FormBuilder+.
+ module FormBuilder
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :_default_form_builder, instance_accessor: false
+ end
+
+ module ClassMethods
+ # Set the form builder to be used as the default for all forms
+ # in the views rendered by this mailer and its subclasses.
+ #
+ # ==== Parameters
+ # * builder - Default form builder, an instance of ActionView::Helpers::FormBuilder
+ def default_form_builder(builder)
+ self._default_form_builder = builder
+ end
+ end
+
+ # Default form builder for the mailer
+ def default_form_builder
+ self.class._default_form_builder
+ end
+ end
+end
diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb
index 90beb3b410dea..a891c030b87cd 100644
--- a/actionmailer/lib/action_mailer/gem_version.rb
+++ b/actionmailer/lib/action_mailer/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb
index 301f834c0562b..130d5d83e62df 100644
--- a/actionmailer/lib/action_mailer/log_subscriber.rb
+++ b/actionmailer/lib/action_mailer/log_subscriber.rb
@@ -11,8 +11,9 @@ class LogSubscriber < ActiveSupport::LogSubscriber
# An email was delivered.
def deliver(event)
info do
- perform_deliveries = event.payload[:perform_deliveries]
- if perform_deliveries
+ if exception = event.payload[:exception_object]
+ "Failed delivery of mail #{event.payload[:message_id]} error_class=#{exception.class} error_message=#{exception.message.inspect}"
+ elsif event.payload[:perform_deliveries]
"Delivered mail #{event.payload[:message_id]} (#{event.duration.round(1)}ms)"
else
"Skipped delivery of mail #{event.payload[:message_id]} as `perform_deliveries` is false"
diff --git a/actionmailer/lib/action_mailer/preview.rb b/actionmailer/lib/action_mailer/preview.rb
index bf21522a56a03..c50eb06162eab 100644
--- a/actionmailer/lib/action_mailer/preview.rb
+++ b/actionmailer/lib/action_mailer/preview.rb
@@ -144,7 +144,7 @@ def preview_name
private
def load_previews
preview_paths.each do |preview_path|
- Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
+ Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file }
end
end
diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb
index 46bf0498d0702..5d86629384bc5 100644
--- a/actionmailer/test/base_test.rb
+++ b/actionmailer/test/base_test.rb
@@ -832,8 +832,8 @@ def self.previewing_email(mail); end
end
test "proc default values can have arity of 1 where arg is a mailer instance" do
- assert_equal(ProcMailer.welcome["X-Lambda-Arity-1-arg"].to_s, "complex_value")
- assert_equal(ProcMailer.welcome["X-Lambda-Arity-1-self"].to_s, "complex_value")
+ assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-arg"].to_s)
+ assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-self"].to_s)
end
test "proc default values with fixed arity of 0 can be called" do
diff --git a/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb
new file mode 100644
index 0000000000000..180a827089c00
--- /dev/null
+++ b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb
@@ -0,0 +1,3 @@
+<%= form_with(url: "/") do |f| %>
+ <%= f.message %>
+<% end %>
diff --git a/actionmailer/test/form_builder_test.rb b/actionmailer/test/form_builder_test.rb
new file mode 100644
index 0000000000000..460acd04be822
--- /dev/null
+++ b/actionmailer/test/form_builder_test.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "abstract_unit"
+require "mailers/form_builder_mailer"
+
+class MailerFormBuilderTest < ActiveSupport::TestCase
+ def test_default_form_builder_assigned
+ email = FormBuilderMailer.welcome
+ assert_includes(email.body.encoded, "hi from SpecializedFormBuilder")
+ end
+end
diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb
index a36f44bae58fc..4272430a9bfd2 100644
--- a/actionmailer/test/log_subscriber_test.rb
+++ b/actionmailer/test/log_subscriber_test.rb
@@ -13,9 +13,12 @@ def setup
ActionMailer::LogSubscriber.attach_to :action_mailer
end
- class TestMailer < ActionMailer::Base
- def receive(mail)
- # Do nothing
+ class BogusDelivery
+ def initialize(*)
+ end
+
+ def deliver!(mail)
+ raise "failed"
end
end
@@ -50,4 +53,17 @@ def test_deliver_message_when_perform_deliveries_is_false
ensure
BaseMailer.deliveries.clear
end
+
+ def test_deliver_message_when_exception_happened
+ previous_delivery_method = BaseMailer.delivery_method
+ BaseMailer.delivery_method = BogusDelivery
+
+ assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now }
+ wait
+
+ assert_equal(1, @logger.logged(:info).size)
+ assert_equal('Failed delivery of mail 123@abc error_class=RuntimeError error_message="failed"', @logger.logged(:info).first)
+ ensure
+ BaseMailer.delivery_method = previous_delivery_method
+ end
end
diff --git a/actionmailer/test/mailers/form_builder_mailer.rb b/actionmailer/test/mailers/form_builder_mailer.rb
new file mode 100644
index 0000000000000..8e2f00a01b20f
--- /dev/null
+++ b/actionmailer/test/mailers/form_builder_mailer.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class FormBuilderMailer < ActionMailer::Base
+ class SpecializedFormBuilder < ActionView::Helpers::FormBuilder
+ def message
+ "hi from SpecializedFormBuilder"
+ end
+ end
+
+ default_form_builder SpecializedFormBuilder
+
+ def welcome
+ mail(to: "email@example.com")
+ end
+end
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md
index e23ed60400df8..4ff4f1eb32c1d 100644
--- a/actionpack/CHANGELOG.md
+++ b/actionpack/CHANGELOG.md
@@ -1,513 +1,2 @@
-* Add `ActionController::Parameters#extract_value` method to allow extracting serialized values from params
- ```ruby
- params = ActionController::Parameters.new(id: "1_123", tags: "ruby,rails")
- params.extract_value(:id) # => ["1", "123"]
- params.extract_value(:tags, delimiter: ",") # => ["ruby", "rails"]
- ```
-
- *Nikita Vasilevsky*
-
-* Parse JSON `response.parsed_body` with `ActiveSupport::HashWithIndifferentAccess`
-
- Integrate with Minitest's new `assert_pattern` by parsing the JSON contents
- of `response.parsed_body` with `ActiveSupport::HashWithIndifferentAccess`, so
- that it's pattern-matching compatible.
-
- *Sean Doyle*
-
-* Add support for Playwright as a driver for system tests.
-
- *Yuki Nishijima*
-
-* Fix `HostAuthorization` potentially displaying the value of the
- X_FORWARDED_HOST header when the HTTP_HOST header is being blocked.
-
- *Hartley McGuire*, *Daniel Schlosser*
-
-* Rename `fixture_file_upload` method to `file_fixture_upload`
-
- Declare an alias to preserve the backwards compatibility of `fixture_file_upload`
-
- *Sean Doyle*
-
-* `ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelper` saves the screenshot path in test metadata on failure.
-
- *Matija Čupić*
-
-* `config.dom_testing_default_html_version` controls the HTML parser used by
- `ActionDispatch::Assertions#html_document`.
-
- The Rails 7.1 default configuration opts into the HTML5 parser when it is supported, to better
- represent what the DOM would be in a browser user agent. Previously this test helper always used
- Nokogiri's HTML4 parser.
-
- *Mike Dalessio*
-
-* The `with_routing` helper can now be called at the class level. When called at the class level, the routes will
- be setup before each test, and reset after every test. For example:
-
- ```ruby
- class RoutingTest < ActionController::TestCase
- with_routing do |routes|
- routes.draw do
- resources :articles
- resources :authors
- end
- end
-
- def test_articles_route
- assert_routing("/articles", controller: "articles", action: "index")
- end
-
- def test_authors_route
- assert_routing("/authors", controller: "authors", action: "index")
- end
- end
- ```
-
- *Andrew Novoselac*
-
-* The `Mime::Type` now supports handling types with parameters and correctly handles quotes.
- When parsing the accept header, the parameters before the q-parameter are kept and if a matching mime-type exists it is used.
- To keep the current functionality, a fallback is created to look for the media-type without the parameters.
-
- This change allows for custom MIME-types that are more complex like `application/vnd.api+json; profile="https://jsonapi.org/profiles/ethanresnick/cursor-pagination/" ext="https://jsonapi.org/ext/atomic"` for the [JSON API](https://jsonapi.org/).
-
- *Nicolas Erni*
-
-* The url_for helpers now support a new option called `path_params`.
- This is very useful in situations where you only want to add a required param that is part of the route's URL but for other route not append an extraneous query param.
-
- Given the following router...
- ```ruby
- Rails.application.routes.draw do
- scope ":account_id" do
- get "dashboard" => "pages#dashboard", as: :dashboard
- get "search/:term" => "search#search", as: :search
- end
- delete "signout" => "sessions#destroy", as: :signout
- end
- ```
-
- And given the following `ApplicationController`
- ```ruby
- class ApplicationController < ActionController::Base
- def default_url_options
- { path_params: { account_id: "foo" } }
- end
- end
- ```
-
- The standard url_for helper and friends will now behave as follows:
-
- ```ruby
- dashboard_path # => /foo/dashboard
- dashboard_path(account_id: "bar") # => /bar/dashboard
-
- signout_path # => /signout
- signout_path(account_id: "bar") # => /signout?account_id=bar
- signout_path(account_id: "bar", path_params: { account_id: "baz" }) # => /signout?account_id=bar
- search_path("quin") # => /foo/search/quin
- ```
-
- *Jason Meller, Jeremy Beker*
-
-* Change `action_dispatch.show_exceptions` to one of `:all`, `:rescuable`, or
- `:none`. `:all` and `:none` behave the same as the previous `true` and
- `false` respectively. The new `:rescuable` option will only show exceptions
- that can be rescued (e.g. `ActiveRecord::RecordNotFound`). `:rescuable` is
- now the default for the test environment.
-
- *Jon Dufresne*
-
-* `config.action_dispatch.cookies_serializer` now accepts `:message_pack` and
- `:message_pack_allow_marshal` as serializers. These serializers require the
- [`msgpack` gem](https://rubygems.org/gems/msgpack) (>= 1.7.0).
-
- The Message Pack format can provide improved performance and smaller payload
- sizes. It also supports roundtripping some Ruby types that are not supported
- by JSON. For example:
-
- ```ruby
- cookies.encrypted[:foo] = [{ a: 1 }, { b: 2 }.with_indifferent_access, 1.to_d, Time.at(0, 123)]
-
- # BEFORE with config.action_dispatch.cookies_serializer = :json
- cookies.encrypted[:foo]
- # => [{"a"=>1}, {"b"=>2}, "1.0", "1969-12-31T18:00:00.000-06:00"]
- cookies.encrypted[:foo].map(&:class)
- # => [Hash, Hash, String, String]
-
- # AFTER with config.action_dispatch.cookies_serializer = :message_pack
- cookies.encrypted[:foo]
- # => [{:a=>1}, {"b"=>2}, 0.1e1, 1969-12-31 18:00:00.000123 -0600]
- cookies.encrypted[:foo].map(&:class)
- # => [Hash, ActiveSupport::HashWithIndifferentAccess, BigDecimal, Time]
- ```
-
- The `:message_pack` serializer can fall back to deserializing with
- `ActiveSupport::JSON` when necessary, and the `:message_pack_allow_marshal`
- serializer can fall back to deserializing with `Marshal` as well as
- `ActiveSupport::JSON`. Additionally, the `:marshal`, `:json`, and
- `:json_allow_marshal` (AKA `:hybrid`) serializers can now fall back to
- deserializing with `ActiveSupport::MessagePack` when necessary. These
- behaviors ensure old cookies can still be read so that migration is easier.
-
- *Jonathan Hefner*
-
-* Remove leading dot from domains on cookies set with `domain: :all`, to meet RFC6265 requirements
-
- *Gareth Adams*
-
-* Include source location in routes extended view.
-
- ```bash
- $ bin/rails routes --expanded
-
- ...
- --[ Route 14 ]----------
- Prefix | new_gist
- Verb | GET
- URI | /gist(.:format)
- Controller#Action | gists/gists#new
- Source Location | config/routes/gist.rb:3
- ```
-
- *Luan Vieira, John Hawthorn and Daniel Colson*
-
-* Add `without` as an alias of `except` on `ActiveController::Parameters`.
-
- *Hidde-Jan Jongsma*
-
-* Expand search field on `rails/info/routes` to also search **route name**, **http verb** and **controller#action**.
-
- *Jason Kotchoff*
-
-* Remove deprecated `poltergeist` and `webkit` (capybara-webkit) driver registration for system testing.
-
- *Rafael Mendonça França*
-
-* Remove deprecated ability to assign a single value to `config.action_dispatch.trusted_proxies`.
-
- *Rafael Mendonça França*
-
-* Deprecate `config.action_dispatch.return_only_request_media_type_on_content_type`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated behavior on `Request#content_type`.
-
- *Rafael Mendonça França*
-
-* Change `ActionController::Instrumentation` to pass `filtered_path` instead of `fullpath` in the event payload to filter sensitive query params
-
- ```ruby
- get "/posts?password=test"
- request.fullpath # => "/posts?password=test"
- request.filtered_path # => "/posts?password=[FILTERED]"
- ```
-
- *Ritikesh G*
-
-* Deprecate `AbstractController::Helpers::MissingHelperError`
-
- *Hartley McGuire*
-
-* Change `ActionDispatch::Testing::TestResponse#parsed_body` to parse HTML as
- a Nokogiri document
-
- ```ruby
- get "/posts"
- response.content_type # => "text/html; charset=utf-8"
- response.parsed_body.class # => Nokogiri::HTML5::Document
- response.parsed_body.to_html # => "\n\n..."
- ```
-
- *Sean Doyle*
-
-* Deprecate `ActionDispatch::IllegalStateError`.
-
- *Samuel Williams*
-
-* Add HTTP::Request#route_uri_pattern that returns URI pattern of matched route.
-
- *Joel Hawksley*, *Kate Higa*
-
-* Add `ActionDispatch::AssumeSSL` middleware that can be turned on via `config.assume_ssl`.
- It makes the application believe that all requests are arriving over SSL. This is useful
- when proxying through a load balancer that terminates SSL, the forwarded request will appear
- as though its HTTP instead of HTTPS to the application. This makes redirects and cookie
- security target HTTP instead of HTTPS. This middleware makes the server assume that the
- proxy already terminated SSL, and that the request really is HTTPS.
-
- *DHH*
-
-* Only use HostAuthorization middleware if `config.hosts` is not empty
-
- *Hartley McGuire*
-
-* Allow raising an error when a callback's only/unless symbols aren't existing methods.
-
- When `before_action :callback, only: :action_name` is declared on a controller that doesn't respond to `action_name`, raise an exception at request time. This is a safety measure to ensure that typos or forgetfulness don't prevent a crucial callback from being run when it should.
-
- For new applications, raising an error for undefined actions is turned on by default. If you do not want to opt-in to this behavior set `config.action_pack.raise_on_missing_callback_actions` to `false` in your application configuration. See #43487 for more details.
-
- *Jess Bees*
-
-* Allow cookie options[:domain] to accept a proc to set the cookie domain on a more flexible per-request basis
-
- *RobL*
-
-* When a host is not specified for an `ActionController::Renderer`'s env,
- the host and related options will now be derived from the routes'
- `default_url_options` and `ActionDispatch::Http::URL.secure_protocol`.
-
- This means that for an application with a configuration like:
-
- ```ruby
- Rails.application.default_url_options = { host: "rubyonrails.org" }
- Rails.application.config.force_ssl = true
- ```
-
- rendering a URL like:
-
- ```ruby
- ApplicationController.renderer.render inline: "<%= blog_url %>"
- ```
-
- will now return `"https://rubyonrails.org/blog"` instead of
- `"http://example.org/blog"`.
-
- *Jonathan Hefner*
-
-* Add details of cookie name and size to `CookieOverflow` exception.
-
- *Andy Waite*
-
-* Don't double log the `controller`, `action`, or `namespaced_controller` when using `ActiveRecord::QueryLog`
-
- Previously if you set `config.active_record.query_log_tags` to an array that included
- `:controller`, `:namespaced_controller`, or `:action`, that item would get logged twice.
- This bug has been fixed.
-
- *Alex Ghiculescu*
-
-* Add the following permissions policy directives: `hid`, `idle-detection`, `screen-wake-lock`,
- `serial`, `sync-xhr`, `web-share`.
-
- *Guillaume Cabanel*
-
-* The `speaker`, `vibrate`, and `vr` permissions policy directives are now
- deprecated.
-
- There is no browser support for these directives, and no plan for browser
- support in the future. You can just remove these directives from your
- application.
-
- *Jonathan Hefner*
-
-* Added the `:status` option to `assert_redirected_to` to specify the precise
- HTTP status of the redirect. Defaults to `:redirect` for backwards
- compatibility.
-
- *Jon Dufresne*
-
-* Rescue `JSON::ParserError` in Cookies JSON deserializer to discards marshal dumps:
-
- Without this change, if `action_dispatch.cookies_serializer` is set to `:json` and
- the app tries to read a `:marshal` serialized cookie, it would error out which wouldn't
- clear the cookie and force app users to manually clear it in their browser.
-
- (See #45127 for original bug discussion)
-
- *Nathan Bardoux*
-
-* Add `HTTP_REFERER` when following redirects on integration tests
-
- This makes `follow_redirect!` a closer simulation of what happens in a real browser
-
- *Felipe Sateler*
-
-* Added `exclude?` method to `ActionController::Parameters`.
-
- *Ian Neubert*
-
-* Rescue `EOFError` exception from `rack` on a multipart request.
-
- *Nikita Vasilevsky*
-
-* Log redirects from routes the same way as redirects from controllers.
-
- *Dennis Paagman*
-
-* Prevent `ActionDispatch::ServerTiming` from overwriting existing values in `Server-Timing`.
- Previously, if another middleware down the chain set `Server-Timing` header,
- it would overwritten by `ActionDispatch::ServerTiming`.
-
- *Jakub Malinowski*
-
-* Allow opting out of the `SameSite` cookie attribute when setting a cookie.
-
- You can opt out of `SameSite` by passing `same_site: nil`.
-
- `cookies[:foo] = { value: "bar", same_site: nil }`
-
- Previously, this incorrectly set the `SameSite` attribute to the value of the `cookies_same_site_protection` setting.
-
- *Alex Ghiculescu*
-
-* Allow using `helper_method`s in `content_security_policy` and `permissions_policy`
-
- Previously you could access basic helpers (defined in helper modules), but not
- helper methods defined using `helper_method`. Now you can use either.
-
- ```ruby
- content_security_policy do |p|
- p.default_src "https://example.com"
- p.script_src "https://example.com" if helpers.script_csp?
- end
- ```
-
- *Alex Ghiculescu*
-
-* Reimplement `ActionController::Parameters#has_value?` and `#value?` to avoid parameters and hashes comparison.
-
- Deprecated equality between parameters and hashes is going to be removed in Rails 7.2.
- The new implementation takes care of conversions.
-
- *Seva Stefkin*
-
-* Allow only String and Symbol keys in `ActionController::Parameters`.
- Raise `ActionController::InvalidParameterKey` when initializing Parameters
- with keys that aren't strings or symbols.
-
- *Seva Stefkin*
-
-* Add the ability to use custom logic for storing and retrieving CSRF tokens.
-
- By default, the token will be stored in the session. Custom classes can be
- defined to specify arbitrary behavior, but the ability to store them in
- encrypted cookies is built in.
-
- *Andrew Kowpak*
-
-* Make ActionController::Parameters#values cast nested hashes into parameters.
-
- *Gannon McGibbon*
-
-* Introduce `html:` and `screenshot:` kwargs for system test screenshot helper
-
- Use these as an alternative to the already-available environment variables.
-
- For example, this will display a screenshot in iTerm, save the HTML, and output
- its path.
-
- ```ruby
- take_screenshot(html: true, screenshot: "inline")
- ```
-
- *Alex Ghiculescu*
-
-* Allow `ActionController::Parameters#to_h` to receive a block.
-
- *Bob Farrell*
-
-* Allow relative redirects when `raise_on_open_redirects` is enabled
-
- *Tom Hughes*
-
-* Allow Content Security Policy DSL to generate for API responses.
-
- *Tim Wade*
-
-* Fix `authenticate_with_http_basic` to allow for missing password.
-
- Before Rails 7.0 it was possible to handle basic authentication with only a username.
-
- ```ruby
- authenticate_with_http_basic do |token, _|
- ApiClient.authenticate(token)
- end
- ```
-
- This ability is restored.
-
- *Jean Boussier*
-
-* Fix `content_security_policy` returning invalid directives.
-
- Directives such as `self`, `unsafe-eval` and few others were not
- single quoted when the directive was the result of calling a lambda
- returning an array.
-
- ```ruby
- content_security_policy do |policy|
- policy.frame_ancestors lambda { [:self, "https://example.com"] }
- end
- ```
-
- With this fix the policy generated from above will now be valid.
-
- *Edouard Chin*
-
-* Fix `skip_forgery_protection` to run without raising an error if forgery
- protection has not been enabled / `verify_authenticity_token` is not a
- defined callback.
-
- This fix prevents the Rails 7.0 Welcome Page (`/`) from raising an
- `ArgumentError` if `default_protect_from_forgery` is false.
-
- *Brad Trick*
-
-* Make `redirect_to` return an empty response body.
-
- Application controllers that wish to add a response body after calling
- `redirect_to` can continue to do so.
-
- *Jon Dufresne*
-
-* Use non-capturing group for subdomain matching in `ActionDispatch::HostAuthorization`
-
- Since we do nothing with the captured subdomain group, we can use a non-capturing group instead.
-
- *Sam Bostock*
-
-* Fix `ActionController::Live` to copy the IsolatedExecutionState in the ephemeral thread.
-
- Since its inception `ActionController::Live` has been copying thread local variables
- to keep things such as `CurrentAttributes` set from middlewares working in the controller action.
-
- With the introduction of `IsolatedExecutionState` in 7.0, some of that global state was lost in
- `ActionController::Live` controllers.
-
- *Jean Boussier*
-
-* Fix setting `trailing_slash: true` in route definition.
-
- ```ruby
- get '/test' => "test#index", as: :test, trailing_slash: true
-
- test_path() # => "/test/"
- ```
-
- *Jean Boussier*
-
-* Make `Session#merge!` stringify keys.
-
- Previously `Session#update` would, but `merge!` wouldn't.
-
- *Drew Bragg*
-
-* Add `:unsafe_hashes` mapping for `content_security_policy`
-
- ```ruby
- # Before
- policy.script_src :strict_dynamic, "'unsafe-hashes'", "'sha256-rRMdkshZyJlCmDX27XnL7g3zXaxv7ei6Sg+yt4R3svU='"
-
- # After
- policy.script_src :strict_dynamic, :unsafe_hashes, "'sha256-rRMdkshZyJlCmDX27XnL7g3zXaxv7ei6Sg+yt4R3svU='"
- ```
-
- *Igor Morozov*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actionpack/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actionpack/CHANGELOG.md) for previous changes.
diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb
index dd91ee2ee636b..f88f6410e0715 100644
--- a/actionpack/lib/abstract_controller/base.rb
+++ b/actionpack/lib/abstract_controller/base.rb
@@ -75,9 +75,14 @@ def inherited(klass) # :nodoc:
# (ActionController::Metal and ActionController::Base are defined as abstract)
def internal_methods
controller = self
+ methods = []
- controller = controller.superclass until controller.abstract?
- controller.public_instance_methods(true)
+ until controller.abstract?
+ methods += controller.public_instance_methods(false)
+ controller = controller.superclass
+ end
+
+ controller.public_instance_methods(true) - methods
end
# A list of method names that should be considered actions. This
diff --git a/actionpack/lib/abstract_controller/translation.rb b/actionpack/lib/abstract_controller/translation.rb
index db71c172abd6c..20b1a80e388cd 100644
--- a/actionpack/lib/abstract_controller/translation.rb
+++ b/actionpack/lib/abstract_controller/translation.rb
@@ -4,8 +4,6 @@
module AbstractController
module Translation
- mattr_accessor :raise_on_missing_translations, default: false
-
# Delegates to I18n.translate.
#
# When the given key starts with a period, it will be scoped by the current
@@ -23,9 +21,7 @@ def translate(key, **options)
key = "#{path}.#{action_name}#{key}"
end
- i18n_raise = options.fetch(:raise, self.raise_on_missing_translations)
-
- ActiveSupport::HtmlSafeTranslation.translate(key, **options, raise: i18n_raise)
+ ActiveSupport::HtmlSafeTranslation.translate(key, **options)
end
alias :t :translate
diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb
index 72a9a73d33b64..f0137d06eef76 100644
--- a/actionpack/lib/action_controller/metal/strong_parameters.rb
+++ b/actionpack/lib/action_controller/metal/strong_parameters.rb
@@ -4,6 +4,7 @@
require "active_support/core_ext/array/wrap"
require "active_support/core_ext/string/filters"
require "active_support/core_ext/object/to_query"
+require "active_support/deep_mergeable"
require "action_dispatch/http/upload"
require "rack/test"
require "stringio"
@@ -137,6 +138,8 @@ class InvalidParameterKey < ArgumentError
# params[:key] # => "value"
# params["key"] # => "value"
class Parameters
+ include ActiveSupport::DeepMergeable
+
cattr_accessor :permit_all_parameters, instance_accessor: false, default: false
cattr_accessor :action_on_unpermitted_parameters, instance_accessor: false
@@ -853,13 +856,41 @@ def merge(other_hash)
)
end
+ ##
+ # :call-seq: merge!(other_hash)
+ #
# Returns the current +ActionController::Parameters+ instance with
# +other_hash+ merged into current hash.
- def merge!(other_hash)
- @parameters.merge!(other_hash.to_h)
+ def merge!(other_hash, &block)
+ @parameters.merge!(other_hash.to_h, &block)
self
end
+ ##
+ # :method: deep_merge
+ # :call-seq: deep_merge(other_hash, &block)
+ #
+ # Returns a new +ActionController::Parameters+ instance with +self+ and +other_hash+ merged recursively.
+ #
+ # Like with +Hash#merge+ in the standard library, a block can be provided
+ # to merge values.
+ #
+ #--
+ # Implemented by ActiveSupport::DeepMergeable#deep_merge.
+
+ ##
+ # :method: deep_merge!
+ # :call-seq: deep_merge!(other_hash, &block)
+ #
+ # Same as +#deep_merge+, but modifies +self+.
+ #
+ #--
+ # Implemented by ActiveSupport::DeepMergeable#deep_merge!.
+
+ def deep_merge?(other_hash) # :nodoc
+ other_hash.is_a?(ActiveSupport::DeepMergeable)
+ end
+
# Returns a new +ActionController::Parameters+ instance with all keys
# from current hash merged into +other_hash+.
def reverse_merge(other_hash)
diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb
index 119905a0de76c..77bf9157af183 100644
--- a/actionpack/lib/action_dispatch/journey/router.rb
+++ b/actionpack/lib/action_dispatch/journey/router.rb
@@ -29,7 +29,7 @@ def eager_load!
end
def serve(req)
- find_routes(req).each do |match, parameters, route|
+ find_routes(req) do |match, parameters, route|
set_params = req.path_parameters
path_info = req.path_info
script_name = req.script_name
@@ -64,7 +64,7 @@ def serve(req)
end
def recognize(rails_req)
- find_routes(rails_req).each do |match, parameters, route|
+ find_routes(rails_req) do |match, parameters, route|
unless route.path.anchored
rails_req.script_name = match.to_s
rails_req.path_info = match.post_match
@@ -121,14 +121,14 @@ def find_routes(req)
routes.sort_by!(&:precedence)
- routes.map! { |r|
+ routes.each { |r|
match_data = r.path.match(path_info)
path_parameters = {}
match_data.names.each_with_index { |name, i|
val = match_data[i + 1]
path_parameters[name.to_sym] = Utils.unescape_uri(val) if val
}
- [match_data, path_parameters, r]
+ yield [match_data, path_parameters, r]
}
end
diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb
index f167414d8b043..9d4adddb9e1eb 100644
--- a/actionpack/lib/action_dispatch/routing/route_set.rb
+++ b/actionpack/lib/action_dispatch/routing/route_set.rb
@@ -632,14 +632,14 @@ def add_route(mapping, name)
if route.segment_keys.include?(:controller)
ActionDispatch.deprecator.warn(<<-MSG.squish)
Using a dynamic :controller segment in a route is deprecated and
- will be removed in Rails 7.1.
+ will be removed in Rails 7.2.
MSG
end
if route.segment_keys.include?(:action)
ActionDispatch.deprecator.warn(<<-MSG.squish)
Using a dynamic :action segment in a route is deprecated and
- will be removed in Rails 7.1.
+ will be removed in Rails 7.2.
MSG
end
@@ -860,7 +860,7 @@ def url_for(options, route_name = nil, url_strategy = UNKNOWN, method_name = nil
params = route_with_params.params
if options.key? :params
- if options[:params]&.respond_to?(:to_hash)
+ if options[:params].respond_to?(:to_hash)
params.merge! options[:params]
else
params[:params] = options[:params]
diff --git a/actionpack/lib/action_dispatch/system_testing/browser.rb b/actionpack/lib/action_dispatch/system_testing/browser.rb
index b4ba977805d0d..710a32b34c156 100644
--- a/actionpack/lib/action_dispatch/system_testing/browser.rb
+++ b/actionpack/lib/action_dispatch/system_testing/browser.rb
@@ -26,10 +26,9 @@ def configure
yield options if block_given? && options
end
- # driver_path can be configured as a proc.
- # proc to update web drivers. Running this proc early allows us to only
- # update the webdriver once and avoid race conditions when using
- # parallel tests.
+ # driver_path can be configured as a proc. Running this proc early allows
+ # us to only update the webdriver once and avoid race conditions when
+ # using parallel tests.
def preload
case type
when :chrome
diff --git a/actionpack/lib/action_pack/gem_version.rb b/actionpack/lib/action_pack/gem_version.rb
index 3bf9ca9f1dd14..5d6e1aca1d1df 100644
--- a/actionpack/lib/action_pack/gem_version.rb
+++ b/actionpack/lib/action_pack/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actionpack/test/abstract/translation_test.rb b/actionpack/test/abstract/translation_test.rb
index 1c0b51c4ed2fc..c1bc8a050a338 100644
--- a/actionpack/test/abstract/translation_test.rb
+++ b/actionpack/test/abstract/translation_test.rb
@@ -47,16 +47,6 @@ def test_action_controller_base_responds_to_l
assert_respond_to @controller, :l
end
- def test_raises_missing_translation_message_with_raise_config_option
- AbstractController::Translation.raise_on_missing_translations = true
-
- assert_raise(I18n::MissingTranslationData) do
- @controller.t("translations.missing")
- end
- ensure
- AbstractController::Translation.raise_on_missing_translations = false
- end
-
def test_raises_missing_translation_message_with_raise_option
assert_raise(I18n::MissingTranslationData) do
@controller.t(:"translations.missing", raise: true)
diff --git a/actionpack/test/controller/base_test.rb b/actionpack/test/controller/base_test.rb
index 6980332fd7ed2..a0932c0472bed 100644
--- a/actionpack/test/controller/base_test.rb
+++ b/actionpack/test/controller/base_test.rb
@@ -14,11 +14,18 @@ class EmptyController < ActionController::Base
end
class SimpleController < ActionController::Base
+ def status
+ head :ok
+ end
+
def hello
self.response_body = "hello"
end
end
+class ChildController < SimpleController
+end
+
class NonEmptyController < ActionController::Base
def public_action
head :ok
@@ -112,12 +119,18 @@ def test_performed?
assert_predicate @empty, :performed?
end
- def test_action_methods
+ def test_empty_controller_action_methods
@empty_controllers.each do |c|
assert_equal Set.new, c.class.action_methods, "#{c.controller_path} should be empty!"
end
end
+ def test_action_methods_with_inherited_shadowed_internal_method
+ assert_includes ActionController::Base.instance_methods, :status
+ assert_equal Set.new(["status", "hello"]), SimpleController.action_methods
+ assert_equal Set.new(["status", "hello"]), ChildController.action_methods
+ end
+
def test_temporary_anonymous_controllers
name = "ExamplesController"
klass = Class.new(ActionController::Base)
diff --git a/actionpack/test/controller/parameters/mutators_test.rb b/actionpack/test/controller/parameters/mutators_test.rb
index a8906d749c0a2..08da724b4b523 100644
--- a/actionpack/test/controller/parameters/mutators_test.rb
+++ b/actionpack/test/controller/parameters/mutators_test.rb
@@ -181,7 +181,7 @@ class ParametersMutatorsTest < ActiveSupport::TestCase
params = ActionController::Parameters.new(name: "Alex", age: "40", location: "Beijing")
params.permit!
params_hash = params.to_h { |key, value| [:"#{key}_modified", value] }
- assert_equal params_hash.keys, %w(name_modified age_modified location_modified)
+ assert_equal %w(name_modified age_modified location_modified), params_hash.keys
end
# rubocop:enable Style/HashTransformKeys
@@ -190,7 +190,7 @@ class ParametersMutatorsTest < ActiveSupport::TestCase
params = ActionController::Parameters.new(name: "Alex", age: "40", location: "Beijing")
params.permit!
params_hash = params.to_h { |key, value| [key, value.is_a?(String) ? "#{value}_modified" : value] }
- assert_equal params_hash.values, %w(Alex_modified 40_modified Beijing_modified)
+ assert_equal %w(Alex_modified 40_modified Beijing_modified), params_hash.values
end
# rubocop:enable Style/HashTransformValues
diff --git a/actionpack/test/controller/parameters/parameters_permit_test.rb b/actionpack/test/controller/parameters/parameters_permit_test.rb
index e3ecfa0266bb9..97acfd46529b9 100644
--- a/actionpack/test/controller/parameters/parameters_permit_test.rb
+++ b/actionpack/test/controller/parameters/parameters_permit_test.rb
@@ -319,6 +319,62 @@ def walk_permitted(params)
assert_equal "32", @params[:person][:age]
end
+ test "not permitted is sticky beyond deep merges" do
+ assert_not_predicate @params.deep_merge(a: "b"), :permitted?
+ end
+
+ test "permitted is sticky beyond deep merges" do
+ @params.permit!
+ assert_predicate @params.deep_merge(a: "b"), :permitted?
+ end
+
+ test "not permitted is sticky beyond deep_merge!" do
+ assert_not_predicate @params.deep_merge!(a: "b"), :permitted?
+ end
+
+ test "permitted is sticky beyond deep_merge!" do
+ @params.permit!
+ assert_predicate @params.deep_merge!(a: "b"), :permitted?
+ end
+
+ test "deep_merge with other Hash" do
+ first, last = @params.dig(:person, :name).values_at(:first, :last)
+ merged_params = @params.deep_merge(person: { name: { last: "A." } })
+
+ assert_equal first, merged_params.dig(:person, :name, :first)
+ assert_not_equal last, merged_params.dig(:person, :name, :last)
+ assert_equal "A.", merged_params.dig(:person, :name, :last)
+ end
+
+ test "deep_merge! with other Hash" do
+ first, last = @params.dig(:person, :name).values_at(:first, :last)
+ @params.deep_merge!(person: { name: { last: "A." } })
+
+ assert_equal first, @params.dig(:person, :name, :first)
+ assert_not_equal last, @params.dig(:person, :name, :last)
+ assert_equal "A.", @params.dig(:person, :name, :last)
+ end
+
+ test "deep_merge with other Parameters" do
+ first, last = @params.dig(:person, :name).values_at(:first, :last)
+ other_params = ActionController::Parameters.new(person: { name: { last: "A." } }).permit!
+ merged_params = @params.deep_merge(other_params)
+
+ assert_equal first, merged_params.dig(:person, :name, :first)
+ assert_not_equal last, merged_params.dig(:person, :name, :last)
+ assert_equal "A.", merged_params.dig(:person, :name, :last)
+ end
+
+ test "deep_merge! with other Parameters" do
+ first, last = @params.dig(:person, :name).values_at(:first, :last)
+ other_params = ActionController::Parameters.new(person: { name: { last: "A." } }).permit!
+ @params.deep_merge!(other_params)
+
+ assert_equal first, @params.dig(:person, :name, :first)
+ assert_not_equal last, @params.dig(:person, :name, :last)
+ assert_equal "A.", @params.dig(:person, :name, :last)
+ end
+
test "#reverse_merge with parameters" do
default_params = ActionController::Parameters.new(id: "1234", person: {}).permit!
merged_params = @params.reverse_merge(default_params)
diff --git a/actionpack/test/controller/routing_test.rb b/actionpack/test/controller/routing_test.rb
index d8f30cf7ba66c..98b3cd8ca4f60 100644
--- a/actionpack/test/controller/routing_test.rb
+++ b/actionpack/test/controller/routing_test.rb
@@ -1694,7 +1694,7 @@ def test_route_with_subdomain_and_constraints_must_receive_params
end
assert_equal({ controller: "pages", action: "show", name: "mypage" },
set.recognize_path("http://subdomain.example.org/page/mypage"))
- assert_equal(name_param, "mypage")
+ assert_equal("mypage", name_param)
end
def test_route_requirement_recognize_with_ignore_case
diff --git a/actionpack/test/controller/test_case_test.rb b/actionpack/test/controller/test_case_test.rb
index 1deb3889cd7ce..bdeb81b999364 100644
--- a/actionpack/test/controller/test_case_test.rb
+++ b/actionpack/test/controller/test_case_test.rb
@@ -259,7 +259,7 @@ def to_param
post :test_params, params: { foo: klass.new }
- assert_equal JSON.parse(@response.body)["foo"], "bar"
+ assert_equal "bar", JSON.parse(@response.body)["foo"]
end
def test_body_stream
@@ -1146,7 +1146,7 @@ class BarControllerTest < ActionController::TestCase
def test_engine_controller_route
get :index
- assert_equal @response.body, "bar"
+ assert_equal "bar", @response.body
end
end
@@ -1159,7 +1159,7 @@ def setup
def test_engine_controller_route
get :index
- assert_equal @response.body, "bar"
+ assert_equal "bar", @response.body
end
end
end
diff --git a/actionpack/test/dispatch/mime_type_test.rb b/actionpack/test/dispatch/mime_type_test.rb
index 49d1c2d85b2fd..311a38037fc9e 100644
--- a/actionpack/test/dispatch/mime_type_test.rb
+++ b/actionpack/test/dispatch/mime_type_test.rb
@@ -161,8 +161,8 @@ class MimeTypeTest < ActiveSupport::TestCase
end
test "type should be equal to symbol" do
- assert_equal Mime[:html], "application/xhtml+xml"
- assert_equal Mime[:html], :html
+ assert_operator Mime[:html], :==, "application/xhtml+xml"
+ assert_operator Mime[:html], :==, :html
end
test "type convenience methods" do
diff --git a/actionpack/test/dispatch/response_test.rb b/actionpack/test/dispatch/response_test.rb
index 0d04bc0d28556..a6cc5f29d97d2 100644
--- a/actionpack/test/dispatch/response_test.rb
+++ b/actionpack/test/dispatch/response_test.rb
@@ -402,7 +402,7 @@ def test_only_set_charset_still_defaults_to_text_html
test "[response.to_a].flatten does not recurse infinitely" do
Timeout.timeout(1) do # use a timeout to prevent it stalling indefinitely
status, headers, body = [@response.to_a].flatten
- assert_equal status, 200
+ assert_equal 200, status
assert_equal headers, @response.headers
assert_nil body
end
diff --git a/actionpack/test/dispatch/test_response_test.rb b/actionpack/test/dispatch/test_response_test.rb
index d2e0aacd9a01b..be8be1482aee2 100644
--- a/actionpack/test/dispatch/test_response_test.rb
+++ b/actionpack/test/dispatch/test_response_test.rb
@@ -37,7 +37,7 @@ def assert_response_code_range(range, predicate)
HTML
assert_kind_of(Nokogiri::XML::Document, response.parsed_body)
- assert_equal(response.parsed_body.at_xpath("/html/body/div").text, "Content")
+ assert_equal("Content", response.parsed_body.at_xpath("/html/body/div").text)
end
if RUBY_VERSION >= "3.1"
diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md
index 674dc9b68c572..f584fc58ed4d9 100644
--- a/actiontext/CHANGELOG.md
+++ b/actiontext/CHANGELOG.md
@@ -1,56 +1,2 @@
-* Use `Rails::HTML5::SafeListSanitizer` by default in the Rails 7.1 configuration if it is
- supported.
- Action Text's sanitizer can be configured by setting
- `config.action_text.sanitizer_vendor`. Supported values are `Rails::HTML4::Sanitizer` or
- `Rails::HTML5::Sanitizer`.
-
- The Rails 7.1 configuration will set this to `Rails::HTML5::Sanitizer` when it is supported, and
- fall back to `Rails::HTML4::Sanitizer`. Previous configurations default to
- `Rails::HTML4::Sanitizer`.
-
- As a result of this change, the defaults for `ActionText::ContentHelper.allowed_tags` and
- `.allowed_attributes` are applied at runtime, so the value of these attributes is now 'nil'
- unless set by the application. You may call `sanitizer_allowed_tags` or
- `sanitizer_allowed_attributes` to inspect the tags and attributes being allowed by the
- sanitizer.
-
- *Mike Dalessio*
-
-* Attachables now can override default attachment missing template.
-
- When rendering Action Text attachments where the underlying attachable model has
- been removed, a fallback template is used. You now can override this template on
- a per-model basis. For example, you could render a placeholder image for a file
- attachment or the text "Deleted User" for a User attachment.
-
- *Matt Swanson*, *Joel Drapper*
-
-* Update bundled Trix version from `1.3.1` to `2.0.4`.
-
- *Sarah Ridge*, *Sean Doyle*
-
-* Apply `field_error_proc` to `rich_text_area` form fields.
-
- *Kaíque Kandy Koga*
-
-* Action Text attachment URLs rendered in a background job (a la Turbo
- Streams) now use `Rails.application.default_url_options` and
- `Rails.application.config.force_ssl` instead of `http://example.org`.
-
- *Jonathan Hefner*
-
-* Support `strict_loading:` option for `has_rich_text` declaration
-
- *Sean Doyle*
-
-* Update ContentAttachment so that it can encapsulate arbitrary HTML content in a document.
-
- *Jamis Buck*
-
-* Fix an issue that caused the content layout to render multiple times when a
- rich_text field was updated.
-
- *Jacob Herrington*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actiontext/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actiontext/CHANGELOG.md) for previous changes.
diff --git a/actiontext/bin/webpack b/actiontext/bin/webpack
deleted file mode 100755
index ac959ee7e53f1..0000000000000
--- a/actiontext/bin/webpack
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-#
-# This file was generated by Bundler.
-#
-# The application 'webpack' is installed as part of a gem, and
-# this file is here to facilitate running it.
-#
-
-require "pathname"
-ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
- Pathname.new(__FILE__).realpath)
-
-bundle_binstub = File.expand_path("../bundle", __FILE__)
-
-if File.file?(bundle_binstub)
- if File.read(bundle_binstub, 150).include?("This file was generated by Bundler")
- load(bundle_binstub)
- else
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
-Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
- end
-end
-
-require "rubygems"
-require "bundler/setup"
-
-load Gem.bin_path("webpacker", "webpack")
diff --git a/actiontext/bin/webpack-dev-server b/actiontext/bin/webpack-dev-server
deleted file mode 100755
index 54b03c0b9f978..0000000000000
--- a/actiontext/bin/webpack-dev-server
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-#
-# This file was generated by Bundler.
-#
-# The application 'webpack-dev-server' is installed as part of a gem, and
-# this file is here to facilitate running it.
-#
-
-require "pathname"
-ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
- Pathname.new(__FILE__).realpath)
-
-bundle_binstub = File.expand_path("../bundle", __FILE__)
-
-if File.file?(bundle_binstub)
- if File.read(bundle_binstub, 150).include?("This file was generated by Bundler")
- load(bundle_binstub)
- else
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
-Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
- end
-end
-
-require "rubygems"
-require "bundler/setup"
-
-load Gem.bin_path("webpacker", "webpack-dev-server")
diff --git a/actiontext/lib/action_text/attachable.rb b/actiontext/lib/action_text/attachable.rb
index f5214ac85a50d..d02c571e0860b 100644
--- a/actiontext/lib/action_text/attachable.rb
+++ b/actiontext/lib/action_text/attachable.rb
@@ -98,11 +98,6 @@ def previewable_attachable?
false
end
- # Returns the attachable as JSON with the +attachable_sgid+ included.
- def as_json(*)
- super.merge("attachable_sgid" => persisted? ? attachable_sgid : nil)
- end
-
# Returns the path to the partial that is used for rendering the attachable
# in Trix. Defaults to +to_partial_path+.
#
@@ -142,5 +137,18 @@ def to_rich_text_attributes(attributes = {})
attrs[:height] = attachable_metadata[:height]
end.compact
end
+
+ private
+ def attribute_names_for_serialization
+ super + ["attachable_sgid"]
+ end
+
+ def read_attribute_for_serialization(key)
+ if key == "attachable_sgid"
+ persisted? ? super : nil
+ else
+ super
+ end
+ end
end
end
diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb
index b1163a865de4e..d003935fbb9f7 100644
--- a/actiontext/lib/action_text/gem_version.rb
+++ b/actiontext/lib/action_text/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actiontext/lib/generators/action_text/install/install_generator.rb b/actiontext/lib/generators/action_text/install/install_generator.rb
index b448a2037685e..e52ce26374708 100644
--- a/actiontext/lib/generators/action_text/install/install_generator.rb
+++ b/actiontext/lib/generators/action_text/install/install_generator.rb
@@ -9,8 +9,10 @@ class InstallGenerator < ::Rails::Generators::Base
source_root File.expand_path("templates", __dir__)
def install_javascript_dependencies
- if Pathname(destination_root).join("package.json").exist?
- say "Installing JavaScript dependencies", :green
+ say "Installing JavaScript dependencies", :green
+ if using_bun?
+ run "bun add @rails/actiontext trix"
+ elsif using_node?
run "yarn add @rails/actiontext trix"
end
end
@@ -66,6 +68,21 @@ def create_migrations
rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true
end
+ def using_js_runtime?
+ @using_js_runtime ||= Pathname(destination_root).join("package.json").exist?
+ end
+
+ def using_bun?
+ # Cannot assume yarn.lock has been generated yet so we look for
+ # a file known to be generated by the jsbundling-rails gem
+ @using_bun ||= using_js_runtime? && Pathname(destination_root).join("bun.config.js").exist?
+ end
+
+ def using_node?
+ # Bun is the only runtime that _isn't_ node.
+ @using_node ||= using_js_runtime? && !Pathname(destination_root).join("bun.config.js").exist?
+ end
+
hook_for :test_framework
end
end
diff --git a/actiontext/lib/generators/action_text/install/templates/actiontext.css b/actiontext/lib/generators/action_text/install/templates/actiontext.css
index 1f667ba251a16..3cfcb2b75f394 100644
--- a/actiontext/lib/generators/action_text/install/templates/actiontext.css
+++ b/actiontext/lib/generators/action_text/install/templates/actiontext.css
@@ -3,11 +3,7 @@
* the trix-editor content (whether displayed or under editing). Feel free to incorporate this
* inclusion directly in any other asset bundle and remove this file.
*
-<%- if defined?(Webpacker::Engine) -%>
- *= require trix/dist/trix
-<%- else -%>
*= require trix
-<% end -%>
*/
/*
diff --git a/actiontext/package.json b/actiontext/package.json
index 2ae3cbb7b64b3..5d4f52c9942b3 100644
--- a/actiontext/package.json
+++ b/actiontext/package.json
@@ -1,6 +1,6 @@
{
"name": "@rails/actiontext",
- "version": "7.1.0-alpha",
+ "version": "7.2.0-alpha",
"description": "Edit and display rich text in Rails applications",
"main": "app/assets/javascripts/actiontext.js",
"type": "module",
diff --git a/actiontext/test/dummy/db/schema.rb b/actiontext/test/dummy/db/schema.rb
index 4f54ab17b999c..00cdb967b01dc 100644
--- a/actiontext/test/dummy/db/schema.rb
+++ b/actiontext/test/dummy/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2019_03_17_200724) do
+ActiveRecord::Schema[7.2].define(version: 2019_03_17_200724) do
create_table "action_text_rich_texts", force: :cascade do |t|
t.string "name", null: false
t.text "body"
diff --git a/actiontext/test/unit/attachable_test.rb b/actiontext/test/unit/attachable_test.rb
index d668febfcf856..bdf46b42d64fc 100644
--- a/actiontext/test/unit/attachable_test.rb
+++ b/actiontext/test/unit/attachable_test.rb
@@ -40,4 +40,13 @@ class ActionText::AttachableTest < ActiveSupport::TestCase
assert_equal attributes, attachable.as_json
end
+
+ test "attachable_sgid is included in as_json when only option is nil or includes attachable_sgid" do
+ attachable = ActiveStorage::Blob.create_after_unfurling!(io: StringIO.new("test"), filename: "test.txt", key: 123)
+
+ assert_equal({ "id" => attachable.id }, attachable.as_json(only: :id))
+ assert_equal({ "id" => attachable.id }, attachable.as_json(only: [:id]))
+ assert_equal(attachable.as_json.except("attachable_sgid"), attachable.as_json(except: :attachable_sgid))
+ assert_equal(attachable.as_json.except("attachable_sgid"), attachable.as_json(except: [:attachable_sgid]))
+ end
end
diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md
index 5c004b9f84f42..e3b04726b00a6 100644
--- a/actionview/CHANGELOG.md
+++ b/actionview/CHANGELOG.md
@@ -1,348 +1,23 @@
-* Don't double-encode nested `field_id` and `field_name` index values
+* Added validation for HTML tag names in the `tag` and `content_tag` helper method. The `tag` and
+ `content_tag` method now checks that the provided tag name adheres to the HTML specification. If
+ an invalid HTML tag name is provided, the method raises an `ArgumentError` with an appropriate error
+ message.
- Pass `index: @options` as a default keyword argument to `field_id` and
- `field_name` view helper methods.
-
- *Sean Doyle*
-
-* Allow opting in/out of `Link preload` headers when calling `stylesheet_link_tag` or `javascript_include_tag`
+ Examples:
```ruby
- # will exclude header, even if setting is enabled:
- javascript_include_tag("http://example.com/all.js", preload_links_header: false)
-
- # will include header, even if setting is disabled:
- stylesheet_link_tag("http://example.com/all.js", preload_links_header: true)
- ```
-
- *Alex Ghiculescu*
-
-* Stop generating `Link preload` headers once it has reached 1KB.
-
- Some proxies have trouble handling large headers, but more importantly preload links
- have diminishing returns so it's preferable not to go overboard with them.
-
- If tighter control is needed, it's recommended to disable automatic generation of preloads
- and to generate them manually from the controller or from a middleware.
-
- *Jean Boussier*
-
-* `simple_format` helper now handles a `:sanitize_options` - any extra options you want appending to the sanitize.
-
- Before:
- ```ruby
- simple_format("Continue")
- # => "
"
- ```
-
- *Andrei Andriichuk*
+ # Raises ArgumentError: Invalid HTML5 tag name: 12p
+ content_tag("12p") # Starting with a number
-* Add support for HTML5 standards-compliant sanitizers, and default to `Rails::HTML5::Sanitizer`
- in the Rails 7.1 configuration if it is supported.
+ # Raises ArgumentError: Invalid HTML5 tag name: ""
+ content_tag("") # Empty tag name
- Action View's HTML sanitizers can be configured by setting
- `config.action_view.sanitizer_vendor`. Supported values are `Rails::HTML4::Sanitizer` or
- `Rails::HTML5::Sanitizer`.
+ # Raises ArgumentError: Invalid HTML5 tag name: div/
+ tag("div/") # Contains a solidus
- The Rails 7.1 configuration will set this to `Rails::HTML5::Sanitizer` when it is supported, and
- fall back to `Rails::HTML4::Sanitizer`. Previous configurations default to
- `Rails::HTML4::Sanitizer`.
-
- *Mike Dalessio*
-
-* `config.dom_testing_default_html_version` controls the HTML parser used by
- `ActionView::TestCase#document_root_element`, which creates the DOM used by the assertions in
- Rails::Dom::Testing.
-
- The Rails 7.1 default configuration opts into the HTML5 parser when it is supported, to better
- represent what the DOM would be in a browser user agent. Previously this test helper always used
- Nokogiri's HTML4 parser.
-
- *Mike Dalessio*
-
-* Add support for the HTML picture tag. It supports passing a String, an Array or a Block.
- Supports passing properties directly to the img tag via the `:image` key.
- Since the picture tag requires an img tag, the last element you provide will be used for the img tag.
- For complete control over the picture tag, a block can be passed, which will populate the contents of the tag accordingly.
-
- Can be used like this for a single source:
- ```erb
- <%= picture_tag("picture.webp") %>
- ```
- which will generate the following:
- ```html
-
-
-
- ```
-
- For multiple sources:
- ```erb
- <%= picture_tag("picture.webp", "picture.png", :class => "mt-2", :image => { alt: "Image", class: "responsive-img" }) %>
+ # Raises ArgumentError: Invalid HTML5 tag name: "image file"
+ tag("image file") # Contains a space
```
- will generate:
- ```html
-
-
-
-
-
- ```
-
- Full control via a block:
- ```erb
- <%= picture_tag(:class => "my-class") do %>
- <%= tag(:source, :srcset => image_path("picture.webp")) %>
- <%= tag(:source, :srcset => image_path("picture.png")) %>
- <%= image_tag("picture.png", :alt => "Image") %>
- <% end %>
- ```
- will generate:
- ```html
-
-
-
-
-
- ```
-
- *Juan Pablo Balarini*
-
-* Remove deprecated support to passing instance variables as locals to partials.
-
- *Rafael Mendonça França*
-
-* Remove deprecated constant `ActionView::Path`.
-
- *Rafael Mendonça França*
-
-* Guard `token_list` calls from escaping HTML too often
-
- *Sean Doyle*
-
-* `select` can now be called with a single hash containing options and some HTML options
-
- Previously this would not work as expected:
-
- ```erb
- <%= select :post, :author, authors, required: true %>
- ```
-
- Instead you needed to do this:
-
- ```erb
- <%= select :post, :author, authors, {}, required: true %>
- ```
-
- Now, either form is accepted, for the following HTML attributes: `required`, `multiple`, `size`.
-
- *Alex Ghiculescu*
-
-* Datetime form helpers (`time_field`, `date_field`, `datetime_field`, `week_field`, `month_field`) now accept an instance of Time/Date/DateTime as `:value` option.
-
- Before:
- ```erb
- <%= form.datetime_field :written_at, value: Time.current.strftime("%Y-%m-%dT%T") %>
- ```
-
- After:
- ```erb
- <%= form.datetime_field :written_at, value: Time.current %>
- ```
-
- *Andrey Samsonov*
-
-* Choices of `select` can optionally contain html attributes as the last element
- of the child arrays when using grouped/nested collections
-
- ```erb
- <%= form.select :foo, [["North America", [["United States","US"],["Canada","CA"]], { disabled: "disabled" }]] %>
- # =>
- ```
-
- *Chris Gunther*
-
-* `check_box_tag` and `radio_button_tag` now accept `checked` as a keyword argument
-
- This is to make the API more consistent with the `FormHelper` variants. You can now
- provide `checked` as a positional or keyword argument:
-
- ```erb
- = check_box_tag "admin", "1", false
- = check_box_tag "admin", "1", checked: false
-
- = radio_button_tag 'favorite_color', 'maroon', false
- = radio_button_tag 'favorite_color', 'maroon', checked: false
- ```
-
- *Alex Ghiculescu*
-
-* Allow passing a class to `dom_id`.
- You no longer need to call `new` when passing a class to `dom_id`.
- This makes `dom_id` behave like `dom_class` in this regard.
- Apart from saving a few keystrokes, it prevents Ruby from needing
- to instantiate a whole new object just to generate a string.
-
- Before:
- ```ruby
- dom_id(Post) # => NoMethodError: undefined method `to_key' for Post:Class
- ```
-
- After:
- ```ruby
- dom_id(Post) # => "new_post"
- ```
-
- *Goulven Champenois*
-
-* Report `:locals` as part of the data returned by ActionView render instrumentation.
-
- Before:
- ```ruby
- {
- identifier: "/Users/adam/projects/notifications/app/views/posts/index.html.erb",
- layout: "layouts/application"
- }
- ```
-
- After:
- ```ruby
- {
- identifier: "/Users/adam/projects/notifications/app/views/posts/index.html.erb",
- layout: "layouts/application",
- locals: {foo: "bar"}
- }
- ```
-
- *Aaron Gough*
-
-* Strip `break_sequence` at the end of `word_wrap`.
-
- This fixes a bug where `word_wrap` didn't properly strip off break sequences that had printable characters.
-
- For example, compare the outputs of this template:
-
- ```erb
- # <%= word_wrap("11 22\n33 44", line_width: 2, break_sequence: "\n# ") %>
- ```
-
- Before:
-
- ```
- # 11
- # 22
- #
- # 33
- # 44
- #
- ```
-
- After:
-
- ```
- # 11
- # 22
- # 33
- # 44
- ```
-
- *Max Chernyak*
-
-* Allow templates to set strict `locals`.
-
- By default, templates will accept any `locals` as keyword arguments. To define what `locals` a template accepts, add a `locals` magic comment:
-
- ```erb
- <%# locals: (message:) -%>
- <%= message %>
- ```
-
- Default values can also be provided:
-
- ```erb
- <%# locals: (message: "Hello, world!") -%>
- <%= message %>
- ```
-
- Or `locals` can be disabled entirely:
-
- ```erb
- <%# locals: () %>
- ```
-
- *Joel Hawksley*
-
-* Add `include_seconds` option for `datetime_local_field`
-
- This allows to omit seconds part in the input field, by passing `include_seconds: false`
-
- *Wojciech Wnętrzak*
-
-* Guard against `ActionView::Helpers::FormTagHelper#field_name` calls with nil
- `object_name` arguments. For example:
-
- ```erb
- <%= fields do |f| %>
- <%= f.field_name :body %>
- <% end %>
- ```
-
- *Sean Doyle*
-
-* Strings returned from `strip_tags` are correctly tagged `html_safe?`
-
- Because these strings contain no HTML elements and the basic entities are escaped, they are safe
- to be included as-is as PCDATA in HTML content. Tagging them as html-safe avoids double-escaping
- entities when being concatenated to a SafeBuffer during rendering.
-
- Fixes [rails/rails-html-sanitizer#124](https://github.com/rails/rails-html-sanitizer/issues/124)
-
- *Mike Dalessio*
-
-* Move `convert_to_model` call from `form_for` into `form_with`
-
- Now that `form_for` is implemented in terms of `form_with`, remove the
- `convert_to_model` call from `form_for`.
-
- *Sean Doyle*
-
-* Fix and add protections for XSS in `ActionView::Helpers` and `ERB::Util`.
-
- Escape dangerous characters in names of tags and names of attributes in the
- tag helpers, following the XML specification. Rename the option
- `:escape_attributes` to `:escape`, to simplify by applying the option to the
- whole tag.
-
- *Álvaro Martín Fraguas*
-
-* Extend audio_tag and video_tag to accept Active Storage attachments.
-
- Now it's possible to write
-
- ```ruby
- audio_tag(user.audio_file)
- video_tag(user.video_file)
- ```
-
- Instead of
-
- ```ruby
- audio_tag(polymorphic_path(user.audio_file))
- video_tag(polymorphic_path(user.video_file))
- ```
-
- `image_tag` already supported that, so this follows the same pattern.
-
- *Matheus Richard*
-
-* Ensure models passed to `form_for` attempt to call `to_model`.
-
- *Sean Doyle*
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/actionview/CHANGELOG.md) for previous changes.
+ *Akhil G Krishnan*
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/actionview/CHANGELOG.md) for previous changes.
diff --git a/actionview/app/javascript/README.md b/actionview/app/javascript/README.md
index 5da96a909a50a..0bec0e2bae0b9 100644
--- a/actionview/app/javascript/README.md
+++ b/actionview/app/javascript/README.md
@@ -15,6 +15,9 @@ Note that the `data` attributes this library adds are a feature of HTML5. If you
## Installation
+### Bun
+ bun add @rails/ujs
+
### npm
npm install @rails/ujs --save
@@ -37,7 +40,7 @@ In a conventional Rails application that uses the asset pipeline, require `rails
### ES2015+
-If you're using the Webpacker gem or some other JavaScript bundler, add the following to your main JS file:
+If you're using a JavaScript bundler, add the following to your main JS file:
```javascript
import Rails from "@rails/ujs"
diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb
index 211d4f1bef973..cb1e485fedb37 100644
--- a/actionview/lib/action_view/base.rb
+++ b/actionview/lib/action_view/base.rb
@@ -44,9 +44,9 @@ module ActionView # :nodoc:
# Using sub templates allows you to sidestep tedious replication and extract common display structures in shared templates. The
# classic example is the use of a header and footer (even though the Action Pack-way would be to use Layouts):
#
- # <%= render "shared/header" %>
+ # <%= render "application/header" %>
# Something really specific and terrific
- # <%= render "shared/footer" %>
+ # <%= render "application/footer" %>
#
# As you see, we use the output embeddings for the render methods. The render call itself will just return a string holding the
# result of the rendering. The output embedding writes it to the current template.
@@ -55,7 +55,7 @@ module ActionView # :nodoc:
# variables defined using the regular embedding tags. Like this:
#
# <% @page_title = "A Wonderful Hello" %>
- # <%= render "shared/header" %>
+ # <%= render "application/header" %>
#
# Now the header can pick up on the @page_title variable and use it for outputting a title tag:
#
@@ -65,9 +65,9 @@ module ActionView # :nodoc:
#
# You can pass local variables to sub templates by using a hash with the variable names as keys and the objects as values:
#
- # <%= render "shared/header", { headline: "Welcome", person: person } %>
+ # <%= render "application/header", { headline: "Welcome", person: person } %>
#
- # These can now be accessed in shared/header with:
+ # These can now be accessed in application/header with:
#
# Headline: <%= headline %>
# First name: <%= person.first_name %>
diff --git a/actionview/lib/action_view/gem_version.rb b/actionview/lib/action_view/gem_version.rb
index 50eab3071fd24..d7b711cf74226 100644
--- a/actionview/lib/action_view/gem_version.rb
+++ b/actionview/lib/action_view/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb
index 622b8ec86c9b7..966c708117824 100644
--- a/actionview/lib/action_view/helpers/form_helper.rb
+++ b/actionview/lib/action_view/helpers/form_helper.rb
@@ -2521,7 +2521,7 @@ def hidden_field(method, options = {})
# * Creates standard HTML attributes for the tag.
# * :disabled - If set to true, the user will not be able to use this input.
# * :multiple - If set to true, *in most updated browsers* the user will be allowed to select multiple files.
- # * :include_hidden - When multiple: true and include_hidden: true, the field will be prefixed with an field with an empty value to support submitting an empty collection of files.
+ # * :include_hidden - When multiple: true and include_hidden: true, the field will be prefixed with an field with an empty value to support submitting an empty collection of files. Since include_hidden will default to config.active_storage.multiple_file_field_include_hidden if you don't specify include_hidden, you will need to pass include_hidden: false to prevent submitting an empty collection of files when passing multiple: true.
# * :accept - If set to one or multiple mime-types, the user will be suggested a filter when choosing a file. You still need to set up model validations.
#
# ==== Examples
diff --git a/actionview/lib/action_view/helpers/tag_helper.rb b/actionview/lib/action_view/helpers/tag_helper.rb
index 095f05813d98e..f59086bb7c908 100644
--- a/actionview/lib/action_view/helpers/tag_helper.rb
+++ b/actionview/lib/action_view/helpers/tag_helper.rb
@@ -79,11 +79,10 @@ def tag_string(name, content = nil, escape: true, **options, &block)
def content_tag_string(name, content, options, escape = true)
tag_options = tag_options(options, escape) if options
+ TagHelper.ensure_valid_html5_tag_name(name)
if escape
- name = ERB::Util.xml_name_escape(name)
content = ERB::Util.unwrapped_html_escape(content)
end
-
"<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}#{name}>".html_safe
end
@@ -310,7 +309,7 @@ def tag(name = nil, options = nil, open = false, escape = true)
if name.nil?
tag_builder
else
- name = ERB::Util.xml_name_escape(name) if escape
+ ensure_valid_html5_tag_name(name)
"<#{name}#{tag_builder.tag_options(options, escape) if options}#{open ? ">" : " />"}".html_safe
end
end
@@ -400,6 +399,11 @@ def escape_once(html)
end
private
+ def ensure_valid_html5_tag_name(name)
+ raise ArgumentError, "Invalid HTML5 tag name: #{name.inspect}" unless /\A[a-zA-Z][^\s\/>]*\z/.match?(name)
+ end
+ module_function :ensure_valid_html5_tag_name
+
def build_tag_values(*args)
tag_values = []
diff --git a/actionview/lib/action_view/helpers/text_helper.rb b/actionview/lib/action_view/helpers/text_helper.rb
index 1a9f410296cd3..77445ca2e5186 100644
--- a/actionview/lib/action_view/helpers/text_helper.rb
+++ b/actionview/lib/action_view/helpers/text_helper.rb
@@ -320,7 +320,7 @@ def word_wrap(text, line_width: 80, break_sequence: "\n")
# simple_format("Continue", {}, { sanitize_options: { attributes: %w[target href] } })
# # => "
"
def simple_format(text, html_options = {}, options = {})
- wrapper_tag = options.fetch(:wrapper_tag, :p)
+ wrapper_tag = options[:wrapper_tag] || "p"
text = sanitize(text, options.fetch(:sanitize_options, {})) if options.fetch(:sanitize, true)
paragraphs = split_paragraphs(text)
diff --git a/actionview/lib/action_view/layouts.rb b/actionview/lib/action_view/layouts.rb
index 74bc5cfb605d8..eee8c87c911fb 100644
--- a/actionview/lib/action_view/layouts.rb
+++ b/actionview/lib/action_view/layouts.rb
@@ -9,9 +9,9 @@ module ActionView
# Layouts reverse the common pattern of including shared headers and footers in many templates to isolate changes in
# repeated setups. The inclusion pattern has pages that look like this:
#
- # <%= render "shared/header" %>
+ # <%= render "application/header" %>
# Hello World
- # <%= render "shared/footer" %>
+ # <%= render "application/footer" %>
#
# This approach is a decent way of keeping common structures isolated from the changing content, but it's verbose
# and if you ever want to change the structure of these two includes, you'll have to change all the templates.
diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb
index 21e16a64efec7..189e73c23991e 100644
--- a/actionview/lib/action_view/template.rb
+++ b/actionview/lib/action_view/template.rb
@@ -96,11 +96,58 @@ class Template
#
# Given this sub template rendering:
#
- # <%= render "shared/header", { headline: "Welcome", person: person } %>
+ # <%= render "application/header", { headline: "Welcome", person: person } %>
#
# You can use +local_assigns+ in the sub templates to access the local variables:
#
# local_assigns[:headline] # => "Welcome"
+ #
+ # Each key in +local_assigns+ is available as a partial-local variable:
+ #
+ # local_assigns[:headline] # => "Welcome"
+ # headline # => "Welcome"
+ #
+ # Since +local_assigns+ is a +Hash+, it's compatible with Ruby 3.1's pattern
+ # matching assignment operator:
+ #
+ # local_assigns => { headline:, **options }
+ # headline # => "Welcome"
+ # options # => {}
+ #
+ # Pattern matching assignment also supports variable renaming:
+ #
+ # local_assigns => { headline: title }
+ # title # => "Welcome"
+ #
+ # If a template refers to a variable that isn't passed into the view as part
+ # of the locals: { ... } Hash, the template will raise an
+ # +ActionView::Template::Error+:
+ #
+ # <%# => raises ActionView::Template::Error %>
+ # <% alerts.each do |alert| %>
+ #
<%= alert %>
+ # <% end %>
+ #
+ # Since +local_assigns+ returns a +Hash+ instance, you can conditionally
+ # read a variable, then fall back to a default value when
+ # the key isn't part of the locals: { ... } options:
+ #
+ # <% local_assigns.fetch(:alerts, []).each do |alert| %>
+ #
<%= render@section %>
", :erb)
tracker = make_tracker("multiple/_dependencies", template)
- assert_equal ["shared/header", "sections/section"], tracker.dependencies
+ assert_equal ["application/header", "sections/section"], tracker.dependencies
end
def test_finds_dependencies_for_all_kinds_of_identifiers
diff --git a/actionview/test/template/form_helper/form_with_test.rb b/actionview/test/template/form_helper/form_with_test.rb
index 3342907abefa7..7f47046c14c8c 100644
--- a/actionview/test/template/form_helper/form_with_test.rb
+++ b/actionview/test/template/form_helper/form_with_test.rb
@@ -276,10 +276,10 @@ def form_with(*, **)
@comment = Comment.new
def @post.errors
Class.new {
- def [](field); field == "author_name" ? ["can’t be empty"] : [] end
+ def [](field); field == "author_name" ? ["can't be empty"] : [] end
def empty?() false end
def count() 1 end
- def full_messages() ["Author name can’t be empty"] end
+ def full_messages() ["Author name can't be empty"] end
}.new
end
def @post.to_key; [123]; end
diff --git a/actionview/test/template/form_helper_test.rb b/actionview/test/template/form_helper_test.rb
index 940ab049180a3..a18a00bc794cf 100644
--- a/actionview/test/template/form_helper_test.rb
+++ b/actionview/test/template/form_helper_test.rb
@@ -110,10 +110,10 @@ def form_for(*)
@comment = Comment.new
def @post.errors
Class.new {
- def [](field); field == "author_name" ? ["can’t be empty"] : [] end
+ def [](field); field == "author_name" ? ["can't be empty"] : [] end
def empty?() false end
def count() 1 end
- def full_messages() ["Author name can’t be empty"] end
+ def full_messages() ["Author name can't be empty"] end
}.new
end
def @post.to_key; [123]; end
diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb
index a1576caf7857f..0e7787873c3bf 100644
--- a/actionview/test/template/render_test.rb
+++ b/actionview/test/template/render_test.rb
@@ -151,7 +151,7 @@ def test_render_raw_is_html_safe_and_does_not_escape_output
buffer = ActiveSupport::SafeBuffer.new
buffer << @view.render(template: "plain_text")
assert_equal true, buffer.html_safe?
- assert_equal buffer, "<%= hello_world %>\n"
+ assert_equal "<%= hello_world %>\n", buffer
end
def test_render_ruby_template_with_handlers
diff --git a/actionview/test/template/tag_helper_test.rb b/actionview/test/template/tag_helper_test.rb
index 1b18b6d546e2c..0685ebeb0324e 100644
--- a/actionview/test/template/tag_helper_test.rb
+++ b/actionview/test/template/tag_helper_test.rb
@@ -8,6 +8,7 @@ class TagHelperTest < ActionView::TestCase
tests ActionView::Helpers::TagHelper
COMMON_DANGEROUS_CHARS = "&<>\"' %*+,/;=^|"
+ INVALID_TAG_CHARS = "> /"
def test_tag
assert_equal " ", tag("br")
@@ -122,20 +123,21 @@ def test_tag_builder_do_not_modify_html_safe_options
end
def test_tag_with_dangerous_name
- assert_equal "<#{"_" * COMMON_DANGEROUS_CHARS.size} />",
- tag(COMMON_DANGEROUS_CHARS)
-
- assert_equal "<#{COMMON_DANGEROUS_CHARS} />",
- tag(COMMON_DANGEROUS_CHARS, nil, false, false)
+ INVALID_TAG_CHARS.each_char do |char|
+ tag_name = "asdf-#{char}"
+ assert_raise(ArgumentError, "expected #{tag_name.inspect} to be invalid") do
+ tag(tag_name)
+ end
+ end
end
def test_tag_builder_with_dangerous_name
- escaped_dangerous_chars = "_" * COMMON_DANGEROUS_CHARS.size
- assert_equal "<#{escaped_dangerous_chars}>#{escaped_dangerous_chars}>",
- tag.public_send(COMMON_DANGEROUS_CHARS.to_sym)
-
- assert_equal "<#{COMMON_DANGEROUS_CHARS}>#{COMMON_DANGEROUS_CHARS}>",
- tag.public_send(COMMON_DANGEROUS_CHARS.to_sym, nil, escape: false)
+ INVALID_TAG_CHARS.each_char do |char|
+ tag_name = "asdf-#{char}".to_sym
+ assert_raise(ArgumentError, "expected #{tag_name.inspect} to be invalid") do
+ tag.public_send(tag_name)
+ end
+ end
end
def test_tag_with_dangerous_aria_attribute_name
@@ -426,6 +428,24 @@ def test_content_tag_with_unescaped_conditional_hash_classes
assert_equal "
\">limelight
", str
end
+ def test_content_tag_with_invalid_html_tag
+ invalid_tags = ["12p", "", "image file", "div/", "my>element", "_header"]
+ invalid_tags.each do |tag_name|
+ assert_raise(ArgumentError, "expected #{tag_name.inspect} to be invalid") do
+ content_tag(tag_name)
+ end
+ end
+ end
+
+ def test_tag_with_invalid_html_tag
+ invalid_tags = ["12p", "", "image file", "div/", "my>element", "_header"]
+ invalid_tags.each do |tag_name|
+ assert_raise(ArgumentError, "expected #{tag_name.inspect} to be invalid") do
+ tag(tag_name)
+ end
+ end
+ end
+
def test_tag_builder_with_unescaped_conditional_hash_classes
str = tag.p "limelight", class: { "song": true, "play>": true }, escape: false
assert_equal "
\">limelight
", str
diff --git a/actionview/test/template/test_case_test.rb b/actionview/test/template/test_case_test.rb
index 3a0166d29e92b..247d6b623a8fb 100644
--- a/actionview/test/template/test_case_test.rb
+++ b/actionview/test/template/test_case_test.rb
@@ -2,6 +2,7 @@
require "abstract_unit"
require "rails/engine"
+require "capybara/minitest"
module ActionView
module ATestHelper
@@ -352,6 +353,94 @@ def render_from_helper
end
end
+ class PlaceholderAssertionsTest < ActionView::TestCase
+ helper_method def render_from_helper
+ content_tag "a", "foo", href: "/bar"
+ end
+
+ test "supports placeholders in assert_select calls" do
+ render(partial: "test/from_helper")
+
+ assert_select "a[href=?]", "/bar", text: "foo"
+ end
+ end
+
+ class CapybaraHTMLEncoderTest < ActionView::TestCase
+ include ::Capybara::Minitest::Assertions
+
+ def page
+ Capybara.string(document_root_element)
+ end
+
+ test "document_root_element can be configured to utilize Capybara" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer_with_h1", developer: developer
+
+ assert_kind_of Capybara::Node::Simple, page
+ assert_css "h1", text: developer.name
+ end
+ end
+
+ class RenderedMethodMissingTest < ActionView::TestCase
+ test "rendered delegates methods to the String" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer", developer: developer
+
+ assert_kind_of String, rendered.to_s
+ assert_equal developer.name, rendered
+ assert_match rendered, /#{developer.name}/
+ assert_includes rendered, developer.name
+ end
+ end
+
+ class HTMLParserTest < ActionView::TestCase
+ test "rendered.html is a Nokogiri::XML::Element" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer", developer: developer
+
+ assert_kind_of Nokogiri::XML::Element, rendered.html
+ assert_equal developer.name, document_root_element.text
+ end
+
+ test "do not memoize the rendered.html in view tests" do
+ concat form_tag("/foo")
+
+ assert_equal "/foo", document_root_element.at("form")["action"]
+
+ concat content_tag(:b, "Strong", class: "foo")
+
+ assert_equal "/foo", document_root_element.at("form")["action"]
+ assert_equal "foo", document_root_element.at("b")["class"]
+ end
+ end
+
+ class JSONParserTest < ActionView::TestCase
+ test "rendered.json is an ActiveSupport::HashWithIndifferentAccess" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render formats: :json, partial: "developers/developer", locals: { developer: developer }
+
+ assert_kind_of ActiveSupport::HashWithIndifferentAccess, rendered.json
+ assert_equal developer.name, rendered.json[:name]
+ end
+ end
+
+ class MissingHTMLParserTest < ActionView::TestCase
+ register_parser :html, nil
+
+ test "rendered.html falls back to returning the value when the parser is missing" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer", developer: developer
+
+ assert_kind_of String, rendered.html
+ assert_equal developer.name, rendered.html
+ end
+ end
+
module AHelperWithInitialize
def initialize(*)
super
@@ -365,3 +454,7 @@ class AHelperWithInitializeTest < ActionView::TestCase
end
end
end
+
+if RUBY_VERSION >= "3.1"
+ require_relative "./test_case_test/pattern_matching_test_cases"
+end
diff --git a/actionview/test/template/test_case_test/pattern_matching_test_cases.rb b/actionview/test/template/test_case_test/pattern_matching_test_cases.rb
new file mode 100644
index 0000000000000..f2df2124fe8f8
--- /dev/null
+++ b/actionview/test/template/test_case_test/pattern_matching_test_cases.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ActionView::PatternMatchingTestCases < ActionView::TestCase
+ test "document_root_element integrates with pattern matching" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer_with_h1", developer: developer
+
+ # rubocop:disable Lint/Syntax
+ assert_pattern { document_root_element.at("h1") => { content: "Eloy", attributes: [{ name: "id", value: "name" }] } }
+ refute_pattern { document_root_element.at("h1") => { content: "Not Eloy" } }
+ end
+
+ test "rendered.html integrates with pattern matching" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render "developers/developer", developer: developer
+
+ # rubocop:disable Lint/Syntax
+ assert_pattern { rendered.html => { content: "Eloy" } }
+ refute_pattern { rendered.html => { content: "Not Eloy" } }
+ # rubocop:enable Lint/Syntax
+ end
+
+ test "rendered.json integrates with pattern matching" do
+ developer = DeveloperStruct.new("Eloy")
+
+ render formats: :json, partial: "developers/developer", locals: { developer: developer }
+
+ # rubocop:disable Lint/Syntax
+ assert_pattern { rendered.json => { name: "Eloy" } }
+ refute_pattern { rendered.json => { name: "Not Eloy" } }
+ # rubocop:enable Lint/Syntax
+ end
+end
diff --git a/actionview/test/template/text_helper_test.rb b/actionview/test/template/text_helper_test.rb
index 775496f5d0097..279db5c99badb 100644
--- a/actionview/test/template/text_helper_test.rb
+++ b/actionview/test/template/text_helper_test.rb
@@ -68,6 +68,7 @@ def test_simple_format_should_not_sanitize_input_when_sanitize_option_is_false
def test_simple_format_with_custom_wrapper
assert_equal "", simple_format(nil, {}, { wrapper_tag: "div" })
+ assert_equal "", simple_format(nil, {}, { wrapper_tag: nil })
end
def test_simple_format_with_custom_wrapper_and_multi_line_breaks
diff --git a/actionview/test/template/translation_helper_test.rb b/actionview/test/template/translation_helper_test.rb
index da8807a20215c..ccf018dcd258b 100644
--- a/actionview/test/template/translation_helper_test.rb
+++ b/actionview/test/template/translation_helper_test.rb
@@ -117,25 +117,6 @@ def test_returns_missing_translation_message_does_filters_out_i18n_options
assert_equal expected, translate(:"translations.missing", year: "2015", scope: %i(scoped))
end
- def test_raises_missing_translation_message_with_raise_config_option
- ActionView::Helpers::TranslationHelper.raise_on_missing_translations = true
-
- assert_raise(I18n::MissingTranslationData) do
- translate("translations.missing")
- end
- ensure
- ActionView::Helpers::TranslationHelper.raise_on_missing_translations = false
- end
-
- def test_raise_arg_overrides_raise_config_option
- ActionView::Helpers::TranslationHelper.raise_on_missing_translations = true
-
- expected = /translation missing: en.translations.missing/i
- assert_match expected, translate(:"translations.missing", raise: false)
- ensure
- ActionView::Helpers::TranslationHelper.raise_on_missing_translations = false
- end
-
def test_raises_missing_translation_message_with_raise_option
assert_raise(I18n::MissingTranslationData) do
translate(:"translations.missing", raise: true)
diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md
index 7568300c83554..ba6d525b2074d 100644
--- a/activejob/CHANGELOG.md
+++ b/activejob/CHANGELOG.md
@@ -1,201 +1,2 @@
-* Add `after_discard` method.
- This method lets job authors define a block which will be run when a job is about to be discarded. For example:
-
- ```ruby
- class AfterDiscardJob < ActiveJob::Base
- after_discard do |job, exception|
- Rails.logger.info("#{job.class} raised an exception: #{exception}")
- end
-
- def perform
- raise StandardError
- end
- end
- ```
-
- The above job will run the block passed to `after_discard` after the job is discarded. The exception will
- still be raised after the block has been run.
-
- *Rob Cardy*
-
-* Fix deserialization of ActiveSupport::Duration
-
- Previously, a deserialized Duration would return an array from Duration#parts.
- It will now return a hash just like a regular Duration.
-
- This also fixes an error when trying to add or subtract from a deserialized Duration
- (eg `duration + 1.year`).
-
- *Jonathan del Strother*
-
-* `perform_enqueued_jobs` is now compatible with all Active Job adapters
-
- This means that methods that depend on it, like Action Mailer's `assert_emails`,
- will work correctly even if the test adapter is not used.
-
- *Alex Ghiculescu*
-
-* Allow queue adapters to provide a custom name by implementing `queue_adapter_name`
-
- *Sander Verdonschot*
-
-* Log background job enqueue callers
-
- Add `verbose_enqueue_logs` configuration option to display the caller
- of background job enqueue in the log to help with debugging.
-
- Example log line:
-
- ```
- Enqueued AvatarThumbnailsJob (Job ID: ab528951-41fb-4c48-9129-3171791c27d6) to Sidekiq(default) with arguments: 1092412064
- ↳ app/models/user.rb:421:in `generate_avatar_thumbnails'
- ```
-
- Enabled in development only for new and upgraded applications. Not recommended for use
- in the production environment since it relies on Ruby's `Kernel#caller` which is fairly slow.
-
- *fatkodima*
-
-* Set `provider_job_id` for Backburner jobs
-
- *Cameron Matheson*
-
-* Add `perform_all_later` to enqueue multiple jobs at once
-
- This adds the ability to bulk enqueue jobs, without running callbacks, by
- passing multiple jobs or an array of jobs. For example:
-
- ```ruby
- ActiveJob.perform_all_later(MyJob.new("hello", 42), MyJob.new("world", 0))
-
- user_jobs = User.pluck(:id).map { |id| UserJob.new(user_id: id) }
- ActiveJob.perform_all_later(user_jobs)
- ```
-
- This can greatly reduce the number of round-trips to the queue datastore.
- For queue adapters that do not implement the new `enqueue_all` method, we
- fall back to enqueuing jobs individually. The Sidekiq adapter implements
- `enqueue_all` with `push_bulk`.
-
- This method does not use the existing `enqueue.active_job` event, but adds a
- new event `enqueue_all.active_job`.
-
- *Sander Verdonschot*
-
-* Don't double log the `job` when using `ActiveRecord::QueryLog`
-
- Previously if you set `config.active_record.query_log_tags` to an array that included
- `:job`, the job name would get logged twice. This bug has been fixed.
-
- *Alex Ghiculescu*
-
-* Add support for Sidekiq's transaction-aware client
-
- *Jonathan del Strother*
-
-* Remove QueAdapter from Active Job.
-
- After maintaining Active Job QueAdapter by Rails and Que side
- to support Ruby 3 keyword arguments and options provided as top level keywords,
- it is quite difficult to maintain it this way.
-
- Active Job Que adapter can be included in the future version of que gem itself.
-
- *Yasuo Honda*
-
-* Fix BigDecimal (de)serialization for adapters using JSON.
-
- Previously, BigDecimal was listed as not needing a serializer. However,
- when used with an adapter storing the job arguments as JSON, it would get
- serialized as a simple String, resulting in deserialization also producing
- a String (instead of a BigDecimal).
-
- By using a serializer, we ensure the round trip is safe.
-
- To ensure applications using BigDecimal job arguments are not subject to
- race conditions during deployment (where a replica running a version of
- Rails without BigDecimalSerializer fails to deserialize an argument
- serialized with it), `ActiveJob.use_big_decimal_serializer` is disabled by
- default, and can be set to true in a following deployment..
-
- *Sam Bostock*
-
-* Preserve full-precision `enqueued_at` timestamps for serialized jobs,
- allowing more accurate reporting of how long a job spent waiting in the
- queue before it was performed.
-
- Retains IS08601 format compatibility.
-
- *Jeremy Daer*
-
-* Add `--parent` option to job generator to specify parent class of job.
-
- Example:
-
- `bin/rails g job process_payment --parent=payment_job` generates:
-
- ```ruby
- class ProcessPaymentJob < PaymentJob
- # ...
- end
- ```
-
- *Gannon McGibbon*
-
-* Add more detailed description to job generator.
-
- *Gannon McGibbon*
-
-* `perform.active_job` notification payloads now include `:db_runtime`, which
- is the total time (in ms) taken by database queries while performing a job.
- This value can be used to better understand how a job's time is spent.
-
- *Jonathan Hefner*
-
-* Update `ActiveJob::QueueAdapters::QueAdapter` to remove deprecation warning.
-
- Remove a deprecation warning introduced in que 1.2 to prepare for changes in
- que 2.0 necessary for Ruby 3 compatibility.
-
- *Damir Zekic* and *Adis Hasovic*
-
-* Add missing `bigdecimal` require in `ActiveJob::Arguments`
-
- Could cause `uninitialized constant ActiveJob::Arguments::BigDecimal (NameError)`
- when loading Active Job in isolation.
-
- *Jean Boussier*
-
-* Allow testing `discard_on/retry_on ActiveJob::DeserializationError`
-
- Previously in `perform_enqueued_jobs`, `deserialize_arguments_if_needed`
- was called before calling `perform_now`. When a record no longer exists
- and is serialized using GlobalID this led to raising
- an `ActiveJob::DeserializationError` before reaching `perform_now` call.
- This behavior makes difficult testing the job `discard_on/retry_on` logic.
-
- Now `deserialize_arguments_if_needed` call is postponed to when `perform_now`
- is called.
-
- Example:
-
- ```ruby
- class UpdateUserJob < ActiveJob::Base
- discard_on ActiveJob::DeserializationError
-
- def perform(user)
- # ...
- end
- end
-
- # In the test
- User.destroy_all
- assert_nothing_raised do
- perform_enqueued_jobs only: UpdateUserJob
- end
- ```
-
- *Jacopo Beschi*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activejob/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activejob/CHANGELOG.md) for previous changes.
diff --git a/activejob/lib/active_job/core.rb b/activejob/lib/active_job/core.rb
index 1743327486391..8304d6dd2379f 100644
--- a/activejob/lib/active_job/core.rb
+++ b/activejob/lib/active_job/core.rb
@@ -12,8 +12,10 @@ module Core
attr_accessor :arguments
attr_writer :serialized_arguments
- # Timestamp when the job should be performed
- attr_accessor :scheduled_at
+ # Time when the job should be performed
+ attr_reader :scheduled_at
+
+ attr_reader :_scheduled_at_time # :nodoc:
# Job Identifier
attr_accessor :job_id
@@ -94,6 +96,8 @@ def initialize(*arguments)
@arguments = arguments
@job_id = SecureRandom.uuid
@queue_name = self.class.queue_name
+ @scheduled_at = nil
+ @_scheduled_at_time = nil
@priority = self.class.priority
@executions = 0
@exception_executions = {}
@@ -115,7 +119,8 @@ def serialize
"exception_executions" => exception_executions,
"locale" => I18n.locale.to_s,
"timezone" => timezone,
- "enqueued_at" => Time.now.utc.iso8601(9)
+ "enqueued_at" => Time.now.utc.iso8601(9),
+ "scheduled_at" => _scheduled_at_time ? _scheduled_at_time.utc.iso8601(9) : nil,
}
end
@@ -155,19 +160,32 @@ def deserialize(job_data)
self.exception_executions = job_data["exception_executions"]
self.locale = job_data["locale"] || I18n.locale.to_s
self.timezone = job_data["timezone"] || Time.zone&.name
- self.enqueued_at = job_data["enqueued_at"]
+ self.enqueued_at = Time.iso8601(job_data["enqueued_at"]) if job_data["enqueued_at"]
+ self.scheduled_at = Time.iso8601(job_data["scheduled_at"]) if job_data["scheduled_at"]
end
# Configures the job with the given options.
def set(options = {}) # :nodoc:
- self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
- self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
+ self.scheduled_at = options[:wait].seconds.from_now if options[:wait]
+ self.scheduled_at = options[:wait_until] if options[:wait_until]
self.queue_name = self.class.queue_name_from_part(options[:queue]) if options[:queue]
self.priority = options[:priority].to_i if options[:priority]
self
end
+ def scheduled_at=(value)
+ @_scheduled_at_time = if value.is_a?(Numeric)
+ ActiveJob.deprecator.warn(<<~MSG.squish)
+ Assigning a numeric/epoch value to scheduled_at is deprecated. Use a Time object instead.
+ MSG
+ Time.at(value)
+ else
+ value
+ end
+ @scheduled_at = value
+ end
+
private
def serialize_arguments_if_needed(arguments)
if arguments_serialized?
diff --git a/activejob/lib/active_job/enqueuing.rb b/activejob/lib/active_job/enqueuing.rb
index 357dcd71d0e7b..a805a53f62c3d 100644
--- a/activejob/lib/active_job/enqueuing.rb
+++ b/activejob/lib/active_job/enqueuing.rb
@@ -23,7 +23,7 @@ def perform_all_later(*jobs)
adapter_jobs.each do |job|
job.successfully_enqueued = false
if job.scheduled_at
- queue_adapter.enqueue_at(job, job.scheduled_at)
+ queue_adapter.enqueue_at(job, job._scheduled_at_time.to_f)
else
queue_adapter.enqueue(job)
end
@@ -92,7 +92,7 @@ def enqueue(options = {})
run_callbacks :enqueue do
if scheduled_at
- queue_adapter.enqueue_at self, scheduled_at
+ queue_adapter.enqueue_at self, _scheduled_at_time.to_f
else
queue_adapter.enqueue self
end
diff --git a/activejob/lib/active_job/exceptions.rb b/activejob/lib/active_job/exceptions.rb
index 66111d919df3c..1ec5f0acb2c49 100644
--- a/activejob/lib/active_job/exceptions.rb
+++ b/activejob/lib/active_job/exceptions.rb
@@ -24,7 +24,7 @@ module ClassMethods
# ==== Options
# * :wait - Re-enqueues the job with a delay specified either in seconds (default: 3 seconds),
# as a computing proc that takes the number of executions so far as an argument, or as a symbol reference of
- # :exponentially_longer, which applies the wait algorithm of ((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2
+ # :polynomially_longer, which applies the wait algorithm of ((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2
# (first wait ~3s, then ~18s, then ~83s, etc)
# * :attempts - Re-enqueues the job the specified number of times (default: 5 attempts) or a symbol reference of :unlimited
# to retry the job until it succeeds
@@ -40,11 +40,11 @@ module ClassMethods
# retry_on CustomInfrastructureException, wait: 5.minutes, attempts: :unlimited
#
# retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
- # retry_on Net::OpenTimeout, Timeout::Error, wait: :exponentially_longer, attempts: 10 # retries at most 10 times for Net::OpenTimeout and Timeout::Error combined
+ # retry_on Net::OpenTimeout, Timeout::Error, wait: :polynomially_longer, attempts: 10 # retries at most 10 times for Net::OpenTimeout and Timeout::Error combined
# # To retry at most 10 times for each individual exception:
- # # retry_on Net::OpenTimeout, wait: :exponentially_longer, attempts: 10
+ # # retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 10
# # retry_on Net::ReadTimeout, wait: 5.seconds, jitter: 0.30, attempts: 10
- # # retry_on Timeout::Error, wait: :exponentially_longer, attempts: 10
+ # # retry_on Timeout::Error, wait: :polynomially_longer, attempts: 10
#
# retry_on(YetAnotherCustomAppException) do |job, error|
# ExceptionNotifier.caught(error)
@@ -57,6 +57,12 @@ module ClassMethods
# end
# end
def retry_on(*exceptions, wait: 3.seconds, attempts: 5, queue: nil, priority: nil, jitter: JITTER_DEFAULT)
+ if wait == :exponentially_longer
+ ActiveJob.deprecator.warn(<<~MSG.squish)
+ `wait: :exponentially_longer` will actually wait polynomially longer and is therefore deprecated.
+ Prefer `wait: :polynomially_longer` to avoid confusion and keep the same behavior.
+ MSG
+ end
rescue_from(*exceptions) do |error|
executions = executions_for(exceptions)
if attempts == :unlimited || executions < attempts
@@ -156,7 +162,8 @@ def determine_delay(seconds_or_duration_or_algorithm:, executions:, jitter: JITT
jitter = jitter == JITTER_DEFAULT ? self.class.retry_jitter : (jitter || 0.0)
case seconds_or_duration_or_algorithm
- when :exponentially_longer
+ when :exponentially_longer, :polynomially_longer
+ # This delay uses a polynomial backoff strategy, which was previously misnamed as exponential
delay = executions**4
delay_jitter = determine_jitter_for_delay(delay, jitter)
delay + delay_jitter + 2
diff --git a/activejob/lib/active_job/gem_version.rb b/activejob/lib/active_job/gem_version.rb
index d232f6796a1b4..ef81c3ee0b2bc 100644
--- a/activejob/lib/active_job/gem_version.rb
+++ b/activejob/lib/active_job/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/activejob/lib/active_job/log_subscriber.rb b/activejob/lib/active_job/log_subscriber.rb
index 98751bae73d43..5982fe3ef2441 100644
--- a/activejob/lib/active_job/log_subscriber.rb
+++ b/activejob/lib/active_job/log_subscriber.rb
@@ -8,7 +8,7 @@ class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc:
def enqueue(event)
job = event.payload[:job]
- ex = event.payload[:exception_object]
+ ex = event.payload[:exception_object] || job.enqueue_error
if ex
error do
@@ -28,7 +28,7 @@ def enqueue(event)
def enqueue_at(event)
job = event.payload[:job]
- ex = event.payload[:exception_object]
+ ex = event.payload[:exception_object] || job.enqueue_error
if ex
error do
@@ -76,7 +76,7 @@ def enqueue_all(event)
def perform_start(event)
info do
job = event.payload[:job]
- "Performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name(event)} enqueued at #{job.enqueued_at}" + args_info(job)
+ "Performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name(event)} enqueued at #{job.enqueued_at.utc.iso8601(9)}" + args_info(job)
end
end
subscribe_log_level :perform_start, :info
diff --git a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb
index 9cf90b38f849a..55b578ae7cd63 100644
--- a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb
+++ b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb
@@ -54,7 +54,7 @@ def enqueue_all(jobs) # :nodoc:
"wrapped" => job_class,
"queue" => queue,
"args" => scheduled_jobs.map { |job| [job.serialize] },
- "at" => scheduled_jobs.map { |job| job.scheduled_at }
+ "at" => scheduled_jobs.map { |job| job.scheduled_at&.to_f }
)
enqueued_count += jids.compact.size
end
diff --git a/activejob/lib/active_job/queue_adapters/test_adapter.rb b/activejob/lib/active_job/queue_adapters/test_adapter.rb
index 975ba9a7dfbb5..6e1c8ec6a2bbc 100644
--- a/activejob/lib/active_job/queue_adapters/test_adapter.rb
+++ b/activejob/lib/active_job/queue_adapters/test_adapter.rb
@@ -59,7 +59,7 @@ def filtered?(job)
end
def filtered_time?(job)
- job.scheduled_at > at.to_f if at && job.scheduled_at
+ job.scheduled_at > at if at && job.scheduled_at
end
def filtered_queue?(job)
diff --git a/activejob/lib/active_job/test_helper.rb b/activejob/lib/active_job/test_helper.rb
index aee3fb7a9c160..6a96c2888241a 100644
--- a/activejob/lib/active_job/test_helper.rb
+++ b/activejob/lib/active_job/test_helper.rb
@@ -730,7 +730,7 @@ def deserialize_args_for_assertion(job)
def instantiate_job(payload, skip_deserialize_arguments: false)
job = payload[:job].deserialize(payload)
- job.scheduled_at = payload[:at].to_f if payload.key?(:at)
+ job.scheduled_at = Time.at(payload[:at]) if payload.key?(:at)
job.send(:deserialize_arguments_if_needed) unless skip_deserialize_arguments
job
end
diff --git a/activejob/test/cases/exceptions_test.rb b/activejob/test/cases/exceptions_test.rb
index 1f94dd6ef4868..08011e8b05fe9 100644
--- a/activejob/test/cases/exceptions_test.rb
+++ b/activejob/test/cases/exceptions_test.rb
@@ -108,23 +108,23 @@ class ExceptionsTest < ActiveSupport::TestCase
end
end
- test "exponentially retrying job includes jitter" do
+ test "polynomially retrying job includes jitter" do
travel_to Time.now
random_amount = 2
delay_for_jitter = -> (delay) { random_amount * delay * ActiveJob::Base.retry_jitter }
Kernel.stub(:rand, random_amount) do
- RetryJob.perform_later "ExponentialWaitTenAttemptsError", 5, :log_scheduled_at
+ RetryJob.perform_later "PolynomialWaitTenAttemptsError", 5, :log_scheduled_at
assert_equal [
- "Raised ExponentialWaitTenAttemptsError for the 1st time",
+ "Raised PolynomialWaitTenAttemptsError for the 1st time",
"Next execution scheduled at #{(Time.now + 3.seconds + delay_for_jitter.(1)).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 2nd time",
+ "Raised PolynomialWaitTenAttemptsError for the 2nd time",
"Next execution scheduled at #{(Time.now + 18.seconds + delay_for_jitter.(16)).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 3rd time",
+ "Raised PolynomialWaitTenAttemptsError for the 3rd time",
"Next execution scheduled at #{(Time.now + 83.seconds + delay_for_jitter.(81)).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 4th time",
+ "Raised PolynomialWaitTenAttemptsError for the 4th time",
"Next execution scheduled at #{(Time.now + 258.seconds + delay_for_jitter.(256)).to_f}",
"Successfully completed job"
], JobBuffer.values
@@ -140,16 +140,16 @@ class ExceptionsTest < ActiveSupport::TestCase
random_amount = 1
Kernel.stub(:rand, random_amount) do
- RetryJob.perform_later "ExponentialWaitTenAttemptsError", 5, :log_scheduled_at
+ RetryJob.perform_later "PolynomialWaitTenAttemptsError", 5, :log_scheduled_at
assert_equal [
- "Raised ExponentialWaitTenAttemptsError for the 1st time",
+ "Raised PolynomialWaitTenAttemptsError for the 1st time",
"Next execution scheduled at #{(Time.now + 7.seconds).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 2nd time",
+ "Raised PolynomialWaitTenAttemptsError for the 2nd time",
"Next execution scheduled at #{(Time.now + 82.seconds).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 3rd time",
+ "Raised PolynomialWaitTenAttemptsError for the 3rd time",
"Next execution scheduled at #{(Time.now + 407.seconds).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 4th time",
+ "Raised PolynomialWaitTenAttemptsError for the 4th time",
"Next execution scheduled at #{(Time.now + 1282.seconds).to_f}",
"Successfully completed job"
], JobBuffer.values
@@ -175,16 +175,16 @@ class ExceptionsTest < ActiveSupport::TestCase
ActiveJob::Base.retry_jitter = old_jitter
end
- test "random wait time for exponentially retrying job when retry jitter delay multiplier value is between 1 and 2" do
+ test "random wait time for polynomially retrying job when retry jitter delay multiplier value is between 1 and 2" do
old_jitter = ActiveJob::Base.retry_jitter
ActiveJob::Base.retry_jitter = 1.2
travel_to Time.now
- RetryJob.perform_later "ExponentialWaitTenAttemptsError", 2, :log_scheduled_at
+ RetryJob.perform_later "PolynomialWaitTenAttemptsError", 2, :log_scheduled_at
assert_not_equal [
- "Raised ExponentialWaitTenAttemptsError for the 1st time",
+ "Raised PolynomialWaitTenAttemptsError for the 1st time",
"Next execution scheduled at #{(Time.now + 3.seconds).to_f}",
"Successfully completed job"
], JobBuffer.values
@@ -198,10 +198,10 @@ class ExceptionsTest < ActiveSupport::TestCase
travel_to Time.now
- RetryJob.perform_later "ExponentialWaitTenAttemptsError", 2, :log_scheduled_at
+ RetryJob.perform_later "PolynomialWaitTenAttemptsError", 2, :log_scheduled_at
assert_not_equal [
- "Raised ExponentialWaitTenAttemptsError for the 1st time",
+ "Raised PolynomialWaitTenAttemptsError for the 1st time",
"Next execution scheduled at #{(Time.now + 3.seconds).to_f}",
"Successfully completed job"
], JobBuffer.values
@@ -258,7 +258,7 @@ class ExceptionsTest < ActiveSupport::TestCase
test "use individual execution timers when calculating retry delay" do
travel_to Time.now
- exceptions_to_raise = %w(ExponentialWaitTenAttemptsError CustomWaitTenAttemptsError ExponentialWaitTenAttemptsError CustomWaitTenAttemptsError)
+ exceptions_to_raise = %w(PolynomialWaitTenAttemptsError CustomWaitTenAttemptsError PolynomialWaitTenAttemptsError CustomWaitTenAttemptsError)
random_amount = 1
@@ -268,11 +268,11 @@ class ExceptionsTest < ActiveSupport::TestCase
delay_for_jitter = -> (delay) { random_amount * delay * ActiveJob::Base.retry_jitter }
assert_equal [
- "Raised ExponentialWaitTenAttemptsError for the 1st time",
+ "Raised PolynomialWaitTenAttemptsError for the 1st time",
"Next execution scheduled at #{(Time.now + 3.seconds + delay_for_jitter.(1)).to_f}",
"Raised CustomWaitTenAttemptsError for the 2nd time",
"Next execution scheduled at #{(Time.now + 2.seconds).to_f}",
- "Raised ExponentialWaitTenAttemptsError for the 3rd time",
+ "Raised PolynomialWaitTenAttemptsError for the 3rd time",
"Next execution scheduled at #{(Time.now + 18.seconds + delay_for_jitter.(16)).to_f}",
"Raised CustomWaitTenAttemptsError for the 4th time",
"Next execution scheduled at #{(Time.now + 4.seconds).to_f}",
@@ -370,6 +370,30 @@ class ExceptionsTest < ActiveSupport::TestCase
assert_equal expected_array, JobBuffer.values.last(2)
end
+ class ::LegacyExponentialNamingError < StandardError; end
+ test "wait: :exponentially_longer is deprecated but still works" do
+ assert_deprecated(ActiveJob.deprecator) do
+ class LegacyRetryJob < RetryJob
+ retry_on LegacyExponentialNamingError, wait: :exponentially_longer, attempts: 10, jitter: nil
+ end
+ end
+
+ travel_to Time.now
+ LegacyRetryJob.perform_later "LegacyExponentialNamingError", 5, :log_scheduled_at
+
+ assert_equal [
+ "Raised LegacyExponentialNamingError for the 1st time",
+ "Next execution scheduled at #{(Time.now + 3.seconds).to_f}",
+ "Raised LegacyExponentialNamingError for the 2nd time",
+ "Next execution scheduled at #{(Time.now + 18.seconds).to_f}",
+ "Raised LegacyExponentialNamingError for the 3rd time",
+ "Next execution scheduled at #{(Time.now + 83.seconds).to_f}",
+ "Raised LegacyExponentialNamingError for the 4th time",
+ "Next execution scheduled at #{(Time.now + 258.seconds).to_f}",
+ "Successfully completed job"
+ ], JobBuffer.values
+ end
+
private
def adapter_skips_scheduling?(queue_adapter)
[
diff --git a/activejob/test/cases/instrumentation_test.rb b/activejob/test/cases/instrumentation_test.rb
new file mode 100644
index 0000000000000..50102061c1239
--- /dev/null
+++ b/activejob/test/cases/instrumentation_test.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "helper"
+require "jobs/hello_job"
+require "jobs/retry_job"
+
+class InstrumentationTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
+ setup do
+ JobBuffer.clear
+ end
+
+ test "perform_now emits perform events" do
+ events = subscribed(/perform.*\.active_job/) { HelloJob.perform_now("World!") }
+ assert_equal 2, events.size
+ assert_equal "perform_start.active_job", events[0].first
+ assert_equal "perform.active_job", events[1].first
+ end
+
+ test "perform_later emits an enqueue event" do
+ events = subscribed("enqueue.active_job") { HelloJob.perform_later("World!") }
+ assert_equal 1, events.size
+ end
+
+ test "retry emits an enqueue retry event" do
+ events = subscribed("enqueue_retry.active_job") do
+ perform_enqueued_jobs { RetryJob.perform_later("DefaultsError", 2) }
+ end
+ assert_equal 1, events.size
+ end
+
+ test "retry exhaustion emits a retry_stopped event" do
+ events = subscribed("retry_stopped.active_job") do
+ perform_enqueued_jobs { RetryJob.perform_later("CustomCatchError", 6) }
+ end
+ assert_equal 1, events.size
+ end
+
+ test "discard emits a discard event" do
+ events = subscribed("discard.active_job") do
+ perform_enqueued_jobs { RetryJob.perform_later("DiscardableError", 2) }
+ end
+ assert_equal 1, events.size
+ end
+
+ def subscribed(name, &block)
+ [].tap do |events|
+ ActiveSupport::Notifications.subscribed(-> (*args) { events << args }, name, &block)
+ end
+ end
+end
diff --git a/activejob/test/cases/job_serialization_test.rb b/activejob/test/cases/job_serialization_test.rb
index 4cf90412ac25b..f2b98958cdb5d 100644
--- a/activejob/test/cases/job_serialization_test.rb
+++ b/activejob/test/cases/job_serialization_test.rb
@@ -65,13 +65,50 @@ class JobSerializationTest < ActiveSupport::TestCase
end
end
- test "serializes enqueued_at with full precision" do
+ test "serializes and deserializes enqueued_at with full precision" do
freeze_time
serialized = HelloJob.new.serialize
assert_kind_of String, serialized["enqueued_at"]
enqueued_at = HelloJob.deserialize(serialized).enqueued_at
- assert_equal Time.now.utc, Time.iso8601(enqueued_at)
+ assert_kind_of Time, enqueued_at
+ assert_equal Time.now.utc, enqueued_at
+ end
+
+ test "serializes and deserializes scheduled_at as Time" do
+ freeze_time
+ current_time = Time.now
+
+ job = HelloJob.new
+ job.scheduled_at = current_time
+ serialized_job = job.serialize
+ assert_kind_of String, serialized_job["enqueued_at"]
+ assert_equal current_time.utc.iso8601(9), serialized_job["enqueued_at"]
+
+ deserialized_job = HelloJob.new
+ deserialized_job.deserialize(serialized_job)
+ assert_equal current_time, deserialized_job.scheduled_at
+
+ assert_equal job.serialize, deserialized_job.serialize
+ end
+
+ test "deprecates and coerces numerical scheduled_at attribute to Time when serialized and deserialized" do
+ freeze_time
+ current_time = Time.now
+
+ job = HelloJob.new
+ assert_deprecated(ActiveJob.deprecator) do
+ job.scheduled_at = current_time.to_f
+ end
+
+ serialized_job = job.serialize
+ assert_kind_of String, serialized_job["scheduled_at"]
+ assert_equal current_time.utc.iso8601(9), serialized_job["scheduled_at"]
+
+ deserialized_job = HelloJob.new
+ deserialized_job.deserialize(serialized_job)
+ assert_equal current_time, deserialized_job.scheduled_at
+ assert_equal job.serialize, deserialized_job.serialize
end
end
diff --git a/activejob/test/cases/logging_test.rb b/activejob/test/cases/logging_test.rb
index 2f12b71c71ab0..6669b5ce0852d 100644
--- a/activejob/test/cases/logging_test.rb
+++ b/activejob/test/cases/logging_test.rb
@@ -107,17 +107,17 @@ def test_globalid_nested_parameter_logging
def test_enqueue_job_logging
events = subscribed { HelloJob.perform_later "Cristian" }
assert_match(/Enqueued HelloJob \(Job ID: .*?\) to .*?:.*Cristian/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue.active_job")
+ assert_equal("enqueue.active_job", key)
end
def test_enqueue_job_log_error_when_callback_chain_is_halted
events = subscribed { AbortBeforeEnqueueJob.perform_later }
assert_match(/Failed enqueuing AbortBeforeEnqueueJob.* a before_enqueue callback halted/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue.active_job")
+ assert_equal("enqueue.active_job", key)
end
def test_enqueue_job_log_error_when_error_is_raised_during_callback_chain
@@ -128,9 +128,9 @@ def test_enqueue_job_log_error_when_error_is_raised_during_callback_chain
end
assert_match(/Failed enqueuing AbortBeforeEnqueueJob/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue.active_job")
+ assert_equal("enqueue.active_job", key)
end
def test_perform_job_logging
@@ -207,9 +207,9 @@ def test_perform_nested_jobs_logging
def test_enqueue_at_job_logging
events = subscribed { HelloJob.set(wait_until: 24.hours.from_now).perform_later "Cristian" }
assert_match(/Enqueued HelloJob \(Job ID: .*\) to .*? at.*Cristian/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue_at.active_job")
+ assert_equal("enqueue_at.active_job", key)
rescue NotImplementedError
skip
end
@@ -217,9 +217,9 @@ def test_enqueue_at_job_logging
def test_enqueue_at_job_log_error_when_callback_chain_is_halted
events = subscribed { AbortBeforeEnqueueJob.set(wait: 1.second).perform_later }
assert_match(/Failed enqueuing AbortBeforeEnqueueJob.* a before_enqueue callback halted/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue_at.active_job")
+ assert_equal("enqueue_at.active_job", key)
end
def test_enqueue_at_job_log_error_when_error_is_raised_during_callback_chain
@@ -230,21 +230,35 @@ def test_enqueue_at_job_log_error_when_error_is_raised_during_callback_chain
end
assert_match(/Failed enqueuing AbortBeforeEnqueueJob/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue_at.active_job")
+ assert_equal("enqueue_at.active_job", key)
end
def test_enqueue_in_job_logging
events = subscribed { HelloJob.set(wait: 2.seconds).perform_later "Cristian" }
assert_match(/Enqueued HelloJob \(Job ID: .*\) to .*? at.*Cristian/, @logger.messages)
- assert_equal(events.count, 1)
+ assert_equal(1, events.count)
key, * = events.first
- assert_equal(key, "enqueue_at.active_job")
+ assert_equal("enqueue_at.active_job", key)
rescue NotImplementedError
skip
end
+ def test_enqueue_log_when_enqueue_error_is_set
+ EnqueueErrorJob.disable_test_adapter
+
+ EnqueueErrorJob.perform_later
+ assert_match(/Failed enqueuing EnqueueErrorJob to EnqueueError\(default\): ActiveJob::EnqueueError \(There was an error enqueuing the job\)/, @logger.messages)
+ end
+
+ def test_enqueue_at_log_when_enqueue_error_is_set
+ EnqueueErrorJob.disable_test_adapter
+
+ EnqueueErrorJob.set(wait: 1.hour).perform_later
+ assert_match(/Failed enqueuing EnqueueErrorJob to EnqueueError\(default\): ActiveJob::EnqueueError \(There was an error enqueuing the job\)/, @logger.messages)
+ end
+
def test_for_tagged_logger_support_is_consistent
set_logger ::Logger.new(nil)
OverriddenLoggingJob.perform_later "Dummy"
diff --git a/activejob/test/cases/queuing_test.rb b/activejob/test/cases/queuing_test.rb
index 675496cbcb82a..2413987df33eb 100644
--- a/activejob/test/cases/queuing_test.rb
+++ b/activejob/test/cases/queuing_test.rb
@@ -5,6 +5,7 @@
require "jobs/enqueue_error_job"
require "jobs/multiple_kwargs_job"
require "active_support/core_ext/numeric/time"
+require "minitest/mock"
class QueuingTest < ActiveSupport::TestCase
setup do
@@ -35,7 +36,7 @@ class QueuingTest < ActiveSupport::TestCase
test "job returned by perform_at has the timestamp available" do
job = HelloJob.set(wait_until: Time.utc(2014, 1, 1)).perform_later
- assert_equal Time.utc(2014, 1, 1).to_f, job.scheduled_at
+ assert_equal Time.utc(2014, 1, 1), job.scheduled_at
rescue NotImplementedError
skip
end
@@ -71,6 +72,19 @@ class QueuingTest < ActiveSupport::TestCase
assert_equal ["Jamie says hello", "Job with argument1: John, argument2: 42"], JobBuffer.values.sort
end
+ test "perform_all_later enqueues jobs with schedules" do
+ scheduled_job_1 = HelloJob.new("Scheduled 2014")
+ scheduled_job_1.set(wait_until: Time.utc(2014, 1, 1))
+
+ scheduled_job_2 = HelloJob.new("Scheduled 2015")
+ scheduled_job_2.scheduled_at = Time.utc(2015, 1, 1)
+
+ ActiveJob.perform_all_later(scheduled_job_1, scheduled_job_2)
+ assert_equal ["Scheduled 2014 says hello", "Scheduled 2015 says hello"], JobBuffer.values.sort
+ rescue NotImplementedError
+ skip
+ end
+
test "perform_all_later instrumentation" do
jobs = HelloJob.new("Jamie"), HelloJob.new("John")
called = false
diff --git a/activejob/test/cases/test_helper_test.rb b/activejob/test/cases/test_helper_test.rb
index 64fca4d6030cd..f206b98ab1020 100644
--- a/activejob/test/cases/test_helper_test.rb
+++ b/activejob/test/cases/test_helper_test.rb
@@ -527,7 +527,7 @@ def test_assert_enqueued_with_returns
end
assert_instance_of LoggingJob, job
- assert_in_delta 5.minutes.from_now.to_f, job.scheduled_at, 1
+ assert_in_delta 5.minutes.from_now.to_f, job.scheduled_at.to_f, 1
assert_equal "default", job.queue_name
assert_equal [1, 2, 3, { keyword: true }], job.arguments
end
@@ -537,7 +537,7 @@ def test_assert_enqueued_with_with_no_block_returns
job = assert_enqueued_with(job: LoggingJob)
assert_instance_of LoggingJob, job
- assert_in_delta 5.minutes.from_now.to_f, job.scheduled_at, 1
+ assert_in_delta 5.minutes.from_now.to_f, job.scheduled_at.to_f, 1
assert_equal "default", job.queue_name
assert_equal [1, 2, 3, { keyword: true }], job.arguments
end
diff --git a/activejob/test/jobs/retry_job.rb b/activejob/test/jobs/retry_job.rb
index 8d8c93fa380e4..a1b5a0f13ef08 100644
--- a/activejob/test/jobs/retry_job.rb
+++ b/activejob/test/jobs/retry_job.rb
@@ -10,7 +10,7 @@ class FirstRetryableErrorOfTwo < StandardError; end
class SecondRetryableErrorOfTwo < StandardError; end
class LongWaitError < StandardError; end
class ShortWaitTenAttemptsError < StandardError; end
-class ExponentialWaitTenAttemptsError < StandardError; end
+class PolynomialWaitTenAttemptsError < StandardError; end
class CustomWaitTenAttemptsError < StandardError; end
class CustomCatchError < StandardError; end
class DiscardableError < StandardError; end
@@ -26,7 +26,7 @@ class RetryJob < ActiveJob::Base
retry_on FirstRetryableErrorOfTwo, SecondRetryableErrorOfTwo, attempts: 4
retry_on LongWaitError, wait: 1.hour, attempts: 10
retry_on ShortWaitTenAttemptsError, wait: 1.second, attempts: 10
- retry_on ExponentialWaitTenAttemptsError, wait: :exponentially_longer, attempts: 10
+ retry_on PolynomialWaitTenAttemptsError, wait: :polynomially_longer, attempts: 10
retry_on CustomWaitTenAttemptsError, wait: ->(executions) { executions * 2 }, attempts: 10
retry_on(CustomCatchError) { |job, error| JobBuffer.add("Dealt with a job that failed to retry in a custom way after #{job.arguments.second} attempts. Message: #{error.message}") }
retry_on(ActiveJob::DeserializationError) { |job, error| JobBuffer.add("Raised #{error.class} for the #{job.executions} time") }
@@ -38,7 +38,7 @@ class RetryJob < ActiveJob::Base
before_enqueue do |job|
if job.arguments.include?(:log_scheduled_at) && job.scheduled_at
- JobBuffer.add("Next execution scheduled at #{job.scheduled_at}")
+ JobBuffer.add("Next execution scheduled at #{job.scheduled_at.to_f}")
end
end
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md
index bd697f8dee7f3..16701b065e7c7 100644
--- a/activemodel/CHANGELOG.md
+++ b/activemodel/CHANGELOG.md
@@ -1,197 +1,2 @@
-* Add `ActiveModel::Conversion.param_delimiter` to configure delimiter being used in `to_param`
- *Nikita Vasilevsky*
-
-* `undefine_attribute_methods` undefines alias attribute methods along with attribute methods.
-
- *Nikita Vasilevsky*
-
-* Error.full_message now strips ":base" from the message.
-
- *zzak*
-
-* Add a load hook for `ActiveModel::Model` (named `active_model`) to match the load hook for
- `ActiveRecord::Base` and allow for overriding aspects of the `ActiveModel::Model` class.
-
- *Lewis Buckley*
-
-* Improve password length validation in ActiveModel::SecurePassword to consider byte size for BCrypt
- compatibility.
-
- The previous password length validation only considered the character count, which may not
- accurately reflect the 72-byte size limit imposed by BCrypt. This change updates the validation
- to consider both character count and byte size while keeping the character length validation in place.
-
- ```ruby
- user = User.new(password: "a" * 73) # 73 characters
- user.valid? # => false
- user.errors[:password] # => ["is too long"]
-
-
- user = User.new(password: "あ" * 25) # 25 characters, 75 bytes
- user.valid? # => false
- user.errors[:password] # => ["is too long"]
- ```
-
- *ChatGPT*, *Guillermo Iguaran*
-
-* `has_secure_password` now generates an `#{attribute}_salt` method that returns the salt
- used to compute the password digest. The salt will change whenever the password is changed,
- so it can be used to create single-use password reset tokens with `generates_token_for`:
-
- ```ruby
- class User < ActiveRecord::Base
- has_secure_password
-
- generates_token_for :password_reset, expires_in: 15.minutes do
- password_salt&.last(10)
- end
- end
- ```
-
- *Lázaro Nixon*
-
-* Improve typography of user facing error messages. In English contractions,
- the Unicode APOSTROPHE (`U+0027`) is now RIGHT SINGLE QUOTATION MARK
- (`U+2019`). For example, "can't be blank" is now "can’t be blank".
-
- *Jon Dufresne*
-
-* Add class to `ActiveModel::MissingAttributeError` error message.
-
- Show which class is missing the attribute in the error message:
-
- ```ruby
- user = User.first
- user.pets.select(:id).first.user_id
- # => ActiveModel::MissingAttributeError: missing attribute 'user_id' for Pet
- ```
-
- *Petrik de Heus*
-
-* Raise `NoMethodError` in `ActiveModel::Type::Value#as_json` to avoid unpredictable
- results.
-
- *Vasiliy Ermolovich*
-
-* Custom attribute types that inherit from Active Model built-in types and do
- not override the `serialize` method will now benefit from an optimization
- when serializing attribute values for the database.
-
- For example, with a custom type like the following:
-
- ```ruby
- class DowncasedString < ActiveModel::Type::String
- def cast(value)
- super&.downcase
- end
- end
-
- ActiveRecord::Type.register(:downcased_string, DowncasedString)
-
- class User < ActiveRecord::Base
- attribute :email, :downcased_string
- end
-
- user = User.new(email: "FooBar@example.com")
- ```
-
- Serializing the `email` attribute for the database will be roughly twice as
- fast. More expensive `cast` operations will likely see greater improvements.
-
- *Jonathan Hefner*
-
-* `has_secure_password` now supports password challenges via a
- `password_challenge` accessor and validation.
-
- A password challenge is a safeguard to verify that the current user is
- actually the password owner. It can be used when changing sensitive model
- fields, such as the password itself. It is different than a password
- confirmation, which is used to prevent password typos.
-
- When `password_challenge` is set, the validation checks that the value's
- digest matches the *currently persisted* `password_digest` (i.e.
- `password_digest_was`).
-
- This allows a password challenge to be done as part of a typical `update`
- call, just like a password confirmation. It also allows a password
- challenge error to be handled in the same way as other validation errors.
-
- For example, in the controller, instead of:
-
- ```ruby
- password_params = params.require(:password).permit(
- :password_challenge,
- :password,
- :password_confirmation,
- )
-
- password_challenge = password_params.delete(:password_challenge)
- @password_challenge_failed = !current_user.authenticate(password_challenge)
-
- if !@password_challenge_failed && current_user.update(password_params)
- # ...
- end
- ```
-
- You can now write:
-
- ```ruby
- password_params = params.require(:password).permit(
- :password_challenge,
- :password,
- :password_confirmation,
- ).with_defaults(password_challenge: "")
-
- if current_user.update(password_params)
- # ...
- end
- ```
-
- And, in the view, instead of checking `@password_challenge_failed`, you can
- render an error for the `password_challenge` field just as you would for
- other form fields, including utilizing `config.action_view.field_error_proc`.
-
- *Jonathan Hefner*
-
-* Support infinite ranges for `LengthValidator`s `:in`/`:within` options
-
- ```ruby
- validates_length_of :first_name, in: ..30
- ```
-
- *fatkodima*
-
-* Add support for beginless ranges to inclusivity/exclusivity validators:
-
- ```ruby
- validates_inclusion_of :birth_date, in: -> { (..Date.today) }
- ```
-
- *Bo Jeanes*
-
-* Make validators accept lambdas without record argument
-
- ```ruby
- # Before
- validates_comparison_of :birth_date, less_than_or_equal_to: ->(_record) { Date.today }
-
- # After
- validates_comparison_of :birth_date, less_than_or_equal_to: -> { Date.today }
- ```
-
- *fatkodima*
-
-* Fix casting long strings to `Date`, `Time` or `DateTime`
-
- *fatkodima*
-
-* Use different cache namespace for proxy calls
-
- Models can currently have different attribute bodies for the same method
- names, leading to conflicts. Adding a new namespace `:active_model_proxy`
- fixes the issue.
-
- *Chris Salzberg*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activemodel/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activemodel/CHANGELOG.md) for previous changes.
diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb
index 9118c350adcb5..b9582566cdd22 100644
--- a/activemodel/lib/active_model/attribute_methods.rb
+++ b/activemodel/lib/active_model/attribute_methods.rb
@@ -202,8 +202,10 @@ def attribute_method_affix(*affixes)
# person.name_short? # => true
# person.nickname_short? # => true
def alias_attribute(new_name, old_name)
- self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s)
- self.local_attribute_aliases = local_attribute_aliases.merge(new_name.to_s => old_name.to_s)
+ old_name = old_name.to_s
+ new_name = new_name.to_s
+ self.attribute_aliases = attribute_aliases.merge(new_name => old_name)
+ aliases_by_attribute_name[old_name] << new_name
eagerly_generate_alias_attribute_methods(new_name, old_name)
end
@@ -282,7 +284,12 @@ def attribute_alias(name)
# end
def define_attribute_methods(*attr_names)
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
- attr_names.flatten.each { |attr_name| define_attribute_method(attr_name, _owner: owner) }
+ attr_names.flatten.each do |attr_name|
+ define_attribute_method(attr_name, _owner: owner)
+ aliases_by_attribute_name[attr_name.to_s].each do |aliased_name|
+ generate_alias_attribute_methods owner, aliased_name, attr_name
+ end
+ end
end
end
@@ -365,13 +372,11 @@ def undefine_attribute_methods
attribute_method_patterns_cache.clear
end
- def local_attribute_aliases # :nodoc:
- @local_attribute_aliases ||= {}
+ def aliases_by_attribute_name # :nodoc:
+ @aliases_by_attribute_name ||= Hash.new { |h, k| h[k] = [] }
end
private
- attr_writer :local_attribute_aliases # :nodoc:
-
def inherited(base) # :nodoc:
super
base.class_eval do
diff --git a/activemodel/lib/active_model/conversion.rb b/activemodel/lib/active_model/conversion.rb
index 0a03336555db5..c6f4b2fc898ad 100644
--- a/activemodel/lib/active_model/conversion.rb
+++ b/activemodel/lib/active_model/conversion.rb
@@ -108,7 +108,9 @@ module ClassMethods # :nodoc:
# Provide a class level cache for #to_partial_path. This is an
# internal method and should not be accessed directly.
def _to_partial_path # :nodoc:
- @_to_partial_path ||= begin
+ @_to_partial_path ||= if respond_to?(:model_name)
+ "#{model_name.collection}/#{model_name.element}"
+ else
element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name))
collection = ActiveSupport::Inflector.tableize(name)
"#{collection}/#{element}"
diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb
index d9b83300e6502..dd981e51949a5 100644
--- a/activemodel/lib/active_model/errors.rb
+++ b/activemodel/lib/active_model/errors.rb
@@ -284,7 +284,7 @@ def group_by_attribute
#
# person.errors.add(:name, :blank)
# person.errors.messages
- # # => {:name=>["can’t be blank"]}
+ # # => {:name=>["can't be blank"]}
#
# person.errors.add(:name, :too_long, count: 25)
# person.errors.messages
@@ -332,7 +332,7 @@ def add(attribute, type = :invalid, **options)
#
# person.errors.add :name, :blank
# person.errors.added? :name, :blank # => true
- # person.errors.added? :name, "can’t be blank" # => true
+ # person.errors.added? :name, "can't be blank" # => true
#
# If the error requires options, then it returns +true+ with
# the correct options, or +false+ with incorrect or missing options.
@@ -385,7 +385,7 @@ def of_kind?(attribute, type = :invalid)
#
# person = Person.create(address: '123 First St.')
# person.errors.full_messages
- # # => ["Name is too short (minimum is 5 characters)", "Name can’t be blank", "Email can’t be blank"]
+ # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
def full_messages
@errors.map(&:full_message)
end
@@ -400,7 +400,7 @@ def full_messages
#
# person = Person.create()
# person.errors.full_messages_for(:name)
- # # => ["Name is too short (minimum is 5 characters)", "Name can’t be blank"]
+ # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"]
def full_messages_for(attribute)
where(attribute).map(&:full_message).freeze
end
@@ -414,7 +414,7 @@ def full_messages_for(attribute)
#
# person = Person.create()
# person.errors.messages_for(:name)
- # # => ["is too short (minimum is 5 characters)", "can’t be blank"]
+ # # => ["is too short (minimum is 5 characters)", "can't be blank"]
def messages_for(attribute)
where(attribute).map(&:message)
end
@@ -487,7 +487,7 @@ def normalize_arguments(attribute, type, **options)
# person = Person.new
# person.name = nil
# person.valid?
- # # => ActiveModel::StrictValidationFailed: Name can’t be blank
+ # # => ActiveModel::StrictValidationFailed: Name can't be blank
class StrictValidationFailed < StandardError
end
diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb
index 22e24583bd366..d1d8f256d1d71 100644
--- a/activemodel/lib/active_model/gem_version.rb
+++ b/activemodel/lib/active_model/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/activemodel/lib/active_model/locale/en.yml b/activemodel/lib/active_model/locale/en.yml
index e96ce8ee18c30..c6a036359f863 100644
--- a/activemodel/lib/active_model/locale/en.yml
+++ b/activemodel/lib/active_model/locale/en.yml
@@ -10,10 +10,10 @@ en:
inclusion: "is not included in the list"
exclusion: "is reserved"
invalid: "is invalid"
- confirmation: "doesn’t match %{attribute}"
+ confirmation: "doesn't match %{attribute}"
accepted: "must be accepted"
- empty: "can’t be empty"
- blank: "can’t be blank"
+ empty: "can't be empty"
+ blank: "can't be blank"
present: "must be blank"
too_long:
one: "is too long (maximum is 1 character)"
diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb
index 965b38b78cec7..b6bf24ab8688c 100644
--- a/activemodel/lib/active_model/secure_password.rb
+++ b/activemodel/lib/active_model/secure_password.rb
@@ -57,7 +57,7 @@ module ClassMethods
#
# user.save # => false, password required
# user.password = "vr00m"
- # user.save # => false, confirmation doesn’t match
+ # user.save # => false, confirmation doesn't match
# user.password_confirmation = "vr00m"
# user.save # => true
#
diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb
index 6d3ad5d4fc3a5..40e96bc83e088 100644
--- a/activemodel/lib/active_model/validations.rb
+++ b/activemodel/lib/active_model/validations.rb
@@ -326,7 +326,7 @@ def initialize_dup(other) # :nodoc:
#
# person = Person.new
# person.valid? # => false
- # person.errors # => #
+ # person.errors # => #
def errors
@errors ||= Errors.new(self)
end
diff --git a/activemodel/lib/active_model/validations/confirmation.rb b/activemodel/lib/active_model/validations/confirmation.rb
index 11e15e1d9e06e..b92e3bb1ac370 100644
--- a/activemodel/lib/active_model/validations/confirmation.rb
+++ b/activemodel/lib/active_model/validations/confirmation.rb
@@ -64,7 +64,7 @@ module HelperMethods
# validates_presence_of :password_confirmation, if: :password_changed?
#
# Configuration options:
- # * :message - A custom error message (default is: "doesn’t match
+ # * :message - A custom error message (default is: "doesn't match
# %{translated_attribute_name}").
# * :case_sensitive - Looks for an exact match. Ignored by
# non-text columns (+true+ by default).
diff --git a/activemodel/lib/active_model/validations/presence.rb b/activemodel/lib/active_model/validations/presence.rb
index ae36320f0cb80..533e4dc29845a 100644
--- a/activemodel/lib/active_model/validations/presence.rb
+++ b/activemodel/lib/active_model/validations/presence.rb
@@ -26,7 +26,7 @@ module HelperMethods
# false.blank? # => true.
#
# Configuration options:
- # * :message - A custom error message (default is: "can’t be blank").
+ # * :message - A custom error message (default is: "can't be blank").
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
diff --git a/activemodel/lib/active_model/validations/validates.rb b/activemodel/lib/active_model/validations/validates.rb
index 8145111833d66..48f54e49c8f4d 100644
--- a/activemodel/lib/active_model/validations/validates.rb
+++ b/activemodel/lib/active_model/validations/validates.rb
@@ -144,7 +144,7 @@ def validates(*attributes)
# person = Person.new
# person.name = ''
# person.valid?
- # # => ActiveModel::StrictValidationFailed: Name can’t be blank
+ # # => ActiveModel::StrictValidationFailed: Name can't be blank
def validates!(*attributes)
options = attributes.extract_options!
options[:strict] = true
diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb
index af4626a9daed7..7f0b15a182ffd 100644
--- a/activemodel/test/cases/attribute_methods_test.rb
+++ b/activemodel/test/cases/attribute_methods_test.rb
@@ -119,6 +119,34 @@ class AttributeMethodsTest < ActiveModel::TestCase
ModelWithAttributes.undefine_attribute_methods
end
+ test "#define_attribute_methods defines alias attribute methods after undefining" do
+ topic_class = Class.new do
+ include ActiveModel::AttributeMethods
+ define_attribute_methods :title
+ alias_attribute :aliased_title_to_be_redefined, :title
+
+ def attributes
+ { title: "Active Model Topic" }
+ end
+
+ private
+ def attribute(name)
+ attributes[name.to_sym]
+ end
+ end
+
+ topic = topic_class.new
+ assert_equal("Active Model Topic", topic.aliased_title_to_be_redefined)
+ topic_class.undefine_attribute_methods
+
+ assert_not_respond_to topic, :aliased_title_to_be_redefined
+
+ topic_class.define_attribute_methods :title
+
+ assert_respond_to topic, :aliased_title_to_be_redefined
+ assert_equal "Active Model Topic", topic.aliased_title_to_be_redefined
+ end
+
test "#define_attribute_method does not generate attribute method if already defined in attribute module" do
klass = Class.new(ModelWithAttributes)
klass.send(:generated_attribute_methods).module_eval do
diff --git a/activemodel/test/cases/attributes_dirty_test.rb b/activemodel/test/cases/attributes_dirty_test.rb
index 2d01a5e9a20c2..e8a109b81c567 100644
--- a/activemodel/test/cases/attributes_dirty_test.rb
+++ b/activemodel/test/cases/attributes_dirty_test.rb
@@ -134,7 +134,7 @@ def save
@model.name = "DudeFella ManGuy"
@model.name = "Mr. Manfredgensonton"
assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change
- assert_equal @model.name_was, "Otto"
+ assert_equal "Otto", @model.name_was
end
test "using attribute_will_change! with a symbol" do
diff --git a/activemodel/test/cases/callbacks_test.rb b/activemodel/test/cases/callbacks_test.rb
index 803101a46f80f..b3612e4f583bf 100644
--- a/activemodel/test/cases/callbacks_test.rb
+++ b/activemodel/test/cases/callbacks_test.rb
@@ -55,33 +55,35 @@ def create
test "complete callback chain" do
model = ModelCallbacks.new
model.create
- assert_equal model.callbacks, [ :before_create, :before_around_create, :create,
- :after_around_create, :after_create, :final_callback]
+ assert_equal \
+ [:before_create, :before_around_create, :create, :after_around_create, :after_create, :final_callback],
+ model.callbacks
end
test "the callback chain is not halted when around or after callbacks return false" do
model = ModelCallbacks.new
model.create
- assert_equal model.callbacks.last, :final_callback
+ assert_equal :final_callback, model.callbacks.last
end
test "the callback chain is not halted when a before callback returns false)" do
model = ModelCallbacks.new(before_create_returns: false)
model.create
- assert_equal model.callbacks.last, :final_callback
+ assert_equal :final_callback, model.callbacks.last
end
test "the callback chain is halted when a callback throws :abort" do
model = ModelCallbacks.new(before_create_throws: :abort)
model.create
- assert_equal model.callbacks, [:before_create]
+ assert_equal [:before_create], model.callbacks
end
test "after callbacks are not executed if the block returns false" do
model = ModelCallbacks.new(valid: false)
model.create
- assert_equal model.callbacks, [ :before_create, :before_around_create,
- :create, :after_around_create]
+ assert_equal \
+ [:before_create, :before_around_create, :create, :after_around_create],
+ model.callbacks
end
test "only selects which types of callbacks should be created" do
diff --git a/activemodel/test/cases/conversion_test.rb b/activemodel/test/cases/conversion_test.rb
index d9befc2e24497..eb134595a039e 100644
--- a/activemodel/test/cases/conversion_test.rb
+++ b/activemodel/test/cases/conversion_test.rb
@@ -58,6 +58,10 @@ def persisted?
assert_equal "helicopter/comanches/comanche", Helicopter::Comanche.new.to_partial_path
end
+ test "to_partial_path handles non-standard model_name" do
+ assert_equal "attack_helicopters/ah-64", Helicopter::Apache.new.to_partial_path
+ end
+
test "#to_param_delimiter allows redefining the delimiter used in #to_param" do
old_delimiter = Contact.param_delimiter
Contact.param_delimiter = "_"
diff --git a/activemodel/test/cases/dirty_test.rb b/activemodel/test/cases/dirty_test.rb
index 5f2964ce06793..a72f5a5204f58 100644
--- a/activemodel/test/cases/dirty_test.rb
+++ b/activemodel/test/cases/dirty_test.rb
@@ -184,7 +184,7 @@ def save
@model.status = "finished"
assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change
assert_equal ["waiting", "finished"], @model.status_change
- assert_equal @model.name_was, "Otto"
+ assert_equal "Otto", @model.name_was
end
test "using attribute_will_change! with a symbol" do
diff --git a/activemodel/test/cases/error_test.rb b/activemodel/test/cases/error_test.rb
index 27e4d711c3e21..ef405630b7d30 100644
--- a/activemodel/test/cases/error_test.rb
+++ b/activemodel/test/cases/error_test.rb
@@ -89,7 +89,7 @@ def test_initialize
test "message with type as a symbol" do
error = ActiveModel::Error.new(Person.new, :name, :blank)
- assert_equal "can’t be blank", error.message
+ assert_equal "can't be blank", error.message
end
test "message with custom interpolation" do
@@ -178,15 +178,15 @@ def test_initialize
test "full_message returns the given message with the attribute name included" do
error = ActiveModel::Error.new(Person.new, :name, :blank)
- assert_equal "name can’t be blank", error.full_message
+ assert_equal "name can't be blank", error.full_message
end
test "full_message uses default format" do
- error = ActiveModel::Error.new(Person.new, :name, message: "can’t be blank")
+ error = ActiveModel::Error.new(Person.new, :name, message: "can't be blank")
# Use a locale without errors.format
I18n.with_locale(:unknown) {
- assert_equal "name can’t be blank", error.full_message
+ assert_equal "name can't be blank", error.full_message
}
end
@@ -241,8 +241,8 @@ def test_initialize
)
assert_equal(
- error.details,
- { error: :too_short, foo: :bar }
+ { error: :too_short, foo: :bar },
+ error.details
)
end
@@ -250,6 +250,6 @@ def test_initialize
person = Person.new
error = ActiveModel::Error.new(person, :name, foo: :bar)
- assert_equal(error.details, { error: :invalid, foo: :bar })
+ assert_equal({ error: :invalid, foo: :bar }, error.details)
end
end
diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb
index a3697e708d49d..d0bba5c5fd54f 100644
--- a/activemodel/test/cases/errors_test.rb
+++ b/activemodel/test/cases/errors_test.rb
@@ -179,7 +179,7 @@ def test_no_key
person.errors.add(:name, :blank)
assert_equal :blank, person.errors.objects.first.type
- assert_equal ["can’t be blank"], person.errors[:name]
+ assert_equal ["can't be blank"], person.errors[:name]
end
test "add, with type as String" do
@@ -216,7 +216,7 @@ def test_no_key
person.errors.add(:name, type)
assert_equal :blank, person.errors.objects.first.type
- assert_equal ["can’t be blank"], person.errors[:name]
+ assert_equal ["can't be blank"], person.errors[:name]
end
test "add an error message on a specific attribute with a defined type" do
diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb
index 4357c9168b53f..fd7b58f804883 100644
--- a/activemodel/test/cases/secure_password_test.rb
+++ b/activemodel/test/cases/secure_password_test.rb
@@ -52,14 +52,14 @@ class SecurePasswordTest < ActiveModel::TestCase
@user.password = ""
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
- assert_equal ["can’t be blank"], @user.errors[:password]
+ assert_equal ["can't be blank"], @user.errors[:password]
end
test "create a new user with validation and a nil password" do
@user.password = nil
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
- assert_equal ["can’t be blank"], @user.errors[:password]
+ assert_equal ["can't be blank"], @user.errors[:password]
end
test "create a new user with validation and password length greater than 72 characters" do
@@ -85,7 +85,7 @@ class SecurePasswordTest < ActiveModel::TestCase
@user.password_confirmation = ""
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
- assert_equal ["doesn’t match Password"], @user.errors[:password_confirmation]
+ assert_equal ["doesn't match Password"], @user.errors[:password_confirmation]
end
test "create a new user with validation and a nil password confirmation" do
@@ -99,7 +99,7 @@ class SecurePasswordTest < ActiveModel::TestCase
@user.password_confirmation = "something else"
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
- assert_equal ["doesn’t match Password"], @user.errors[:password_confirmation]
+ assert_equal ["doesn't match Password"], @user.errors[:password_confirmation]
end
test "resetting password to nil clears the password cache" do
@@ -144,7 +144,7 @@ class SecurePasswordTest < ActiveModel::TestCase
@existing_user.password = nil
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
- assert_equal ["can’t be blank"], @existing_user.errors[:password]
+ assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "updating an existing user with validation and password length greater than 72" do
@@ -160,7 +160,7 @@ class SecurePasswordTest < ActiveModel::TestCase
@existing_user.password_confirmation = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
- assert_equal ["doesn’t match Password"], @existing_user.errors[:password_confirmation]
+ assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation]
end
test "updating an existing user with validation and a nil password confirmation" do
@@ -174,7 +174,7 @@ class SecurePasswordTest < ActiveModel::TestCase
@existing_user.password_confirmation = "something else"
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
- assert_equal ["doesn’t match Password"], @existing_user.errors[:password_confirmation]
+ assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation]
end
test "updating an existing user with validation and a correct password challenge" do
@@ -222,14 +222,14 @@ class SecurePasswordTest < ActiveModel::TestCase
@existing_user.password_digest = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
- assert_equal ["can’t be blank"], @existing_user.errors[:password]
+ assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "updating an existing user with validation and a nil password digest" do
@existing_user.password_digest = nil
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
- assert_equal ["can’t be blank"], @existing_user.errors[:password]
+ assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "setting a blank password should not change an existing password" do
diff --git a/activemodel/test/cases/serializers/json_serialization_test.rb b/activemodel/test/cases/serializers/json_serialization_test.rb
index 5b5f5559b7916..050fef1182dca 100644
--- a/activemodel/test/cases/serializers/json_serialization_test.rb
+++ b/activemodel/test/cases/serializers/json_serialization_test.rb
@@ -109,12 +109,12 @@ def @contact.favorite_quote; "Constraints are liberating"; end
test "should return Hash for errors" do
contact = Contact.new
- contact.errors.add :name, "can’t be blank"
+ contact.errors.add :name, "can't be blank"
contact.errors.add :name, "is too short (minimum is 2 characters)"
contact.errors.add :age, "must be 16 or over"
hash = {}
- hash[:name] = ["can’t be blank", "is too short (minimum is 2 characters)"]
+ hash[:name] = ["can't be blank", "is too short (minimum is 2 characters)"]
hash[:age] = ["must be 16 or over"]
assert_equal hash.to_json, contact.errors.to_json
end
diff --git a/activemodel/test/cases/validations/confirmation_validation_test.rb b/activemodel/test/cases/validations/confirmation_validation_test.rb
index 0fef15248ef43..7bf15e4beea5b 100644
--- a/activemodel/test/cases/validations/confirmation_validation_test.rb
+++ b/activemodel/test/cases/validations/confirmation_validation_test.rb
@@ -57,7 +57,7 @@ def test_validates_confirmation_of_for_ruby_class
p.karma_confirmation = "None"
assert_predicate p, :invalid?
- assert_equal ["doesn’t match Karma"], p.errors[:karma_confirmation]
+ assert_equal ["doesn't match Karma"], p.errors[:karma_confirmation]
p.karma = "None"
assert_predicate p, :valid?
@@ -70,14 +70,14 @@ def test_title_confirmation_with_i18n_attribute
I18n.load_path.clear
I18n.backend = I18n::Backend::Simple.new
I18n.backend.store_translations("en",
- errors: { messages: { confirmation: "doesn’t match %{attribute}" } },
+ errors: { messages: { confirmation: "doesn't match %{attribute}" } },
activemodel: { attributes: { topic: { title: "Test Title" } } })
Topic.validates_confirmation_of(:title)
t = Topic.new("title" => "We should be confirmed", "title_confirmation" => "")
assert_predicate t, :invalid?
- assert_equal ["doesn’t match Test Title"], t.errors[:title_confirmation]
+ assert_equal ["doesn't match Test Title"], t.errors[:title_confirmation]
ensure
I18n.load_path.replace @old_load_path
I18n.backend = @old_backend
diff --git a/activemodel/test/cases/validations/exclusion_validation_test.rb b/activemodel/test/cases/validations/exclusion_validation_test.rb
index 26353e32fabcd..07654142bf1ab 100644
--- a/activemodel/test/cases/validations/exclusion_validation_test.rb
+++ b/activemodel/test/cases/validations/exclusion_validation_test.rb
@@ -84,6 +84,26 @@ def test_validates_exclusion_of_with_range
assert_predicate Topic.new(content: "h"), :valid?
end
+ def test_validates_exclusion_of_beginless_numeric_range
+ range_end = 1000
+ Topic.validates_exclusion_of(:raw_price, in: ..range_end)
+ assert_predicate Topic.new(title: "aaa", price: -100), :invalid?
+ assert_predicate Topic.new(title: "aaa", price: 0), :invalid?
+ assert_predicate Topic.new(title: "aaa", price: 100), :invalid?
+ assert_predicate Topic.new(title: "aaa", price: 2000), :valid?
+ assert_predicate Topic.new(title: "aaa", price: range_end), :invalid?
+ end
+
+ def test_validates_exclusion_of_endless_numeric_range
+ range_begin = 0
+ Topic.validates_exclusion_of(:raw_price, in: range_begin..)
+ assert_predicate Topic.new(title: "aaa", price: -1), :valid?
+ assert_predicate Topic.new(title: "aaa", price: -100), :valid?
+ assert_predicate Topic.new(title: "aaa", price: 100), :invalid?
+ assert_predicate Topic.new(title: "aaa", price: 2000), :invalid?
+ assert_predicate Topic.new(title: "aaa", price: range_begin), :invalid?
+ end
+
def test_validates_exclusion_of_with_time_range
Topic.validates_exclusion_of :created_at, in: 6.days.ago..2.days.ago
diff --git a/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb b/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb
index 50484b538c5a1..d3e44945db27a 100644
--- a/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb
+++ b/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb
@@ -39,7 +39,7 @@ def test_generate_message_invalid_with_custom_message
# validates_confirmation_of: generate_message(attr_name, :confirmation, message: custom_message)
def test_generate_message_confirmation_with_default_message
- assert_equal "doesn’t match Title", @person.errors.generate_message(:title, :confirmation)
+ assert_equal "doesn't match Title", @person.errors.generate_message(:title, :confirmation)
end
def test_generate_message_confirmation_with_custom_message
@@ -57,7 +57,7 @@ def test_generate_message_accepted_with_custom_message
# add_on_empty: generate_message(attr, :empty, message: custom_message)
def test_generate_message_empty_with_default_message
- assert_equal "can’t be empty", @person.errors.generate_message(:title, :empty)
+ assert_equal "can't be empty", @person.errors.generate_message(:title, :empty)
end
def test_generate_message_empty_with_custom_message
@@ -66,7 +66,7 @@ def test_generate_message_empty_with_custom_message
# validates_presence_of: generate_message(attr, :blank, message: custom_message)
def test_generate_message_blank_with_default_message
- assert_equal "can’t be blank", @person.errors.generate_message(:title, :blank)
+ assert_equal "can't be blank", @person.errors.generate_message(:title, :blank)
end
def test_generate_message_blank_with_custom_message
diff --git a/activemodel/test/cases/validations/i18n_validation_test.rb b/activemodel/test/cases/validations/i18n_validation_test.rb
index 3676e32bbde68..0717e6bb6d66e 100644
--- a/activemodel/test/cases/validations/i18n_validation_test.rb
+++ b/activemodel/test/cases/validations/i18n_validation_test.rb
@@ -67,9 +67,9 @@ def test_errors_full_messages_on_nested_error_uses_attribute_format
})
person = person_class.new
- error = ActiveModel::Error.new(person, :gender, "can’t be blank")
+ error = ActiveModel::Error.new(person, :gender, "can't be blank")
person.errors.import(error, attribute: "person[0].contacts.gender")
- assert_equal ["Gender can’t be blank"], person.errors.full_messages
+ assert_equal ["Gender can't be blank"], person.errors.full_messages
end
def test_errors_full_messages_uses_attribute_format
diff --git a/activemodel/test/cases/validations/presence_validation_test.rb b/activemodel/test/cases/validations/presence_validation_test.rb
index f7e5fe631ea9b..7c9ff2681e2fd 100644
--- a/activemodel/test/cases/validations/presence_validation_test.rb
+++ b/activemodel/test/cases/validations/presence_validation_test.rb
@@ -18,14 +18,14 @@ def test_validate_presences
t = Topic.new
assert_predicate t, :invalid?
- assert_equal ["can’t be blank"], t.errors[:title]
- assert_equal ["can’t be blank"], t.errors[:content]
+ assert_equal ["can't be blank"], t.errors[:title]
+ assert_equal ["can't be blank"], t.errors[:content]
t.title = "something"
t.content = " "
assert_predicate t, :invalid?
- assert_equal ["can’t be blank"], t.errors[:content]
+ assert_equal ["can't be blank"], t.errors[:content]
t.content = "like stuff"
@@ -36,8 +36,8 @@ def test_accepts_array_arguments
Topic.validates_presence_of %w(title content)
t = Topic.new
assert_predicate t, :invalid?
- assert_equal ["can’t be blank"], t.errors[:title]
- assert_equal ["can’t be blank"], t.errors[:content]
+ assert_equal ["can't be blank"], t.errors[:title]
+ assert_equal ["can't be blank"], t.errors[:content]
end
def test_validates_acceptance_of_with_custom_error_using_quotes
@@ -53,7 +53,7 @@ def test_validates_presence_of_for_ruby_class
p = Person.new
assert_predicate p, :invalid?
- assert_equal ["can’t be blank"], p.errors[:karma]
+ assert_equal ["can't be blank"], p.errors[:karma]
p.karma = "Cold"
assert_predicate p, :valid?
@@ -65,7 +65,7 @@ def test_validates_presence_of_for_ruby_class_with_custom_reader
p = CustomReader.new
assert_predicate p, :invalid?
- assert_equal ["can’t be blank"], p.errors[:karma]
+ assert_equal ["can't be blank"], p.errors[:karma]
p[:karma] = "Cold"
assert_predicate p, :valid?
@@ -79,11 +79,11 @@ def test_validates_presence_of_with_allow_nil_option
t.title = ""
assert_predicate t, :invalid?
- assert_equal ["can’t be blank"], t.errors[:title]
+ assert_equal ["can't be blank"], t.errors[:title]
t.title = " "
assert_predicate t, :invalid?
- assert_equal ["can’t be blank"], t.errors[:title]
+ assert_equal ["can't be blank"], t.errors[:title]
t.title = nil
assert_predicate t, :valid?
diff --git a/activemodel/test/cases/validations/validates_test.rb b/activemodel/test/cases/validations/validates_test.rb
index d3734f2483a21..2ee3385814529 100644
--- a/activemodel/test/cases/validations/validates_test.rb
+++ b/activemodel/test/cases/validations/validates_test.rb
@@ -65,7 +65,7 @@ def test_validates_with_if_as_local_conditions
Person.validates :karma, presence: true, email: { if: :condition_is_false }
person = Person.new
person.valid?
- assert_equal ["can’t be blank"], person.errors[:karma]
+ assert_equal ["can't be blank"], person.errors[:karma]
end
def test_validates_with_if_as_shared_conditions
@@ -78,7 +78,7 @@ def test_validates_with_unless_as_local_conditions
Person.validates :karma, presence: true, email: { unless: :condition_is_true }
person = Person.new
person.valid?
- assert_equal ["can’t be blank"], person.errors[:karma]
+ assert_equal ["can't be blank"], person.errors[:karma]
end
def test_validates_with_unless_shared_conditions
diff --git a/activemodel/test/cases/validations_test.rb b/activemodel/test/cases/validations_test.rb
index 8ba32a9477c55..da839fae78872 100644
--- a/activemodel/test/cases/validations_test.rb
+++ b/activemodel/test/cases/validations_test.rb
@@ -64,8 +64,8 @@ def test_multiple_errors_per_attr_iteration_with_full_error_composition
def test_errors_on_nested_attributes_expands_name
t = Topic.new
- t.errors.add("replies.name", "can’t be blank")
- assert_equal ["Replies name can’t be blank"], t.errors.full_messages
+ t.errors.add("replies.name", "can't be blank")
+ assert_equal ["Replies name can't be blank"], t.errors.full_messages
end
def test_errors_on_base
@@ -213,8 +213,8 @@ def test_errors_to_json
assert_predicate t, :invalid?
hash = {}
- hash[:title] = ["can’t be blank"]
- hash[:content] = ["can’t be blank"]
+ hash[:title] = ["can't be blank"]
+ hash[:content] = ["can't be blank"]
assert_equal t.errors.to_json, hash.to_json
end
@@ -224,7 +224,7 @@ def test_validation_order
t = Topic.new("title" => "")
assert_predicate t, :invalid?
- assert_equal "can’t be blank", t.errors["title"].first
+ assert_equal "can't be blank", t.errors["title"].first
Topic.validates_presence_of :title, :author_name
Topic.validate { errors.add("author_email_address", "will never be valid") }
Topic.validates_length_of :title, :content, minimum: 2
@@ -233,10 +233,10 @@ def test_validation_order
assert_predicate t, :invalid?
assert_equal :title, key = t.errors.attribute_names[0]
- assert_equal "can’t be blank", t.errors[key][0]
+ assert_equal "can't be blank", t.errors[key][0]
assert_equal "is too short (minimum is 2 characters)", t.errors[key][1]
assert_equal :author_name, key = t.errors.attribute_names[1]
- assert_equal "can’t be blank", t.errors[key][0]
+ assert_equal "can't be blank", t.errors[key][0]
assert_equal :author_email_address, key = t.errors.attribute_names[2]
assert_equal "will never be valid", t.errors[key][0]
assert_equal :content, key = t.errors.attribute_names[3]
@@ -414,7 +414,7 @@ def test_strict_validation_error_message
exception = assert_raises(ActiveModel::StrictValidationFailed) do
Topic.new.valid?
end
- assert_equal "Title can’t be blank", exception.message
+ assert_equal "Title can't be blank", exception.message
end
def test_does_not_modify_options_argument
diff --git a/activemodel/test/models/helicopter.rb b/activemodel/test/models/helicopter.rb
index fe82c463d331a..d01954c58749c 100644
--- a/activemodel/test/models/helicopter.rb
+++ b/activemodel/test/models/helicopter.rb
@@ -7,3 +7,16 @@ class Helicopter
class Helicopter::Comanche
include ActiveModel::Conversion
end
+
+class Helicopter::Apache
+ include ActiveModel::Conversion
+
+ class << self
+ def model_name
+ @model_name ||= ActiveModel::Name.new(self).tap do |model_name|
+ model_name.collection = "attack_helicopters"
+ model_name.element = "ah-64"
+ end
+ end
+ end
+end
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index b7745caf5f16b..e54105e6580cb 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,2046 +1,2 @@
-* Allow batching methods to use already loaded relation if available
- Calling batch methods on already loaded relations will use the records previously loaded instead of retrieving
- them from the database again.
-
- *Adam Hess*
-
-* Deprecate `read_attribute(:id)` returning the primary key if the primary key is not `:id`.
-
- Starting in Rails 7.2, `read_attribute(:id)` will return the value of the id column, regardless of the model's
- primary key. To retrieve the value of the primary key, use `#id` instead. `read_attribute(:id)` for composite
- primary key models will now return the value of the id column.
-
- *Adrianna Chang*
-
-* Fix `change_table` setting datetime precision for 6.1 Migrations
-
- *Hartley McGuire*
-
-* Fix change_column setting datetime precision for 6.1 Migrations
-
- *Hartley McGuire*
-
-* Add `ActiveRecord::Base#id_value` alias to access the raw value of a record's id column.
-
- This alias is only provided for models that declare an `:id` column.
-
- *Adrianna Chang*
-
-* Fix previous change tracking for `ActiveRecord::Store` when using a column with JSON structured database type
-
- Before, the methods to access the changes made during the last save `#saved_change_to_key?`, `#saved_change_to_key`, and `#key_before_last_save` did not work if the store was defined as a `store_accessor` on a column with a JSON structured database type
-
- *Robert DiMartino*
-
-* Fully support `NULLS [NOT] DISTINCT` for PostgreSQL 15+ indexes.
-
- Previous work was done to allow the index to be created in a migration, but it was not
- supported in schema.rb. Additionally, the matching for `NULLS [NOT] DISTINCT` was not
- in the correct order, which could have resulted in inconsistent schema detection.
-
- *Gregory Jones*
-
-* Allow escaping of literal colon characters in `sanitize_sql_*` methods when named bind variables are used
-
- *Justin Bull*
-
-* Fix `#previously_new_record?` to return true for destroyed records.
-
- Before, if a record was created and then destroyed, `#previously_new_record?` would return true.
- Now, any UPDATE or DELETE to a record is considered a change, and will result in `#previously_new_record?`
- returning false.
-
- *Adrianna Chang*
-
-* Specify callback in `has_secure_token`
-
- ```ruby
- class User < ApplicationRecord
- has_secure_token on: :initialize
- end
-
- User.new.token # => "abc123...."
- ```
-
- *Sean Doyle*
-
-* Fix incrementation of in memory counter caches when associations overlap
-
- When two associations had a similarly named counter cache column, Active Record
- could sometime increment the wrong one.
-
- *Jacopo Beschi*, *Jean Boussier*
-
-* Don't show secrets for Active Record's `Cipher::Aes256Gcm#inspect`.
-
- Before:
-
- ```ruby
- ActiveRecord::Encryption::Cipher::Aes256Gcm.new(secret).inspect
- "#"
- ```
-
- After:
-
- ```ruby
- ActiveRecord::Encryption::Cipher::Aes256Gcm(secret).inspect
- "#"
- ```
-
- *Petrik de Heus*
-
-* Bring back the historical behavior of committing transaction on non-local return.
-
- ```ruby
- Model.transaction do
- model.save
- return
- other_model.save # not executed
- end
- ```
-
- Historically only raised errors would trigger a rollback, but in Ruby `2.3`, the `timeout` library
- started using `throw` to interrupt execution which had the adverse effect of committing open transactions.
-
- To solve this, in Active Record 6.1 the behavior was changed to instead rollback the transaction as it was safer
- than to potentially commit an incomplete transaction.
-
- Using `return`, `break` or `throw` inside a `transaction` block was essentially deprecated from Rails 6.1 onwards.
-
- However with the release of `timeout 0.4.0`, `Timeout.timeout` now raises an error again, and Active Record is able
- to return to its original, less surprising, behavior.
-
- This historical behavior can now be opt-ed in via:
-
- ```
- Rails.application.config.active_record.commit_transaction_on_non_local_return = true
- ```
-
- And is the default for new applications created in Rails 7.1.
-
- *Jean Boussier*
-
-* Deprecate `name` argument on `#remove_connection`.
-
- The `name` argument is deprecated on `#remove_connection` without replacement. `#remove_connection` should be called directly on the class that established the connection.
-
- *Eileen M. Uchitelle*
-
-* Fix has_one through singular building with inverse.
-
- Allows building of records from an association with a has_one through a
- singular association with inverse. For belongs_to through associations,
- linking the foreign key to the primary key model isn't needed.
- For has_one, we cannot build records due to the association not being mutable.
-
- *Gannon McGibbon*
-
-* Disable database prepared statements when query logs are enabled
-
- Prepared Statements and Query Logs are incompatible features due to query logs making every query unique.
-
- *zzak, Jean Boussier*
-
-* Support decrypting data encrypted non-deterministically with a SHA1 hash digest.
-
- This adds a new Active Record encryption option to support decrypting data encrypted
- non-deterministically with a SHA1 hash digest:
-
- ```
- Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true
- ```
-
- The new option addresses a problem when upgrading from 7.0 to 7.1. Due to a bug in how Active Record
- Encryption was getting initialized, the key provider used for non-deterministic encryption were using
- SHA-1 as its digest class, instead of the one configured globally by Rails via
- `Rails.application.config.active_support.key_generator_hash_digest_class`.
-
- *Cadu Ribeiro and Jorge Manrubia*
-
-* Added PostgreSQL migration commands for enum rename, add value, and rename value.
-
- `rename_enum` and `rename_enum_value` are reversible. Due to Postgres
- limitation, `add_enum_value` is not reversible since you cannot delete enum
- values. As an alternative you should drop and recreate the enum entirely.
-
- ```ruby
- rename_enum :article_status, to: :article_state
- ```
-
- ```ruby
- add_enum_value :article_state, "archived" # will be at the end of existing values
- add_enum_value :article_state, "in review", before: "published"
- add_enum_value :article_state, "approved", after: "in review"
- ```
-
- ```ruby
- rename_enum_value :article_state, from: "archived", to: "deleted"
- ```
-
- *Ray Faddis*
-
-* Allow composite primary key to be derived from schema
-
- Booting an application with a schema that contains composite primary keys
- will not issue warning and won't `nil`ify the `ActiveRecord::Base#primary_key` value anymore.
-
- Given a `travel_routes` table definition and a `TravelRoute` model like:
- ```ruby
- create_table :travel_routes, primary_key: [:origin, :destination], force: true do |t|
- t.string :origin
- t.string :destination
- end
-
- class TravelRoute < ActiveRecord::Base; end
- ```
- The `TravelRoute.primary_key` value will be automatically derived to `["origin", "destination"]`
-
- *Nikita Vasilevsky*
-
-* Include the `connection_pool` with exceptions raised from an adapter.
-
- The `connection_pool` provides added context such as the connection used
- that led to the exception as well as which role and shard.
-
- *Luan Vieira*
-
-* Support multiple column ordering for `find_each`, `find_in_batches` and `in_batches`.
-
- When find_each/find_in_batches/in_batches are performed on a table with composite primary keys, ascending or descending order can be selected for each key.
-
- ```ruby
- Person.find_each(order: [:desc, :asc]) do |person|
- person.party_all_night!
- end
- ```
-
- *Takuya Kurimoto*
-
-* Fix where on association with has_one/has_many polymorphic relations.
-
- Before:
- ```ruby
- Treasure.where(price_estimates: PriceEstimate.all)
- #=> SELECT (...) WHERE "treasures"."id" IN (SELECT "price_estimates"."estimate_of_id" FROM "price_estimates")
- ```
-
- Later:
- ```ruby
- Treasure.where(price_estimates: PriceEstimate.all)
- #=> SELECT (...) WHERE "treasures"."id" IN (SELECT "price_estimates"."estimate_of_id" FROM "price_estimates" WHERE "price_estimates"."estimate_of_type" = 'Treasure')
- ```
-
- *Lázaro Nixon*
-
-* Assign auto populated columns on Active Record record creation.
-
- Changes record creation logic to allow for the `auto_increment` column to be assigned
- immediately after creation regardless of it's relation to the model's primary key.
-
- The PostgreSQL adapter benefits the most from the change allowing for any number of auto-populated
- columns to be assigned on the object immediately after row insertion utilizing the `RETURNING` statement.
-
- *Nikita Vasilevsky*
-
-* Use the first key in the `shards` hash from `connected_to` for the `default_shard`.
-
- Some applications may not want to use `:default` as a shard name in their connection model. Unfortunately Active Record expects there to be a `:default` shard because it must assume a shard to get the right connection from the pool manager. Rather than force applications to manually set this, `connects_to` can infer the default shard name from the hash of shards and will now assume that the first shard is your default.
-
- For example if your model looked like this:
-
- ```ruby
- class ShardRecord < ApplicationRecord
- self.abstract_class = true
-
- connects_to shards: {
- shard_one: { writing: :shard_one },
- shard_two: { writing: :shard_two }
- }
- ```
-
- Then the `default_shard` for this class would be set to `shard_one`.
-
- Fixes: #45390
-
- *Eileen M. Uchitelle*
-
-* Fix mutation detection for serialized attributes backed by binary columns.
-
- *Jean Boussier*
-
-* Add `ActiveRecord.disconnect_all!` method to immediately close all connections from all pools.
-
- *Jean Boussier*
-
-* Discard connections which may have been left in a transaction.
-
- There are cases where, due to an error, `within_new_transaction` may unexpectedly leave a connection in an open transaction. In these cases the connection may be reused, and the following may occur:
- - Writes appear to fail when they actually succeed.
- - Writes appear to succeed when they actually fail.
- - Reads return stale or uncommitted data.
-
- Previously, the following case was detected:
- - An error is encountered during the transaction, then another error is encountered while attempting to roll it back.
-
- Now, the following additional cases are detected:
- - An error is encountered just after successfully beginning a transaction.
- - An error is encountered while committing a transaction, then another error is encountered while attempting to roll it back.
- - An error is encountered while rolling back a transaction.
-
- *Nick Dower*
-
-* Active Record query cache now evicts least recently used entries
-
- By default it only keeps the `100` most recently used queries.
-
- The cache size can be configured via `database.yml`
-
- ```yaml
- development:
- adapter: mysql2
- query_cache: 200
- ```
-
- It can also be entirely disabled:
-
- ```yaml
- development:
- adapter: mysql2
- query_cache: false
- ```
-
- *Jean Boussier*
-
-* Deprecate `check_pending!` in favor of `check_all_pending!`.
-
- `check_pending!` will only check for pending migrations on the current database connection or the one passed in. This has been deprecated in favor of `check_all_pending!` which will find all pending migrations for the database configurations in a given environment.
-
- *Eileen M. Uchitelle*
-
-* Make `increment_counter`/`decrement_counter` accept an amount argument
-
- ```ruby
- Post.increment_counter(:comments_count, 5, by: 3)
- ```
-
- *fatkodima*
-
-* Add support for `Array#intersect?` to `ActiveRecord::Relation`.
-
- `Array#intersect?` is only available on Ruby 3.1 or later.
-
- This allows the Rubocop `Style/ArrayIntersect` cop to work with `ActiveRecord::Relation` objects.
-
- *John Harry Kelly*
-
-* The deferrable foreign key can be passed to `t.references`.
-
- *Hiroyuki Ishii*
-
-* Deprecate `deferrable: true` option of `add_foreign_key`.
-
- `deferrable: true` is deprecated in favor of `deferrable: :immediate`, and
- will be removed in Rails 7.2.
-
- Because `deferrable: true` and `deferrable: :deferred` are hard to understand.
- Both true and :deferred are truthy values.
- This behavior is the same as the deferrable option of the add_unique_key method, added in #46192.
-
- *Hiroyuki Ishii*
-
-* `AbstractAdapter#execute` and `#exec_query` now clear the query cache
-
- If you need to perform a read only SQL query without clearing the query
- cache, use `AbstractAdapter#select_all`.
-
- *Jean Boussier*
-
-* Make `.joins` / `.left_outer_joins` work with CTEs.
-
- For example:
-
- ```ruby
- Post
- .with(commented_posts: Comment.select(:post_id).distinct)
- .joins(:commented_posts)
- #=> WITH (...) SELECT ... INNER JOIN commented_posts on posts.id = commented_posts.post_id
- ```
-
- *Vladimir Dementyev*
-
-* Add a load hook for `ActiveRecord::ConnectionAdapters::Mysql2Adapter`
- (named `active_record_mysql2adapter`) to allow for overriding aspects of the
- `ActiveRecord::ConnectionAdapters::Mysql2Adapter` class. This makes `Mysql2Adapter`
- consistent with `PostgreSQLAdapter` and `SQLite3Adapter` that already have load hooks.
-
- *fatkodima*
-
-* Introduce adapter for Trilogy database client
-
- Trilogy is a MySQL-compatible database client. Rails applications can use Trilogy
- by configuring their `config/database.yml`:
-
- ```yaml
- development:
- adapter: trilogy
- database: blog_development
- pool: 5
- ```
-
- Or by using the `DATABASE_URL` environment variable:
-
- ```ruby
- ENV['DATABASE_URL'] # => "trilogy://localhost/blog_development?pool=5"
- ```
-
- *Adrianna Chang*
-
-* `after_commit` callbacks defined on models now execute in the correct order.
-
- ```ruby
- class User < ActiveRecord::Base
- after_commit { puts("this gets called first") }
- after_commit { puts("this gets called second") }
- end
- ```
-
- Previously, the callbacks executed in the reverse order. To opt in to the new behaviour:
-
- ```ruby
- config.active_record.run_after_transaction_callbacks_in_order_defined = true
- ```
-
- This is the default for new apps.
-
- *Alex Ghiculescu*
-
-* Infer `foreign_key` when `inverse_of` is present on `has_one` and `has_many` associations.
-
- ```ruby
- has_many :citations, foreign_key: "book1_id", inverse_of: :book
- ```
-
- can be simplified to
-
- ```ruby
- has_many :citations, inverse_of: :book
- ```
-
- and the foreign_key will be read from the corresponding `belongs_to` association.
-
- *Daniel Whitney*
-
-* Limit max length of auto generated index names
-
- Auto generated index names are now limited to 62 bytes, which fits within
- the default index name length limits for MySQL, Postgres and SQLite.
-
- Any index name over the limit will fallback to the new short format.
-
- Before (too long):
- ```
- index_testings_on_foo_and_bar_and_first_name_and_last_name_and_administrator
- ```
-
- After (short format):
- ```
- idx_on_foo_bar_first_name_last_name_administrator_5939248142
- ```
-
- The short format includes a hash to ensure the name is unique database-wide.
-
- *Mike Coutermarsh*
-
-* Introduce a more stable and optimized Marshal serializer for Active Record models.
-
- Can be enabled with `config.active_record.marshalling_format_version = 7.1`.
-
- *Jean Boussier*
-
-* Allow specifying where clauses with column-tuple syntax.
-
- Querying through `#where` now accepts a new tuple-syntax which accepts, as
- a key, an array of columns and, as a value, an array of corresponding tuples.
- The key specifies a list of columns, while the value is an array of
- ordered-tuples that conform to the column list.
-
- For instance:
-
- ```ruby
- # Cpk::Book => Cpk::Book(author_id: integer, number: integer, title: string, revision: integer)
- # Cpk::Book.primary_key => ["author_id", "number"]
-
- book = Cpk::Book.create!(author_id: 1, number: 1)
- Cpk::Book.where(Cpk::Book.primary_key => [[1, 2]]) # => [book]
-
- # Topic => Topic(id: integer, title: string, author_name: string...)
-
- Topic.where([:title, :author_name] => [["The Alchemist", "Paul Coelho"], ["Harry Potter", "J.K Rowling"]])
- ```
-
- *Paarth Madan*
-
-* Allow warning codes to be ignore when reporting SQL warnings.
-
- Active Record config that can ignore warning codes
-
- ```ruby
- # Configure allowlist of warnings that should always be ignored
- config.active_record.db_warnings_ignore = [
- "1062", # MySQL Error 1062: Duplicate entry
- ]
- ```
-
- This is supported for the MySQL and PostgreSQL adapters.
-
- *Nick Borromeo*
-
-* Introduce `:active_record_fixtures` lazy load hook.
-
- Hooks defined with this name will be run whenever `TestFixtures` is included
- in a class.
-
- ```ruby
- ActiveSupport.on_load(:active_record_fixtures) do
- self.fixture_paths << "test/fixtures"
- end
-
- klass = Class.new
- klass.include(ActiveRecord::TestFixtures)
-
- klass.fixture_paths # => ["test/fixtures"]
- ```
-
- *Andrew Novoselac*
-
-* Introduce `TestFixtures#fixture_paths`.
-
- Multiple fixture paths can now be specified using the `#fixture_paths` accessor.
- Apps will continue to have `test/fixtures` as their one fixture path by default,
- but additional fixture paths can be specified.
-
- ```ruby
- ActiveSupport::TestCase.fixture_paths << "component1/test/fixtures"
- ActiveSupport::TestCase.fixture_paths << "component2/test/fixtures"
- ```
-
- `TestFixtures#fixture_path` is now deprecated.
-
- *Andrew Novoselac*
-
-* Adds support for deferrable exclude constraints in PostgreSQL.
-
- By default, exclude constraints in PostgreSQL are checked after each statement.
- This works for most use cases, but becomes a major limitation when replacing
- records with overlapping ranges by using multiple statements.
-
- ```ruby
- exclusion_constraint :users, "daterange(valid_from, valid_to) WITH &&", deferrable: :immediate
- ```
-
- Passing `deferrable: :immediate` checks constraint after each statement,
- but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED`
- within a transaction. This will cause the excludes to be checked after the transaction.
-
- It's also possible to change the default behavior from an immediate check
- (after the statement), to a deferred check (after the transaction):
-
- ```ruby
- exclusion_constraint :users, "daterange(valid_from, valid_to) WITH &&", deferrable: :deferred
- ```
-
- *Hiroyuki Ishii*
-
-* Respect `foreign_type` option to `delegated_type` for `{role}_class` method.
-
- Usage of `delegated_type` with non-conventional `{role}_type` column names can now be specified with `foreign_type` option.
- This option is the same as `foreign_type` as forwarded to the underlying `belongs_to` association that `delegated_type` wraps.
-
- *Jason Karns*
-
-* Add support for unique constraints (PostgreSQL-only).
-
- ```ruby
- add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_section_position"
- remove_unique_key :sections, name: "unique_section_position"
- ```
-
- See PostgreSQL's [Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS) documentation for more on unique constraints.
-
- By default, unique constraints in PostgreSQL are checked after each statement.
- This works for most use cases, but becomes a major limitation when replacing
- records with unique column by using multiple statements.
-
- An example of swapping unique columns between records.
-
- ```ruby
- # position is unique column
- old_item = Item.create!(position: 1)
- new_item = Item.create!(position: 2)
-
- Item.transaction do
- old_item.update!(position: 2)
- new_item.update!(position: 1)
- end
- ```
-
- Using the default behavior, the transaction would fail when executing the
- first `UPDATE` statement.
-
- By passing the `:deferrable` option to the `add_unique_key` statement in
- migrations, it's possible to defer this check.
-
- ```ruby
- add_unique_key :items, [:position], deferrable: :immediate
- ```
-
- Passing `deferrable: :immediate` does not change the behaviour of the previous example,
- but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction.
- This will cause the unique constraints to be checked after the transaction.
-
- It's also possible to adjust the default behavior from an immediate
- check (after the statement), to a deferred check (after the transaction):
-
- ```ruby
- add_unique_key :items, [:position], deferrable: :deferred
- ```
-
- If you want to change an existing unique index to deferrable, you can use :using_index
- to create deferrable unique constraints.
-
- ```ruby
- add_unique_key :items, deferrable: :deferred, using_index: "index_items_on_position"
- ```
-
- *Hiroyuki Ishii*
-
-* Remove deprecated `Tasks::DatabaseTasks.schema_file_type`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated `config.active_record.partial_writes`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated `ActiveRecord::Base` config accessors.
-
- *Rafael Mendonça França*
-
-* Remove the `:include_replicas` argument from `configs_for`. Use `:include_hidden` argument instead.
-
- *Eileen M. Uchitelle*
-
-* Allow applications to lookup a config via a custom hash key.
-
- If you have registered a custom config or want to find configs where the hash matches a specific key, now you can pass `config_key` to `configs_for`. For example if you have a `db_config` with the key `vitess` you can look up a database configuration hash by matching that key.
-
- ```ruby
- ActiveRecord::Base.configurations.configs_for(env_name: "development", name: "primary", config_key: :vitess)
- ActiveRecord::Base.configurations.configs_for(env_name: "development", config_key: :vitess)
- ```
-
- *Eileen M. Uchitelle*
-
-* Allow applications to register a custom database configuration handler.
-
- Adds a mechanism for registering a custom handler for cases where you want database configurations to respond to custom methods. This is useful for non-Rails database adapters or tools like Vitess that you may want to configure differently from a standard `HashConfig` or `UrlConfig`.
-
- Given the following database YAML we want the `animals` db to create a `CustomConfig` object instead while the `primary` database will be a `UrlConfig`:
-
- ```yaml
- development:
- primary:
- url: postgres://localhost/primary
- animals:
- url: postgres://localhost/animals
- custom_config:
- sharded: 1
- ```
-
- To register a custom handler first make a class that has your custom methods:
-
- ```ruby
- class CustomConfig < ActiveRecord::DatabaseConfigurations::UrlConfig
- def sharded?
- custom_config.fetch("sharded", false)
- end
-
- private
- def custom_config
- configuration_hash.fetch(:custom_config)
- end
- end
- ```
-
- Then register the config in an initializer:
-
- ```ruby
- ActiveRecord::DatabaseConfigurations.register_db_config_handler do |env_name, name, url, config|
- next unless config.key?(:custom_config)
- CustomConfig.new(env_name, name, url, config)
- end
- ```
-
- When the application is booted, configuration hashes with the `:custom_config` key will be `CustomConfig` objects and respond to `sharded?`. Applications must handle the condition in which Active Record should use their custom handler.
-
- *Eileen M. Uchitelle and John Crepezzi*
-
-* `ActiveRecord::Base.serialize` no longer uses YAML by default.
-
- YAML isn't particularly performant and can lead to security issues
- if not used carefully.
-
- Unfortunately there isn't really any good serializers in Ruby's stdlib
- to replace it.
-
- The obvious choice would be JSON, which is a fine format for this use case,
- however the JSON serializer in Ruby's stdlib isn't strict enough, as it fallback
- to casting unknown types to strings, which could lead to corrupted data.
-
- Some third party JSON libraries like `Oj` have a suitable strict mode.
-
- So it's preferable that users choose a serializer based on their own constraints.
-
- The original default can be restored by setting `config.active_record.default_column_serializer = YAML`.
-
- *Jean Boussier*
-
-* `ActiveRecord::Base.serialize` signature changed.
-
- Rather than a single positional argument that accepts two possible
- types of values, `serialize` now accepts two distinct keyword arguments.
-
- Before:
-
- ```ruby
- serialize :content, JSON
- serialize :backtrace, Array
- ```
-
- After:
-
- ```ruby
- serialize :content, coder: JSON
- serialize :backtrace, type: Array
- ```
-
- *Jean Boussier*
-
-* YAML columns use `YAML.safe_dump` is available.
-
- As of `psych 5.1.0`, `YAML.safe_dump` can now apply the same permitted
- types restrictions than `YAML.safe_load`.
-
- It's preferable to ensure the payload only use allowed types when we first
- try to serialize it, otherwise you may end up with invalid records in the
- database.
-
- *Jean Boussier*
-
-* `ActiveRecord::QueryLogs` better handle broken encoding.
-
- It's not uncommon when building queries with BLOB fields to contain
- binary data. Unless the call carefully encode the string in ASCII-8BIT
- it generally end up being encoded in `UTF-8`, and `QueryLogs` would
- end up failing on it.
-
- `ActiveRecord::QueryLogs` no longer depend on the query to be properly encoded.
-
- *Jean Boussier*
-
-* Fix a bug where `ActiveRecord::Generators::ModelGenerator` would not respect create_table_migration template overrides.
-
- ```
- rails g model create_books title:string content:text
- ```
- will now read from the create_table_migration.rb.tt template in the following locations in order:
- ```
- lib/templates/active_record/model/create_table_migration.rb
- lib/templates/active_record/migration/create_table_migration.rb
- ```
-
- *Spencer Neste*
-
-* `ActiveRecord::Relation#explain` now accepts options.
-
- For databases and adapters which support them (currently PostgreSQL
- and MySQL), options can be passed to `explain` to provide more
- detailed query plan analysis:
-
- ```ruby
- Customer.where(id: 1).joins(:orders).explain(:analyze, :verbose)
- ```
-
- *Reid Lynch*
-
-* Multiple `Arel::Nodes::SqlLiteral` nodes can now be added together to
- form `Arel::Nodes::Fragments` nodes. This allows joining several pieces
- of SQL.
-
- *Matthew Draper*, *Ole Friis*
-
-* `ActiveRecord::Base#signed_id` raises if called on a new record.
-
- Previously it would return an ID that was not usable, since it was based on `id = nil`.
-
- *Alex Ghiculescu*
-
-* Allow SQL warnings to be reported.
-
- Active Record configs can be set to enable SQL warning reporting.
-
- ```ruby
- # Configure action to take when SQL query produces warning
- config.active_record.db_warnings_action = :raise
-
- # Configure allowlist of warnings that should always be ignored
- config.active_record.db_warnings_ignore = [
- /Invalid utf8mb4 character string/,
- "An exact warning message",
- ]
- ```
-
- This is supported for the MySQL and PostgreSQL adapters.
-
- *Adrianna Chang*, *Paarth Madan*
-
-* Add `#regroup` query method as a short-hand for `.unscope(:group).group(fields)`
-
- Example:
-
- ```ruby
- Post.group(:title).regroup(:author)
- # SELECT `posts`.`*` FROM `posts` GROUP BY `posts`.`author`
- ```
-
- *Danielius Visockas*
-
-* PostgreSQL adapter method `enable_extension` now allows parameter to be `[schema_name.]`
- if the extension must be installed on another schema.
-
- Example: `enable_extension('heroku_ext.hstore')`
-
- *Leonardo Luarte*
-
-* Add `:include` option to `add_index`.
-
- Add support for including non-key columns in indexes for PostgreSQL
- with the `INCLUDE` parameter.
-
- ```ruby
- add_index(:users, :email, include: [:id, :created_at])
- ```
-
- will result in:
-
- ```sql
- CREATE INDEX index_users_on_email USING btree (email) INCLUDE (id, created_at)
- ```
-
- *Steve Abrams*
-
-* `ActiveRecord::Relation`’s `#any?`, `#none?`, and `#one?` methods take an optional pattern
- argument, more closely matching their `Enumerable` equivalents.
-
- *George Claghorn*
-
-* Add `ActiveRecord::Base::normalizes` to declare attribute normalizations.
-
- A normalization is applied when the attribute is assigned or updated, and
- the normalized value will be persisted to the database. The normalization
- is also applied to the corresponding keyword argument of finder methods.
- This allows a record to be created and later queried using unnormalized
- values. For example:
-
- ```ruby
- class User < ActiveRecord::Base
- normalizes :email, with: -> email { email.strip.downcase }
- end
-
- user = User.create(email: " CRUISE-CONTROL@EXAMPLE.COM\n")
- user.email # => "cruise-control@example.com"
-
- user = User.find_by(email: "\tCRUISE-CONTROL@EXAMPLE.COM ")
- user.email # => "cruise-control@example.com"
- user.email_before_type_cast # => "cruise-control@example.com"
-
- User.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
- User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
- ```
-
- *Jonathan Hefner*
-
-* Hide changes to before_committed! callback behaviour behind flag.
-
- In #46525, behavior around before_committed! callbacks was changed so that callbacks
- would run on every enrolled record in a transaction, not just the first copy of a record.
- This change in behavior is now controlled by a configuration option,
- `config.active_record.before_committed_on_all_records`. It will be enabled by default on Rails 7.1.
-
- *Adrianna Chang*
-
-* The `namespaced_controller` Query Log tag now matches the `controller` format
-
- For example, a request processed by `NameSpaced::UsersController` will now log as:
-
- ```
- :controller # "users"
- :namespaced_controller # "name_spaced/users"
- ```
-
- *Alex Ghiculescu*
-
-* Return only unique ids from ActiveRecord::Calculations#ids
-
- Updated ActiveRecord::Calculations#ids to only return the unique ids of the base model
- when using eager_load, preload and includes.
-
- ```ruby
- Post.find_by(id: 1).comments.count
- # => 5
- Post.includes(:comments).where(id: 1).pluck(:id)
- # => [1, 1, 1, 1, 1]
- Post.includes(:comments).where(id: 1).ids
- # => [1]
- ```
-
- *Joshua Young*
-
-* Stop using `LOWER()` for case-insensitive queries on `citext` columns
-
- Previously, `LOWER()` was added for e.g. uniqueness validations with
- `case_sensitive: false`.
- It wasn't mentioned in the documentation that the index without `LOWER()`
- wouldn't be used in this case.
-
- *Phil Pirozhkov*
-
-* Extract `#sync_timezone_changes` method in AbstractMysqlAdapter to enable subclasses
- to sync database timezone changes without overriding `#raw_execute`.
-
- *Adrianna Chang*, *Paarth Madan*
-
-* Do not write additional new lines when dumping sql migration versions
-
- This change updates the `insert_versions_sql` function so that the database insert string containing the current database migration versions does not end with two additional new lines.
-
- *Misha Schwartz*
-
-* Fix `composed_of` value freezing and duplication.
-
- Previously composite values exhibited two confusing behaviors:
-
- - When reading a compositve value it'd _NOT_ be frozen, allowing it to get out of sync with its underlying database
- columns.
- - When writing a compositve value the argument would be frozen, potentially confusing the caller.
-
- Currently, composite values instantiated based on database columns are frozen (addressing the first issue) and
- assigned compositve values are duplicated and the duplicate is frozen (addressing the second issue).
-
- *Greg Navis*
-
-* Fix redundant updates to the column insensitivity cache
-
- Fixed redundant queries checking column capability for insensitive
- comparison.
-
- *Phil Pirozhkov*
-
-* Allow disabling methods generated by `ActiveRecord.enum`.
-
- *Alfred Dominic*
-
-* Avoid validating `belongs_to` association if it has not changed.
-
- Previously, when updating a record, Active Record will perform an extra query to check for the presence of
- `belongs_to` associations (if the presence is configured to be mandatory), even if that attribute hasn't changed.
-
- Currently, only `belongs_to`-related columns are checked for presence. It is possible to have orphaned records with
- this approach. To avoid this problem, you need to use a foreign key.
-
- This behavior can be controlled by configuration:
-
- ```ruby
- config.active_record.belongs_to_required_validates_foreign_key = false
- ```
-
- and will be disabled by default with `config.load_defaults 7.1`.
-
- *fatkodima*
-
-* `has_one` and `belongs_to` associations now define a `reset_association` method
- on the owner model (where `association` is the name of the association). This
- method unloads the cached associate record, if any, and causes the next access
- to query it from the database.
-
- *George Claghorn*
-
-* Allow per attribute setting of YAML permitted classes (safe load) and unsafe load.
-
- *Carlos Palhares*
-
-* Add a build persistence method
-
- Provides a wrapper for `new`, to provide feature parity with `create`s
- ability to create multiple records from an array of hashes, using the
- same notation as the `build` method on associations.
-
- *Sean Denny*
-
-* Raise on assignment to readonly attributes
-
- ```ruby
- class Post < ActiveRecord::Base
- attr_readonly :content
- end
- Post.create!(content: "cannot be updated")
- post.content # "cannot be updated"
- post.content = "something else" # => ActiveRecord::ReadonlyAttributeError
- ```
-
- Previously, assignment would succeed but silently not write to the database.
-
- This behavior can be controlled by configuration:
-
- ```ruby
- config.active_record.raise_on_assign_to_attr_readonly = true
- ```
-
- and will be enabled by default with `config.load_defaults 7.1`.
-
- *Alex Ghiculescu*, *Hartley McGuire*
-
-* Allow unscoping of preload and eager_load associations
-
- Added the ability to unscope preload and eager_load associations just like
- includes, joins, etc. See ActiveRecord::QueryMethods::VALID_UNSCOPING_VALUES
- for the full list of supported unscopable scopes.
-
- ```ruby
- query.unscope(:eager_load, :preload).group(:id).select(:id)
- ```
-
- *David Morehouse*
-
-* Add automatic filtering of encrypted attributes on inspect
-
- This feature is enabled by default but can be disabled with
-
- ```ruby
- config.active_record.encryption.add_to_filter_parameters = false
- ```
-
- *Hartley McGuire*
-
-* Clear locking column on #dup
-
- This change fixes not to duplicate locking_column like id and timestamps.
-
- ```
- car = Car.create!
- car.touch
- car.lock_version #=> 1
- car.dup.lock_version #=> 0
- ```
-
- *Shouichi Kamiya*, *Seonggi Yang*, *Ryohei UEDA*
-
-* Invalidate transaction as early as possible
-
- After rescuing a `TransactionRollbackError` exception Rails invalidates transactions earlier in the flow
- allowing the framework to skip issuing the `ROLLBACK` statement in more cases.
- Only affects adapters that have `savepoint_errors_invalidate_transactions?` configured as `true`,
- which at this point is only applicable to the `mysql2` adapter.
-
- *Nikita Vasilevsky*
-
-* Allow configuring columns list to be used in SQL queries issued by an `ActiveRecord::Base` object
-
- It is now possible to configure columns list that will be used to build an SQL query clauses when
- updating, deleting or reloading an `ActiveRecord::Base` object
-
- ```ruby
- class Developer < ActiveRecord::Base
- query_constraints :company_id, :id
- end
- developer = Developer.first.update(name: "Bob")
- # => UPDATE "developers" SET "name" = 'Bob' WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
- ```
-
- *Nikita Vasilevsky*
-
-* Adds `validate` to foreign keys and check constraints in schema.rb
-
- Previously, `schema.rb` would not record if `validate: false` had been used when adding a foreign key or check
- constraint, so restoring a database from the schema could result in foreign keys or check constraints being
- incorrectly validated.
-
- *Tommy Graves*
-
-* Adapter `#execute` methods now accept an `allow_retry` option. When set to `true`, the SQL statement will be
- retried, up to the database's configured `connection_retries` value, upon encountering connection-related errors.
-
- *Adrianna Chang*
-
-* Only trigger `after_commit :destroy` callbacks when a database row is deleted.
-
- This prevents `after_commit :destroy` callbacks from being triggered again
- when `destroy` is called multiple times on the same record.
-
- *Ben Sheldon*
-
-* Fix `ciphertext_for` for yet-to-be-encrypted values.
-
- Previously, `ciphertext_for` returned the cleartext of values that had not
- yet been encrypted, such as with an unpersisted record:
-
- ```ruby
- Post.encrypts :body
-
- post = Post.create!(body: "Hello")
- post.ciphertext_for(:body)
- # => "{\"p\":\"abc..."
-
- post.body = "World"
- post.ciphertext_for(:body)
- # => "World"
- ```
-
- Now, `ciphertext_for` will always return the ciphertext of encrypted
- attributes:
-
- ```ruby
- Post.encrypts :body
-
- post = Post.create!(body: "Hello")
- post.ciphertext_for(:body)
- # => "{\"p\":\"abc..."
-
- post.body = "World"
- post.ciphertext_for(:body)
- # => "{\"p\":\"xyz..."
- ```
-
- *Jonathan Hefner*
-
-* Fix a bug where using groups and counts with long table names would return incorrect results.
-
- *Shota Toguchi*, *Yusaku Ono*
-
-* Fix encryption of column default values.
-
- Previously, encrypted attributes that used column default values appeared to
- be encrypted on create, but were not:
-
- ```ruby
- Book.encrypts :name
-
- book = Book.create!
- book.name
- # => ""
- book.name_before_type_cast
- # => "{\"p\":\"abc..."
- book.reload.name_before_type_cast
- # => ""
- ```
-
- Now, attributes with column default values are encrypted:
-
- ```ruby
- Book.encrypts :name
-
- book = Book.create!
- book.name
- # => ""
- book.name_before_type_cast
- # => "{\"p\":\"abc..."
- book.reload.name_before_type_cast
- # => "{\"p\":\"abc..."
- ```
-
- *Jonathan Hefner*
-
-* Deprecate delegation from `Base` to `connection_handler`.
-
- Calling `Base.clear_all_connections!`, `Base.clear_active_connections!`, `Base.clear_reloadable_connections!` and `Base.flush_idle_connections!` is deprecated. Please call these methods on the connection handler directly. In future Rails versions, the delegation from `Base` to the `connection_handler` will be removed.
-
- *Eileen M. Uchitelle*
-
-* Allow ActiveRecord::QueryMethods#reselect to receive hash values, similar to ActiveRecord::QueryMethods#select
-
- *Sampat Badhe*
-
-* Validate options when managing columns and tables in migrations.
-
- If an invalid option is passed to a migration method like `create_table` and `add_column`, an error will be raised
- instead of the option being silently ignored. Validation of the options will only be applied for new migrations
- that are created.
-
- *Guo Xiang Tan*, *George Wambold*
-
-* Update query log tags to use the [SQLCommenter](https://open-telemetry.github.io/opentelemetry-sqlcommenter/) format by default. See [#46179](https://github.com/rails/rails/issues/46179)
-
- To opt out of SQLCommenter-formatted query log tags, set `config.active_record.query_log_tags_format = :legacy`. By default, this is set to `:sqlcommenter`.
-
- *Modulitos* and *Iheanyi*
-
-* Allow any ERB in the database.yml when creating rake tasks.
-
- Any ERB can be used in `database.yml` even if it accesses environment
- configurations.
-
- Deprecates `config.active_record.suppress_multiple_database_warning`.
-
- *Eike Send*
-
-* Add table to error for duplicate column definitions.
-
- If a migration defines duplicate columns for a table, the error message
- shows which table it concerns.
-
- *Petrik de Heus*
-
-* Fix erroneous nil default precision on virtual datetime columns.
-
- Prior to this change, virtual datetime columns did not have the same
- default precision as regular datetime columns, resulting in the following
- being erroneously equivalent:
-
- t.virtual :name, type: datetime, as: "expression"
- t.virtual :name, type: datetime, precision: nil, as: "expression"
-
- This change fixes the default precision lookup, so virtual and regular
- datetime column default precisions match.
-
- *Sam Bostock*
-
-* Use connection from `#with_raw_connection` in `#quote_string`.
-
- This ensures that the string quoting is wrapped in the reconnect and retry logic
- that `#with_raw_connection` offers.
-
- *Adrianna Chang*
-
-* Add `expires_in` option to `signed_id`.
-
- *Shouichi Kamiya*
-
-* Allow applications to set retry deadline for query retries.
-
- Building on the work done in #44576 and #44591, we extend the logic that automatically
- reconnects database connections to take into account a timeout limit. We won't retry
- a query if a given amount of time has elapsed since the query was first attempted. This
- value defaults to nil, meaning that all retryable queries are retried regardless of time elapsed,
- but this can be changed via the `retry_deadline` option in the database config.
-
- *Adrianna Chang*
-
-* Fix a case where the query cache can return wrong values. See #46044
-
- *Aaron Patterson*
-
-* Support MySQL's ssl-mode option for MySQLDatabaseTasks.
-
- Verifying the identity of the database server requires setting the ssl-mode
- option to VERIFY_CA or VERIFY_IDENTITY. This option was previously ignored
- for MySQL database tasks like creating a database and dumping the structure.
-
- *Petrik de Heus*
-
-* Move `ActiveRecord::InternalMetadata` to an independent object.
-
- `ActiveRecord::InternalMetadata` no longer inherits from `ActiveRecord::Base` and is now an independent object that should be instantiated with a `connection`. This class is private and should not be used by applications directly. If you want to interact with the schema migrations table, please access it on the connection directly, for example: `ActiveRecord::Base.connection.schema_migration`.
-
- *Eileen M. Uchitelle*
-
-* Deprecate quoting `ActiveSupport::Duration` as an integer
-
- Using ActiveSupport::Duration as an interpolated bind parameter in a SQL
- string template is deprecated. To avoid this warning, you should explicitly
- convert the duration to a more specific database type. For example, if you
- want to use a duration as an integer number of seconds:
- ```
- Record.where("duration = ?", 1.hour.to_i)
- ```
- If you want to use a duration as an ISO 8601 string:
- ```
- Record.where("duration = ?", 1.hour.iso8601)
- ```
-
- *Aram Greenman*
-
-* Allow `QueryMethods#in_order_of` to order by a string column name.
-
- ```ruby
- Post.in_order_of("id", [4,2,3,1]).to_a
- Post.joins(:author).in_order_of("authors.name", ["Bob", "Anna", "John"]).to_a
- ```
-
- *Igor Kasyanchuk*
-
-* Move `ActiveRecord::SchemaMigration` to an independent object.
-
- `ActiveRecord::SchemaMigration` no longer inherits from `ActiveRecord::Base` and is now an independent object that should be instantiated with a `connection`. This class is private and should not be used by applications directly. If you want to interact with the schema migrations table, please access it on the connection directly, for example: `ActiveRecord::Base.connection.schema_migration`.
-
- *Eileen M. Uchitelle*
-
-* Deprecate `all_connection_pools` and make `connection_pool_list` more explicit.
-
- Following on #45924 `all_connection_pools` is now deprecated. `connection_pool_list` will either take an explicit role or applications can opt into the new behavior by passing `:all`.
-
- *Eileen M. Uchitelle*
-
-* Fix connection handler methods to operate on all pools.
-
- `active_connections?`, `clear_active_connections!`, `clear_reloadable_connections!`, `clear_all_connections!`, and `flush_idle_connections!` now operate on all pools by default. Previously they would default to using the `current_role` or `:writing` role unless specified.
-
- *Eileen M. Uchitelle*
-
-
-* Allow ActiveRecord::QueryMethods#select to receive hash values.
-
- Currently, `select` might receive only raw sql and symbols to define columns and aliases to select.
-
- With this change we can provide `hash` as argument, for example:
-
- ```ruby
- Post.joins(:comments).select(posts: [:id, :title, :created_at], comments: [:id, :body, :author_id])
- #=> "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"created_at\", \"comments\".\"id\", \"comments\".\"body\", \"comments\".\"author_id\"
- # FROM \"posts\" INNER JOIN \"comments\" ON \"comments\".\"post_id\" = \"posts\".\"id\""
-
- Post.joins(:comments).select(posts: { id: :post_id, title: :post_title }, comments: { id: :comment_id, body: :comment_body })
- #=> "SELECT posts.id as post_id, posts.title as post_title, comments.id as comment_id, comments.body as comment_body
- # FROM \"posts\" INNER JOIN \"comments\" ON \"comments\".\"post_id\" = \"posts\".\"id\""
- ```
- *Oleksandr Holubenko*, *Josef Šimánek*, *Jean Boussier*
-
-* Adapts virtual attributes on `ActiveRecord::Persistence#becomes`.
-
- When source and target classes have a different set of attributes adapts
- attributes such that the extra attributes from target are added.
-
- ```ruby
- class Person < ApplicationRecord
- end
-
- class WebUser < Person
- attribute :is_admin, :boolean
- after_initialize :set_admin
-
- def set_admin
- write_attribute(:is_admin, email =~ /@ourcompany\.com$/)
- end
- end
-
- person = Person.find_by(email: "email@ourcompany.com")
- person.respond_to? :is_admin
- # => false
- person.becomes(WebUser).is_admin?
- # => true
- ```
-
- *Jacopo Beschi*, *Sampson Crowley*
-
-* Fix `ActiveRecord::QueryMethods#in_order_of` to include `nil`s, to match the
- behavior of `Enumerable#in_order_of`.
-
- For example, `Post.in_order_of(:title, [nil, "foo"])` will now include posts
- with `nil` titles, the same as `Post.all.to_a.in_order_of(:title, [nil, "foo"])`.
-
- *fatkodima*
-
-* Optimize `add_timestamps` to use a single SQL statement.
-
- ```ruby
- add_timestamps :my_table
- ```
-
- Now results in the following SQL:
-
- ```sql
- ALTER TABLE "my_table" ADD COLUMN "created_at" datetime(6) NOT NULL, ADD COLUMN "updated_at" datetime(6) NOT NULL
- ```
-
- *Iliana Hadzhiatanasova*
-
-* Add `drop_enum` migration command for PostgreSQL
-
- This does the inverse of `create_enum`. Before dropping an enum, ensure you have
- dropped columns that depend on it.
-
- *Alex Ghiculescu*
-
-* Adds support for `if_exists` option when removing a check constraint.
-
- The `remove_check_constraint` method now accepts an `if_exists` option. If set
- to true an error won't be raised if the check constraint doesn't exist.
-
- *Margaret Parsa* and *Aditya Bhutani*
-
-* `find_or_create_by` now try to find a second time if it hits a unicity constraint.
-
- `find_or_create_by` always has been inherently racy, either creating multiple
- duplicate records or failing with `ActiveRecord::RecordNotUnique` depending on
- whether a proper unicity constraint was set.
-
- `create_or_find_by` was introduced for this use case, however it's quite wasteful
- when the record is expected to exist most of the time, as INSERT require to send
- more data than SELECT and require more work from the database. Also on some
- databases it can actually consume a primary key increment which is undesirable.
-
- So for case where most of the time the record is expected to exist, `find_or_create_by`
- can be made race-condition free by re-trying the `find` if the `create` failed
- with `ActiveRecord::RecordNotUnique`. This assumes that the table has the proper
- unicity constraints, if not, `find_or_create_by` will still lead to duplicated records.
-
- *Jean Boussier*, *Alex Kitchens*
-
-* Introduce a simpler constructor API for ActiveRecord database adapters.
-
- Previously the adapter had to know how to build a new raw connection to
- support reconnect, but also expected to be passed an initial already-
- established connection.
-
- When manually creating an adapter instance, it will now accept a single
- config hash, and only establish the real connection on demand.
-
- *Matthew Draper*
-
-* Avoid redundant `SELECT 1` connection-validation query during DB pool
- checkout when possible.
-
- If the first query run during a request is known to be idempotent, it can be
- used directly to validate the connection, saving a network round-trip.
-
- *Matthew Draper*
-
-* Automatically reconnect broken database connections when safe, even
- mid-request.
-
- When an error occurs while attempting to run a known-idempotent query, and
- not inside a transaction, it is safe to immediately reconnect to the
- database server and try again, so this is now the default behavior.
-
- This new default should always be safe -- to support that, it's consciously
- conservative about which queries are considered idempotent -- but if
- necessary it can be disabled by setting the `connection_retries` connection
- option to `0`.
-
- *Matthew Draper*
-
-* Avoid removing a PostgreSQL extension when there are dependent objects.
-
- Previously, removing an extension also implicitly removed dependent objects. Now, this will raise an error.
-
- You can force removing the extension:
-
- ```ruby
- disable_extension :citext, force: :cascade
- ```
-
- Fixes #29091.
-
- *fatkodima*
-
-* Allow nested functions as safe SQL string
-
- *Michael Siegfried*
-
-* Allow `destroy_association_async_job=` to be configured with a class string instead of a constant.
-
- Defers an autoloading dependency between `ActiveRecord::Base` and `ActiveJob::Base`
- and moves the configuration of `ActiveRecord::DestroyAssociationAsyncJob`
- from ActiveJob to ActiveRecord.
-
- Deprecates `ActiveRecord::ActiveJobRequiredError` and now raises a `NameError`
- if the job class is unloadable or an `ActiveRecord::ConfigurationError` if
- `dependent: :destroy_async` is declared on an association but there is no job
- class configured.
-
- *Ben Sheldon*
-
-* Fix `ActiveRecord::Store` to serialize as a regular Hash
-
- Previously it would serialize as an `ActiveSupport::HashWithIndifferentAccess`
- which is wasteful and cause problem with YAML safe_load.
-
- *Jean Boussier*
-
-* Add `timestamptz` as a time zone aware type for PostgreSQL
-
- This is required for correctly parsing `timestamp with time zone` values in your database.
-
- If you don't want this, you can opt out by adding this initializer:
-
- ```ruby
- ActiveRecord::Base.time_zone_aware_types -= [:timestamptz]
- ```
-
- *Alex Ghiculescu*
-
-* Add new `ActiveRecord::Base::generates_token_for` API.
-
- Currently, `signed_id` fulfills the role of generating tokens for e.g.
- resetting a password. However, signed IDs cannot reflect record state, so
- if a token is intended to be single-use, it must be tracked in a database at
- least until it expires.
-
- With `generates_token_for`, a token can embed data from a record. When
- using the token to fetch the record, the data from the token and the data
- from the record will be compared. If the two do not match, the token will
- be treated as invalid, the same as if it had expired. For example:
-
- ```ruby
- class User < ActiveRecord::Base
- has_secure_password
-
- generates_token_for :password_reset, expires_in: 15.minutes do
- # A password's BCrypt salt changes when the password is updated.
- # By embedding (part of) the salt in a token, the token will
- # expire when the password is updated.
- BCrypt::Password.new(password_digest).salt[-10..]
- end
- end
-
- user = User.first
- token = user.generate_token_for(:password_reset)
-
- User.find_by_token_for(:password_reset, token) # => user
-
- user.update!(password: "new password")
- User.find_by_token_for(:password_reset, token) # => nil
- ```
-
- *Jonathan Hefner*
-
-* Optimize Active Record batching for whole table iterations.
-
- Previously, `in_batches` got all the ids and constructed an `IN`-based query for each batch.
- When iterating over the whole tables, this approach is not optimal as it loads unneeded ids and
- `IN` queries with lots of items are slow.
-
- Now, whole table iterations use range iteration (`id >= x AND id <= y`) by default which can make iteration
- several times faster. E.g., tested on a PostgreSQL table with 10 million records: querying (`253s` vs `30s`),
- updating (`288s` vs `124s`), deleting (`268s` vs `83s`).
-
- Only whole table iterations use this style of iteration by default. You can disable this behavior by passing `use_ranges: false`.
- If you iterate over the table and the only condition is, e.g., `archived_at: nil` (and only a tiny fraction
- of the records are archived), it makes sense to opt in to this approach:
-
- ```ruby
- Project.where(archived_at: nil).in_batches(use_ranges: true) do |relation|
- # do something
- end
- ```
-
- See #45414 for more details.
-
- *fatkodima*
-
-* `.with` query method added. Construct common table expressions with ease and get `ActiveRecord::Relation` back.
-
- ```ruby
- Post.with(posts_with_comments: Post.where("comments_count > ?", 0))
- # => ActiveRecord::Relation
- # WITH posts_with_comments AS (SELECT * FROM posts WHERE (comments_count > 0)) SELECT * FROM posts
- ```
-
- *Vlado Cingel*
-
-* Don't establish a new connection if an identical pool exists already.
-
- Previously, if `establish_connection` was called on a class that already had an established connection, the existing connection would be removed regardless of whether it was the same config. Now if a pool is found with the same values as the new connection, the existing connection will be returned instead of creating a new one.
-
- This has a slight change in behavior if application code is depending on a new connection being established regardless of whether it's identical to an existing connection. If the old behavior is desirable, applications should call `ActiveRecord::Base#remove_connection` before establishing a new one. Calling `establish_connection` with a different config works the same way as it did previously.
-
- *Eileen M. Uchitelle*
-
-* Update `db:prepare` task to load schema when an uninitialized database exists, and dump schema after migrations.
-
- *Ben Sheldon*
-
-* Fix supporting timezone awareness for `tsrange` and `tstzrange` array columns.
-
- ```ruby
- # In database migrations
- add_column :shops, :open_hours, :tsrange, array: true
- # In app config
- ActiveRecord::Base.time_zone_aware_types += [:tsrange]
- # In the code times are properly converted to app time zone
- Shop.create!(open_hours: [Time.current..8.hour.from_now])
- ```
-
- *Wojciech Wnętrzak*
-
-* Introduce strategy pattern for executing migrations.
-
- By default, migrations will use a strategy object that delegates the method
- to the connection adapter. Consumers can implement custom strategy objects
- to change how their migrations run.
-
- *Adrianna Chang*
-
-* Add adapter option disallowing foreign keys
-
- This adds a new option to be added to `database.yml` which enables skipping
- foreign key constraints usage even if the underlying database supports them.
-
- Usage:
- ```yaml
- development:
- <<: *default
- database: storage/development.sqlite3
- foreign_keys: false
- ```
-
- *Paulo Barros*
-
-* Add configurable deprecation warning for singular associations
-
- This adds a deprecation warning when using the plural name of a singular associations in `where`.
- It is possible to opt into the new more performant behavior with `config.active_record.allow_deprecated_singular_associations_name = false`
-
- *Adam Hess*
-
-* Run transactional callbacks on the freshest instance to save a given
- record within a transaction.
-
- When multiple Active Record instances change the same record within a
- transaction, Rails runs `after_commit` or `after_rollback` callbacks for
- only one of them. `config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`
- was added to specify how Rails chooses which instance receives the
- callbacks. The framework defaults were changed to use the new logic.
-
- When `config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`
- is `true`, transactional callbacks are run on the first instance to save,
- even though its instance state may be stale.
-
- When it is `false`, which is the new framework default starting with version
- 7.1, transactional callbacks are run on the instances with the freshest
- instance state. Those instances are chosen as follows:
-
- - In general, run transactional callbacks on the last instance to save a
- given record within the transaction.
- - There are two exceptions:
- - If the record is created within the transaction, then updated by
- another instance, `after_create_commit` callbacks will be run on the
- second instance. This is instead of the `after_update_commit`
- callbacks that would naively be run based on that instance’s state.
- - If the record is destroyed within the transaction, then
- `after_destroy_commit` callbacks will be fired on the last destroyed
- instance, even if a stale instance subsequently performed an update
- (which will have affected 0 rows).
-
- *Cameron Bothner and Mitch Vollebregt*
-
-* Enable strict strings mode for `SQLite3Adapter`.
-
- Configures SQLite with a strict strings mode, which disables double-quoted string literals.
-
- SQLite has some quirks around double-quoted string literals.
- It first tries to consider double-quoted strings as identifier names, but if they don't exist
- it then considers them as string literals. Because of this, typos can silently go unnoticed.
- For example, it is possible to create an index for a non existing column.
- See [SQLite documentation](https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted) for more details.
-
- If you don't want this behavior, you can disable it via:
-
- ```ruby
- # config/application.rb
- config.active_record.sqlite3_adapter_strict_strings_by_default = false
- ```
-
- Fixes #27782.
-
- *fatkodima*, *Jean Boussier*
-
-* Resolve issue where a relation cache_version could be left stale.
-
- Previously, when `reset` was called on a relation object it did not reset the cache_versions
- ivar. This led to a confusing situation where despite having the correct data the relation
- still reported a stale cache_version.
-
- Usage:
-
- ```ruby
- developers = Developer.all
- developers.cache_version
-
- Developer.update_all(updated_at: Time.now.utc + 1.second)
-
- developers.cache_version # Stale cache_version
- developers.reset
- developers.cache_version # Returns the current correct cache_version
- ```
-
- Fixes #45341.
-
- *Austen Madden*
-
-* Add support for exclusion constraints (PostgreSQL-only).
-
- ```ruby
- add_exclusion_constraint :invoices, "daterange(start_date, end_date) WITH &&", using: :gist, name: "invoices_date_overlap"
- remove_exclusion_constraint :invoices, name: "invoices_date_overlap"
- ```
-
- See PostgreSQL's [`CREATE TABLE ... EXCLUDE ...`](https://www.postgresql.org/docs/12/sql-createtable.html#SQL-CREATETABLE-EXCLUDE) documentation for more on exclusion constraints.
-
- *Alex Robbin*
-
-* `change_column_null` raises if a non-boolean argument is provided
-
- Previously if you provided a non-boolean argument, `change_column_null` would
- treat it as truthy and make your column nullable. This could be surprising, so now
- the input must be either `true` or `false`.
-
- ```ruby
- change_column_null :table, :column, true # good
- change_column_null :table, :column, false # good
- change_column_null :table, :column, from: true, to: false # raises (previously this made the column nullable)
- ```
-
- *Alex Ghiculescu*
-
-* Enforce limit on table names length.
-
- Fixes #45130.
-
- *fatkodima*
-
-* Adjust the minimum MariaDB version for check constraints support.
-
- *Eddie Lebow*
-
-* Fix Hstore deserialize regression.
-
- *edsharp*
-
-* Add validity for PostgreSQL indexes.
-
- ```ruby
- connection.index_exists?(:users, :email, valid: true)
- connection.indexes(:users).select(&:valid?)
- ```
-
- *fatkodima*
-
-* Fix eager loading for models without primary keys.
-
- *Anmol Chopra*, *Matt Lawrence*, and *Jonathan Hefner*
-
-* Avoid validating a unique field if it has not changed and is backed by a unique index.
-
- Previously, when saving a record, Active Record will perform an extra query to check for the
- uniqueness of each attribute having a `uniqueness` validation, even if that attribute hasn't changed.
- If the database has the corresponding unique index, then this validation can never fail for persisted
- records, and we could safely skip it.
-
- *fatkodima*
-
-* Stop setting `sql_auto_is_null`
-
- Since version 5.5 the default has been off, we no longer have to manually turn it off.
-
- *Adam Hess*
-
-* Fix `touch` to raise an error for readonly columns.
-
- *fatkodima*
-
-* Add ability to ignore tables by regexp for SQL schema dumps.
-
- ```ruby
- ActiveRecord::SchemaDumper.ignore_tables = [/^_/]
- ```
-
- *fatkodima*
-
-* Avoid queries when performing calculations on contradictory relations.
-
- Previously calculations would make a query even when passed a
- contradiction, such as `User.where(id: []).count`. We no longer perform a
- query in that scenario.
-
- This applies to the following calculations: `count`, `sum`, `average`,
- `minimum` and `maximum`
-
- *Luan Vieira, John Hawthorn and Daniel Colson*
-
-* Allow using aliased attributes with `insert_all`/`upsert_all`.
-
- ```ruby
- class Book < ApplicationRecord
- alias_attribute :title, :name
- end
-
- Book.insert_all [{ title: "Remote", author_id: 1 }], returning: :title
- ```
-
- *fatkodima*
-
-* Support encrypted attributes on columns with default db values.
-
- This adds support for encrypted attributes defined on columns with default values.
- It will encrypt those values at creation time. Before, it would raise an
- error unless `config.active_record.encryption.support_unencrypted_data` was true.
-
- *Jorge Manrubia* and *Dima Fatko*
-
-* Allow overriding `reading_request?` in `DatabaseSelector::Resolver`
-
- The default implementation checks if a request is a `get?` or `head?`,
- but you can now change it to anything you like. If the method returns true,
- `Resolver#read` gets called meaning the request could be served by the
- replica database.
-
- *Alex Ghiculescu*
-
-* Remove `ActiveRecord.legacy_connection_handling`.
-
- *Eileen M. Uchitelle*
-
-* `rails db:schema:{dump,load}` now checks `ENV["SCHEMA_FORMAT"]` before config
-
- Since `rails db:structure:{dump,load}` was deprecated there wasn't a simple
- way to dump a schema to both SQL and Ruby formats. You can now do this with
- an environment variable. For example:
-
- ```
- SCHEMA_FORMAT=sql rake db:schema:dump
- ```
-
- *Alex Ghiculescu*
-
-* Fixed MariaDB default function support.
-
- Defaults would be written wrong in "db/schema.rb" and not work correctly
- if using `db:schema:load`. Further more the function name would be
- added as string content when saving new records.
-
- *kaspernj*
-
-* Add `active_record.destroy_association_async_batch_size` configuration
-
- This allows applications to specify the maximum number of records that will
- be destroyed in a single background job by the `dependent: :destroy_async`
- association option. By default, the current behavior will remain the same:
- when a parent record is destroyed, all dependent records will be destroyed
- in a single background job. If the number of dependent records is greater
- than this configuration, the records will be destroyed in multiple
- background jobs.
-
- *Nick Holden*
-
-* Fix `remove_foreign_key` with `:if_exists` option when foreign key actually exists.
-
- *fatkodima*
-
-* Remove `--no-comments` flag in structure dumps for PostgreSQL
-
- This broke some apps that used custom schema comments. If you don't want
- comments in your structure dump, you can use:
-
- ```ruby
- ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = ['--no-comments']
- ```
-
- *Alex Ghiculescu*
-
-* Reduce the memory footprint of fixtures accessors.
-
- Until now fixtures accessors were eagerly defined using `define_method`.
- So the memory usage was directly dependent of the number of fixtures and
- test suites.
-
- Instead fixtures accessors are now implemented with `method_missing`,
- so they incur much less memory and CPU overhead.
-
- *Jean Boussier*
-
-* Fix `config.active_record.destroy_association_async_job` configuration
-
- `config.active_record.destroy_association_async_job` should allow
- applications to specify the job that will be used to destroy associated
- records in the background for `has_many` associations with the
- `dependent: :destroy_async` option. Previously, that was ignored, which
- meant the default `ActiveRecord::DestroyAssociationAsyncJob` always
- destroyed records in the background.
-
- *Nick Holden*
-
-* Fix `change_column_comment` to preserve column's AUTO_INCREMENT in the MySQL adapter
-
- *fatkodima*
-
-* Fix quoting of `ActiveSupport::Duration` and `Rational` numbers in the MySQL adapter.
-
- *Kevin McPhillips*
-
-* Allow column name with COLLATE (e.g., title COLLATE "C") as safe SQL string
-
- *Shugo Maeda*
-
-* Permit underscores in the VERSION argument to database rake tasks.
-
- *Eddie Lebow*
-
-* Reversed the order of `INSERT` statements in `structure.sql` dumps
-
- This should decrease the likelihood of merge conflicts. New migrations
- will now be added at the top of the list.
-
- For existing apps, there will be a large diff the next time `structure.sql`
- is generated.
-
- *Alex Ghiculescu*, *Matt Larraz*
-
-* Fix PG.connect keyword arguments deprecation warning on ruby 2.7
-
- Fixes #44307.
-
- *Nikita Vasilevsky*
-
-* Fix dropping DB connections after serialization failures and deadlocks.
-
- Prior to 6.1.4, serialization failures and deadlocks caused rollbacks to be
- issued for both real transactions and savepoints. This breaks MySQL which
- disallows rollbacks of savepoints following a deadlock.
-
- 6.1.4 removed these rollbacks, for both transactions and savepoints, causing
- the DB connection to be left in an unknown state and thus discarded.
-
- These rollbacks are now restored, except for savepoints on MySQL.
-
- *Thomas Morgan*
-
-* Make `ActiveRecord::ConnectionPool` Fiber-safe
-
- When `ActiveSupport::IsolatedExecutionState.isolation_level` is set to `:fiber`,
- the connection pool now supports multiple Fibers from the same Thread checking
- out connections from the pool.
-
- *Alex Matchneer*
-
-* Add `update_attribute!` to `ActiveRecord::Persistence`
-
- Similar to `update_attribute`, but raises `ActiveRecord::RecordNotSaved` when a `before_*` callback throws `:abort`.
-
- ```ruby
- class Topic < ActiveRecord::Base
- before_save :check_title
-
- def check_title
- throw(:abort) if title == "abort"
- end
- end
-
- topic = Topic.create(title: "Test Title")
- # #=> #
- topic.update_attribute!(:title, "Another Title")
- # #=> #
- topic.update_attribute!(:title, "abort")
- # raises ActiveRecord::RecordNotSaved
- ```
-
- *Drew Tempelmeyer*
-
-* Avoid loading every record in `ActiveRecord::Relation#pretty_print`
-
- ```ruby
- # Before
- pp Foo.all # Loads the whole table.
-
- # After
- pp Foo.all # Shows 10 items and an ellipsis.
- ```
-
- *Ulysse Buonomo*
-
-* Change `QueryMethods#in_order_of` to drop records not listed in values.
-
- `in_order_of` now filters down to the values provided, to match the behavior of the `Enumerable` version.
-
- *Kevin Newton*
-
-* Allow named expression indexes to be revertible.
-
- Previously, the following code would raise an error in a reversible migration executed while rolling back, due to the index name not being used in the index removal.
-
- ```ruby
- add_index(:settings, "(data->'property')", using: :gin, name: :index_settings_data_property)
- ```
-
- Fixes #43331.
-
- *Oliver Günther*
-
-* Fix incorrect argument in PostgreSQL structure dump tasks.
-
- Updating the `--no-comment` argument added in Rails 7 to the correct `--no-comments` argument.
-
- *Alex Dent*
-
-* Fix migration compatibility to create SQLite references/belongs_to column as integer when migration version is 6.0.
-
- Reference/belongs_to in migrations with version 6.0 were creating columns as
- bigint instead of integer for the SQLite Adapter.
-
- *Marcelo Lauxen*
-
-* Fix `QueryMethods#in_order_of` to handle empty order list.
-
- ```ruby
- Post.in_order_of(:id, []).to_a
- ```
-
- Also more explicitly set the column as secondary order, so that any other
- value is still ordered.
-
- *Jean Boussier*
-
-* Fix quoting of column aliases generated by calculation methods.
-
- Since the alias is derived from the table name, we can't assume the result
- is a valid identifier.
-
- ```ruby
- class Test < ActiveRecord::Base
- self.table_name = '1abc'
- end
- Test.group(:id).count
- # syntax error at or near "1" (ActiveRecord::StatementInvalid)
- # LINE 1: SELECT COUNT(*) AS count_all, "1abc"."id" AS 1abc_id FROM "1...
- ```
-
- *Jean Boussier*
-
-* Add `authenticate_by` when using `has_secure_password`.
-
- `authenticate_by` is intended to replace code like the following, which
- returns early when a user with a matching email is not found:
-
- ```ruby
- User.find_by(email: "...")&.authenticate("...")
- ```
-
- Such code is vulnerable to timing-based enumeration attacks, wherein an
- attacker can determine if a user account with a given email exists. After
- confirming that an account exists, the attacker can try passwords associated
- with that email address from other leaked databases, in case the user
- re-used a password across multiple sites (a common practice). Additionally,
- knowing an account email address allows the attacker to attempt a targeted
- phishing ("spear phishing") attack.
-
- `authenticate_by` addresses the vulnerability by taking the same amount of
- time regardless of whether a user with a matching email is found:
-
- ```ruby
- User.authenticate_by(email: "...", password: "...")
- ```
-
- *Jonathan Hefner*
-
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activerecord/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activerecord/CHANGELOG.md) for previous changes.
diff --git a/activerecord/README.rdoc b/activerecord/README.rdoc
index b430b17795ae8..07a07f47f21f6 100644
--- a/activerecord/README.rdoc
+++ b/activerecord/README.rdoc
@@ -139,7 +139,7 @@ A short rundown of some of the major features:
* Database agnostic schema management with Migrations.
- class AddSystemSettings < ActiveRecord::Migration[7.1]
+ class AddSystemSettings < ActiveRecord::Migration[7.2]
def up
create_table :system_settings do |t|
t.string :name
diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb
index 408e6fd6d202d..85dfd20fae0ac 100644
--- a/activerecord/lib/active_record.rb
+++ b/activerecord/lib/active_record.rb
@@ -447,6 +447,13 @@ def self.suppress_multiple_database_warning=(value)
singleton_class.attr_accessor :yaml_column_permitted_classes
self.yaml_column_permitted_classes = [Symbol]
+ ##
+ # :singleton-method:
+ # Controls when to generate a value for has_secure_token
+ # declarations. Defaults to :create.
+ singleton_class.attr_accessor :generate_secure_token_on
+ self.generate_secure_token_on = :create
+
def self.marshalling_format_version
Marshalling.format_version
end
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb
index 91ec10d3be673..89dc7df8186b4 100644
--- a/activerecord/lib/active_record/associations.rb
+++ b/activerecord/lib/active_record/associations.rb
@@ -1276,15 +1276,15 @@ module ClassMethods
# +collection+ is a placeholder for the symbol passed as the +name+ argument, so
# has_many :clients would add among others clients.empty?.
#
- # [collection]
+ # [collection]
# Returns a Relation of all the associated objects.
# An empty Relation is returned if none are found.
- # [collection<<(object, ...)]
+ # [collection<<(object, ...)]
# Adds one or more objects to the collection by setting their foreign keys to the collection's primary key.
# Note that this operation instantly fires update SQL without waiting for the save or update call on the
# parent object, unless the parent object is a new record.
# This will also run validations and callbacks of associated object(s).
- # [collection.delete(object, ...)]
+ # [collection.delete(object, ...)]
# Removes one or more objects from the collection by setting their foreign keys to +NULL+.
# Objects will be in addition destroyed if they're associated with dependent: :destroy,
# and deleted if they're associated with dependent: :delete_all.
@@ -1292,50 +1292,50 @@ module ClassMethods
# If the :through option is used, then the join records are deleted (rather than
# nullified) by default, but you can specify dependent: :destroy or
# dependent: :nullify to override this.
- # [collection.destroy(object, ...)]
+ # [collection.destroy(object, ...)]
# Removes one or more objects from the collection by running destroy on
# each record, regardless of any dependent option, ensuring callbacks are run.
#
# If the :through option is used, then the join records are destroyed
# instead, not the objects themselves.
- # [collection=objects]
+ # [collection=objects]
# Replaces the collections content by deleting and adding objects as appropriate. If the :through
# option is true callbacks in the join models are triggered except destroy callbacks, since deletion is
# direct by default. You can specify dependent: :destroy or
# dependent: :nullify to override this.
- # [collection_singular_ids]
+ # [collection_singular_ids]
# Returns an array of the associated objects' ids
- # [collection_singular_ids=ids]
+ # [collection_singular_ids=ids]
# Replace the collection with the objects identified by the primary keys in +ids+. This
# method loads the models and calls collection=. See above.
- # [collection.clear]
+ # [collection.clear]
# Removes every object from the collection. This destroys the associated objects if they
# are associated with dependent: :destroy, deletes them directly from the
# database if dependent: :delete_all, otherwise sets their foreign keys to +NULL+.
# If the :through option is true no destroy callbacks are invoked on the join models.
# Join models are directly deleted.
- # [collection.empty?]
+ # [collection.empty?]
# Returns +true+ if there are no associated objects.
- # [collection.size]
+ # [collection.size]
# Returns the number of associated objects.
- # [collection.find(...)]
+ # [collection.find(...)]
# Finds an associated object according to the same rules as ActiveRecord::FinderMethods#find.
- # [collection.exists?(...)]
+ # [collection.exists?(...)]
# Checks whether an associated object with the given conditions exists.
# Uses the same rules as ActiveRecord::FinderMethods#exists?.
- # [collection.build(attributes = {}, ...)]
+ # [collection.build(attributes = {}, ...)]
# Returns one or more new objects of the collection type that have been instantiated
# with +attributes+ and linked to this object through a foreign key, but have not yet
# been saved.
- # [collection.create(attributes = {})]
+ # [collection.create(attributes = {})]
# Returns a new object of the collection type that has been instantiated
# with +attributes+, linked to this object through a foreign key, and that has already
# been saved (if it passed the validation). *Note*: This only works if the base model
# already exists in the DB, not if it is a new (unsaved) record!
- # [collection.create!(attributes = {})]
+ # [collection.create!(attributes = {})]
# Does the same as collection.create, but raises ActiveRecord::RecordInvalid
# if the record is invalid.
- # [collection.reload]
+ # [collection.reload]
# Returns a Relation of all of the associated objects, forcing a database read.
# An empty Relation is returned if none are found.
#
@@ -1395,27 +1395,27 @@ module ClassMethods
# end
#
# === Options
- # [:class_name]
+ # [+:class_name+]
# Specify the class name of the association. Use it only if that name can't be inferred
# from the association name. So has_many :products will by default be linked
# to the +Product+ class, but if the real class name is +SpecialProduct+, you'll have to
# specify it with this option.
- # [:foreign_key]
+ # [+:foreign_key+]
# Specify the foreign key used for the association. By default this is guessed to be the name
# of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_many
# association will use "person_id" as the default :foreign_key.
#
# Setting the :foreign_key option prevents automatic detection of the association's
# inverse, so it is generally a good idea to set the :inverse_of option as well.
- # [:foreign_type]
+ # [+:foreign_type+]
# Specify the column used to store the associated object's type, if this is a polymorphic
# association. By default this is guessed to be the name of the polymorphic association
# specified on "as" option with a "_type" suffix. So a class that defines a
# has_many :tags, as: :taggable association will use "taggable_type" as the
# default :foreign_type.
- # [:primary_key]
+ # [+:primary_key+]
# Specify the name of the column to use as the primary key for the association. By default this is +id+.
- # [:dependent]
+ # [+:dependent+]
# Controls what happens to the associated objects when
# their owner is destroyed. Note that these are implemented as
# callbacks, and \Rails executes callbacks in order. Therefore, other
@@ -1442,12 +1442,12 @@ module ClassMethods
# has_many :comments, -> { where published: true }, dependent: :destroy and destroy is
# called on a post, only published comments are destroyed. This means that any unpublished comments in the
# database would still contain a foreign key pointing to the now deleted post.
- # [:counter_cache]
+ # [+:counter_cache+]
# This option can be used to configure a custom named :counter_cache. You only need this option,
# when you customized the name of your :counter_cache on the #belongs_to association.
- # [:as]
+ # [+:as+]
# Specifies a polymorphic interface (See #belongs_to).
- # [:through]
+ # [+:through+]
# Specifies an association through which to perform the query. This can be any other type
# of association, including other :through associations. Options for :class_name,
# :primary_key and :foreign_key are ignored, as the association uses the
@@ -1463,23 +1463,23 @@ module ClassMethods
# join model. This allows associated records to be built which will automatically create
# the appropriate join model records when they are saved. (See the 'Association Join Models'
# and 'Setting Inverses' sections above.)
- # [:disable_joins]
+ # [+:disable_joins+]
# Specifies whether joins should be skipped for an association. If set to true, two or more queries
# will be generated. Note that in some cases, if order or limit is applied, it will be done in-memory
# due to database limitations. This option is only applicable on has_many :through associations as
# +has_many+ alone do not perform a join.
- # [:source]
+ # [+:source+]
# Specifies the source association name used by #has_many :through queries.
# Only use it if the name cannot be inferred from the association.
# has_many :subscribers, through: :subscriptions will look for either :subscribers or
# :subscriber on Subscription, unless a :source is given.
- # [:source_type]
+ # [+:source_type+]
# Specifies type of the source association used by #has_many :through queries where the source
# association is a polymorphic #belongs_to.
- # [:validate]
+ # [+:validate+]
# When set to +true+, validates new objects added to association when saving the parent object. +true+ by default.
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+.
- # [:autosave]
+ # [+:autosave+]
# If true, always save the associated objects or destroy them if marked for destruction,
# when saving the parent object. If false, never save or destroy the associated objects.
# By default, only save associated objects that are new records. This option is implemented as a
@@ -1488,20 +1488,24 @@ module ClassMethods
#
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets
# :autosave to true.
- # [:inverse_of]
+ # [+:inverse_of+]
# Specifies the name of the #belongs_to association on the associated object
# that is the inverse of this #has_many association.
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail.
- # [:extend]
+ # [+:extend+]
# Specifies a module or array of modules that will be extended into the association object returned.
# Useful for defining methods on associations, especially when they should be shared between multiple
# association objects.
- # [:strict_loading]
+ # [+:strict_loading+]
# When set to +true+, enforces strict loading every time the associated record is loaded through this
# association.
- # [:ensuring_owner_was]
+ # [+:ensuring_owner_was+]
# Specifies an instance method to be called on the owner. The method must return true in order for the
# associated records to be deleted in a background job.
+ # [+:query_constraints+]
+ # Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
+ # This is an optional option. By default Rails will attempt to derive the value automatically.
+ # When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
#
# Option examples:
# has_many :comments, -> { order("posted_on") }
@@ -1514,6 +1518,7 @@ module ClassMethods
# has_many :subscribers, through: :subscriptions, source: :user
# has_many :subscribers, through: :subscriptions, disable_joins: true
# has_many :comments, strict_loading: true
+ # has_many :comments, query_constraints: [:blog_id, :post_id]
def has_many(name, scope = nil, **options, &extension)
reflection = Builder::HasMany.build(self, name, scope, options, &extension)
Reflection.add_reflection self, name, reflection
@@ -1529,26 +1534,26 @@ def has_many(name, scope = nil, **options, &extension)
# +association+ is a placeholder for the symbol passed as the +name+ argument, so
# has_one :manager would add among others manager.nil?.
#
- # [association]
+ # [association]
# Returns the associated object. +nil+ is returned if none is found.
- # [association=(associate)]
+ # [association=(associate)]
# Assigns the associate object, extracts the primary key, sets it as the foreign key,
# and saves the associate object. To avoid database inconsistencies, permanently deletes an existing
# associated object when assigning a new one, even if the new one isn't saved to database.
- # [build_association(attributes = {})]
+ # [build_association(attributes = {})]
# Returns a new object of the associated type that has been instantiated
# with +attributes+ and linked to this object through a foreign key, but has not
# yet been saved.
- # [create_association(attributes = {})]
+ # [create_association(attributes = {})]
# Returns a new object of the associated type that has been instantiated
# with +attributes+, linked to this object through a foreign key, and that
# has already been saved (if it passed the validation).
- # [create_association!(attributes = {})]
+ # [create_association!(attributes = {})]
# Does the same as create_association, but raises ActiveRecord::RecordInvalid
# if the record is invalid.
- # [reload_association]
+ # [reload_association]
# Returns the associated object, forcing a database read.
- # [reset_association]
+ # [reset_association]
# Unloads the associated object. The next access will query it from the database.
#
# === Example
@@ -1586,11 +1591,11 @@ def has_many(name, scope = nil, **options, &extension)
# The declaration can also include an +options+ hash to specialize the behavior of the association.
#
# Options are:
- # [:class_name]
+ # [+:class_name+]
# Specify the class name of the association. Use it only if that name can't be inferred
# from the association name. So has_one :manager will by default be linked to the Manager class, but
# if the real class name is Person, you'll have to specify it with this option.
- # [:dependent]
+ # [+:dependent+]
# Controls what happens to the associated object when
# its owner is destroyed:
#
@@ -1606,24 +1611,24 @@ def has_many(name, scope = nil, **options, &extension)
# * :restrict_with_error causes an error to be added to the owner if there is an associated object
#
# Note that :dependent option is ignored when using :through option.
- # [:foreign_key]
+ # [+:foreign_key+]
# Specify the foreign key used for the association. By default this is guessed to be the name
# of this class in lower-case and "_id" suffixed. So a Person class that makes a #has_one association
# will use "person_id" as the default :foreign_key.
#
# Setting the :foreign_key option prevents automatic detection of the association's
# inverse, so it is generally a good idea to set the :inverse_of option as well.
- # [:foreign_type]
+ # [+:foreign_type+]
# Specify the column used to store the associated object's type, if this is a polymorphic
# association. By default this is guessed to be the name of the polymorphic association
# specified on "as" option with a "_type" suffix. So a class that defines a
# has_one :tag, as: :taggable association will use "taggable_type" as the
# default :foreign_type.
- # [:primary_key]
+ # [+:primary_key+]
# Specify the method that returns the primary key used for the association. By default this is +id+.
- # [:as]
+ # [+:as+]
# Specifies a polymorphic interface (See #belongs_to).
- # [:through]
+ # [+:through+]
# Specifies a Join Model through which to perform the query. Options for :class_name,
# :primary_key, and :foreign_key are ignored, as the association uses the
# source reflection. You can only use a :through query through a #has_one
@@ -1639,48 +1644,52 @@ def has_many(name, scope = nil, **options, &extension)
# join model. This allows associated records to be built which will automatically create
# the appropriate join model records when they are saved. (See the 'Association Join Models'
# and 'Setting Inverses' sections above.)
- # [:disable_joins]
+ # [+:disable_joins+]
# Specifies whether joins should be skipped for an association. If set to true, two or more queries
# will be generated. Note that in some cases, if order or limit is applied, it will be done in-memory
# due to database limitations. This option is only applicable on has_one :through associations as
# +has_one+ alone does not perform a join.
- # [:source]
+ # [+:source+]
# Specifies the source association name used by #has_one :through queries.
# Only use it if the name cannot be inferred from the association.
# has_one :favorite, through: :favorites will look for a
# :favorite on Favorite, unless a :source is given.
- # [:source_type]
+ # [+:source_type+]
# Specifies type of the source association used by #has_one :through queries where the source
# association is a polymorphic #belongs_to.
- # [:validate]
+ # [+:validate+]
# When set to +true+, validates new objects added to association when saving the parent object. +false+ by default.
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+.
- # [:autosave]
+ # [+:autosave+]
# If true, always save the associated object or destroy it if marked for destruction,
# when saving the parent object. If false, never save or destroy the associated object.
# By default, only save the associated object if it's a new record.
#
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets
# :autosave to true.
- # [:touch]
+ # [+:touch+]
# If true, the associated object will be touched (the +updated_at+ / +updated_on+ attributes set to current time)
# when this record is either saved or destroyed. If you specify a symbol, that attribute
# will be updated with the current time in addition to the +updated_at+ / +updated_on+ attribute.
# Please note that no validation will be performed when touching, and only the +after_touch+,
# +after_commit+, and +after_rollback+ callbacks will be executed.
- # [:inverse_of]
+ # [+:inverse_of+]
# Specifies the name of the #belongs_to association on the associated object
# that is the inverse of this #has_one association.
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail.
- # [:required]
+ # [+:required+]
# When set to +true+, the association will also have its presence validated.
# This will validate the association itself, not the id. You can use
# +:inverse_of+ to avoid an extra query during validation.
- # [:strict_loading]
+ # [+:strict_loading+]
# Enforces strict loading every time the associated record is loaded through this association.
- # [:ensuring_owner_was]
+ # [+:ensuring_owner_was+]
# Specifies an instance method to be called on the owner. The method must return true in order for the
# associated records to be deleted in a background job.
+ # [+:query_constraints+]
+ # Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
+ # This is an optional option. By default Rails will attempt to derive the value automatically.
+ # When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
#
# Option examples:
# has_one :credit_card, dependent: :destroy # destroys the associated credit card
@@ -1695,6 +1704,7 @@ def has_many(name, scope = nil, **options, &extension)
# has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable
# has_one :credit_card, required: true
# has_one :credit_card, strict_loading: true
+ # has_one :employment_record_book, query_constraints: [:organization_id, :employee_id]
def has_one(name, scope = nil, **options)
reflection = Builder::HasOne.build(self, name, scope, options)
Reflection.add_reflection self, name, reflection
@@ -1711,28 +1721,28 @@ def has_one(name, scope = nil, **options)
# +association+ is a placeholder for the symbol passed as the +name+ argument, so
# belongs_to :author would add among others author.nil?.
#
- # [association]
+ # [association]
# Returns the associated object. +nil+ is returned if none is found.
- # [association=(associate)]
+ # [association=(associate)]
# Assigns the associate object, extracts the primary key, and sets it as the foreign key.
# No modification or deletion of existing records takes place.
- # [build_association(attributes = {})]
+ # [build_association(attributes = {})]
# Returns a new object of the associated type that has been instantiated
# with +attributes+ and linked to this object through a foreign key, but has not yet been saved.
- # [create_association(attributes = {})]
+ # [create_association(attributes = {})]
# Returns a new object of the associated type that has been instantiated
# with +attributes+, linked to this object through a foreign key, and that
# has already been saved (if it passed the validation).
- # [create_association!(attributes = {})]
+ # [create_association!(attributes = {})]
# Does the same as create_association, but raises ActiveRecord::RecordInvalid
# if the record is invalid.
- # [reload_association]
+ # [reload_association]
# Returns the associated object, forcing a database read.
- # [reset_association]
+ # [reset_association]
# Unloads the associated object. The next access will query it from the database.
- # [association_changed?]
+ # [association_changed?]
# Returns true if a new associate object has been assigned and the next save will update the foreign key.
- # [association_previously_changed?]
+ # [association_previously_changed?]
# Returns true if the previous save updated the association to reference a new associate object.
#
# === Example
@@ -1771,11 +1781,11 @@ def has_one(name, scope = nil, **options)
#
# The declaration can also include an +options+ hash to specialize the behavior of the association.
#
- # [:class_name]
+ # [+:class_name+]
# Specify the class name of the association. Use it only if that name can't be inferred
# from the association name. So belongs_to :author will by default be linked to the Author class, but
# if the real class name is Person, you'll have to specify it with this option.
- # [:foreign_key]
+ # [+:foreign_key+]
# Specify the foreign key used for the association. By default this is guessed to be the name
# of the association with an "_id" suffix. So a class that defines a belongs_to :person
# association will use "person_id" as the default :foreign_key. Similarly,
@@ -1784,22 +1794,22 @@ def has_one(name, scope = nil, **options)
#
# Setting the :foreign_key option prevents automatic detection of the association's
# inverse, so it is generally a good idea to set the :inverse_of option as well.
- # [:foreign_type]
+ # [+:foreign_type+]
# Specify the column used to store the associated object's type, if this is a polymorphic
# association. By default this is guessed to be the name of the association with a "_type"
# suffix. So a class that defines a belongs_to :taggable, polymorphic: true
# association will use "taggable_type" as the default :foreign_type.
- # [:primary_key]
+ # [+:primary_key+]
# Specify the method that returns the primary key of associated object used for the association.
# By default this is +id+.
- # [:dependent]
+ # [+:dependent+]
# If set to :destroy, the associated object is destroyed when this object is. If set to
# :delete, the associated object is deleted *without* calling its destroy method. If set to
# :destroy_async, the associated object is scheduled to be destroyed in a background job.
# This option should not be specified when #belongs_to is used in conjunction with
# a #has_many relationship on another class because of the potential to leave
# orphaned records behind.
- # [:counter_cache]
+ # [+:counter_cache+]
# Caches the number of belonging objects on the associate class through the use of CounterCache::ClassMethods#increment_counter
# and CounterCache::ClassMethods#decrement_counter. The counter cache is incremented when an object of this
# class is created and decremented when it's destroyed. This requires that a column
@@ -1811,14 +1821,14 @@ def has_one(name, scope = nil, **options)
# option (e.g., counter_cache: :my_custom_counter.)
# Note: Specifying a counter cache will add it to that model's list of readonly attributes
# using +attr_readonly+.
- # [:polymorphic]
+ # [+:polymorphic+]
# Specify this association is a polymorphic association by passing +true+.
# Note: If you've enabled the counter cache, then you may want to add the counter cache attribute
# to the +attr_readonly+ list in the associated classes (e.g. class Post; attr_readonly :comments_count; end).
- # [:validate]
+ # [+:validate+]
# When set to +true+, validates new objects added to association when saving the parent object. +false+ by default.
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+.
- # [:autosave]
+ # [+:autosave+]
# If true, always save the associated object or destroy it if marked for destruction, when
# saving the parent object.
# If false, never save or destroy the associated object.
@@ -1826,33 +1836,37 @@ def has_one(name, scope = nil, **options)
#
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for
# sets :autosave to true.
- # [:touch]
+ # [+:touch+]
# If true, the associated object will be touched (the +updated_at+ / +updated_on+ attributes set to current time)
# when this record is either saved or destroyed. If you specify a symbol, that attribute
# will be updated with the current time in addition to the +updated_at+ / +updated_on+ attribute.
# Please note that no validation will be performed when touching, and only the +after_touch+,
# +after_commit+, and +after_rollback+ callbacks will be executed.
- # [:inverse_of]
+ # [+:inverse_of+]
# Specifies the name of the #has_one or #has_many association on the associated
# object that is the inverse of this #belongs_to association.
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail.
- # [:optional]
+ # [+:optional+]
# When set to +true+, the association will not have its presence validated.
- # [:required]
+ # [+:required+]
# When set to +true+, the association will also have its presence validated.
# This will validate the association itself, not the id. You can use
# +:inverse_of+ to avoid an extra query during validation.
# NOTE: required is set to true by default and is deprecated. If
# you don't want to have association presence validated, use optional: true.
- # [:default]
+ # [+:default+]
# Provide a callable (i.e. proc or lambda) to specify that the association should
# be initialized with a particular record before validation.
# Please note that callable won't be executed if the record exists.
- # [:strict_loading]
+ # [+:strict_loading+]
# Enforces strict loading every time the associated record is loaded through this association.
- # [:ensuring_owner_was]
+ # [+:ensuring_owner_was+]
# Specifies an instance method to be called on the owner. The method must return true in order for the
# associated records to be deleted in a background job.
+ # [+:query_constraints+]
+ # Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
+ # This is an optional option. By default Rails will attempt to derive the value automatically.
+ # When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
#
# Option examples:
# belongs_to :firm, foreign_key: "client_of"
@@ -1868,6 +1882,7 @@ def has_one(name, scope = nil, **options)
# belongs_to :user, optional: true
# belongs_to :account, default: -> { company.account }
# belongs_to :account, strict_loading: true
+ # belong_to :note, query_constraints: [:organization_id, :note_id]
def belongs_to(name, scope = nil, **options)
reflection = Builder::BelongsTo.build(self, name, scope, options)
Reflection.add_reflection self, name, reflection
@@ -1890,7 +1905,7 @@ def belongs_to(name, scope = nil, **options)
# The join table should not have a primary key or a model associated with it. You must manually generate the
# join table with a migration such as this:
#
- # class CreateDevelopersProjectsJoinTable < ActiveRecord::Migration[7.1]
+ # class CreateDevelopersProjectsJoinTable < ActiveRecord::Migration[7.2]
# def change
# create_join_table :developers, :projects
# end
@@ -1905,47 +1920,47 @@ def belongs_to(name, scope = nil, **options)
# +collection+ is a placeholder for the symbol passed as the +name+ argument, so
# has_and_belongs_to_many :categories would add among others categories.empty?.
#
- # [collection]
+ # [collection]
# Returns a Relation of all the associated objects.
# An empty Relation is returned if none are found.
- # [collection<<(object, ...)]
+ # [collection<<(object, ...)]
# Adds one or more objects to the collection by creating associations in the join table
# (collection.push and collection.concat are aliases to this method).
# Note that this operation instantly fires update SQL without waiting for the save or update call on the
# parent object, unless the parent object is a new record.
- # [collection.delete(object, ...)]
+ # [collection.delete(object, ...)]
# Removes one or more objects from the collection by removing their associations from the join table.
# This does not destroy the objects.
- # [collection.destroy(object, ...)]
+ # [collection.destroy(object, ...)]
# Removes one or more objects from the collection by running destroy on each association in the join table, overriding any dependent option.
# This does not destroy the objects.
- # [collection=objects]
+ # [collection=objects]
# Replaces the collection's content by deleting and adding objects as appropriate.
- # [collection_singular_ids]
+ # [collection_singular_ids]
# Returns an array of the associated objects' ids.
- # [collection_singular_ids=ids]
+ # [collection_singular_ids=ids]
# Replace the collection by the objects identified by the primary keys in +ids+.
- # [collection.clear]
+ # [collection.clear]
# Removes every object from the collection. This does not destroy the objects.
- # [collection.empty?]
+ # [collection.empty?]
# Returns +true+ if there are no associated objects.
- # [collection.size]
+ # [collection.size]
# Returns the number of associated objects.
- # [collection.find(id)]
+ # [collection.find(id)]
# Finds an associated object responding to the +id+ and that
# meets the condition that it has to be associated with this object.
# Uses the same rules as ActiveRecord::FinderMethods#find.
- # [collection.exists?(...)]
+ # [collection.exists?(...)]
# Checks whether an associated object with the given conditions exists.
# Uses the same rules as ActiveRecord::FinderMethods#exists?.
- # [collection.build(attributes = {})]
+ # [collection.build(attributes = {})]
# Returns a new object of the collection type that has been instantiated
# with +attributes+ and linked to this object through the join table, but has not yet been saved.
- # [collection.create(attributes = {})]
+ # [collection.create(attributes = {})]
# Returns a new object of the collection type that has been instantiated
# with +attributes+, linked to this object through the join table, and that has already been
# saved (if it passed the validation).
- # [collection.reload]
+ # [collection.reload]
# Returns a Relation of all of the associated objects, forcing a database read.
# An empty Relation is returned if none are found.
#
@@ -2007,15 +2022,15 @@ def belongs_to(name, scope = nil, **options)
#
# === Options
#
- # [:class_name]
+ # [+:class_name+]
# Specify the class name of the association. Use it only if that name can't be inferred
# from the association name. So has_and_belongs_to_many :projects will by default be linked to the
# Project class, but if the real class name is SuperProject, you'll have to specify it with this option.
- # [:join_table]
+ # [+:join_table+]
# Specify the name of the join table if the default based on lexical order isn't what you want.
# WARNING: If you're overwriting the table name of either class, the +table_name+ method
# MUST be declared underneath any #has_and_belongs_to_many declaration in order to work.
- # [:foreign_key]
+ # [+:foreign_key+]
# Specify the foreign key used for the association. By default this is guessed to be the name
# of this class in lower-case and "_id" suffixed. So a Person class that makes
# a #has_and_belongs_to_many association to Project will use "person_id" as the
@@ -2023,15 +2038,15 @@ def belongs_to(name, scope = nil, **options)
#
# Setting the :foreign_key option prevents automatic detection of the association's
# inverse, so it is generally a good idea to set the :inverse_of option as well.
- # [:association_foreign_key]
+ # [+:association_foreign_key+]
# Specify the foreign key used for the association on the receiving side of the association.
# By default this is guessed to be the name of the associated class in lower-case and "_id" suffixed.
# So if a Person class makes a #has_and_belongs_to_many association to Project,
# the association will use "project_id" as the default :association_foreign_key.
- # [:validate]
+ # [+:validate+]
# When set to +true+, validates new objects added to association when saving the parent object. +true+ by default.
# If you want to ensure associated objects are revalidated on every update, use +validates_associated+.
- # [:autosave]
+ # [+:autosave+]
# If true, always save the associated objects or destroy them if marked for destruction, when
# saving the parent object.
# If false, never save or destroy the associated objects.
@@ -2039,7 +2054,7 @@ def belongs_to(name, scope = nil, **options)
#
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets
# :autosave to true.
- # [:strict_loading]
+ # [+:strict_loading+]
# Enforces strict loading every time an associated record is loaded through this association.
#
# Option examples:
diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb
index 1d59b7ffa856c..098bedd592a41 100644
--- a/activerecord/lib/active_record/associations/builder/belongs_to.rb
+++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb
@@ -37,7 +37,7 @@ def self.add_counter_cache_callbacks(model, reflection)
}
klass = reflection.class_name.safe_constantize
- klass._counter_cache_columns << cache_column if klass && klass.respond_to?(:_counter_cache_columns)
+ klass._counter_cache_columns |= [cache_column] if klass && klass.respond_to?(:_counter_cache_columns)
end
def self.touch_record(o, changes, foreign_key, name, touch) # :nodoc:
diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb
index d96555620a7ef..825671d13c8fa 100644
--- a/activerecord/lib/active_record/associations/collection_association.rb
+++ b/activerecord/lib/active_record/associations/collection_association.rb
@@ -64,9 +64,7 @@ def ids_writer(ids)
ids.map! { |id| pk_type.cast(id) }
records = if klass.composite_primary_key?
- query_records = ids.map { |values_set| klass.where(primary_key.zip(values_set).to_h) }.inject(&:or)
-
- query_records.index_by do |record|
+ klass.where(primary_key => ids).index_by do |record|
primary_key.map { |primary_key| record._read_attribute(primary_key) }
end
else
diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb
index 43c3d34106295..bdaec12cfc7af 100644
--- a/activerecord/lib/active_record/associations/collection_proxy.rb
+++ b/activerecord/lib/active_record/associations/collection_proxy.rb
@@ -361,7 +361,7 @@ def create(attributes = {}, &block)
# end
#
# person.pets.create!(name: nil)
- # # => ActiveRecord::RecordInvalid: Validation failed: Name can’t be blank
+ # # => ActiveRecord::RecordInvalid: Validation failed: Name can't be blank
def create!(attributes = {}, &block)
@association.create!(attributes, &block)
end
diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb
index 44d84ae2f6516..5a52410e0a567 100644
--- a/activerecord/lib/active_record/attribute_methods.rb
+++ b/activerecord/lib/active_record/attribute_methods.rb
@@ -71,8 +71,10 @@ def generate_alias_attributes # :nodoc:
generated_attribute_methods.synchronize do
return if @alias_attributes_mass_generated
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |code_generator|
- local_attribute_aliases.each do |new_name, old_name|
- generate_alias_attribute_methods(code_generator, new_name, old_name)
+ aliases_by_attribute_name.each do |old_name, new_names|
+ new_names.each do |new_name|
+ generate_alias_attribute_methods(code_generator, new_name, old_name)
+ end
end
end
@@ -96,9 +98,9 @@ def alias_attribute_method_definition(code_generator, pattern, new_name, old_nam
should_warn = target_name == old_name
if should_warn
ActiveRecord.deprecator.warn(
- "#{self} model aliases `#{old_name}`, but #{old_name} is not an attribute. " \
- "Starting in Rails 7.2 `, alias_attribute with non-attribute targets will raise. " \
- "Use `alias_method :#{new_name}`, :#{old_name} or define the method manually."
+ "#{self} model aliases `#{old_name}`, but `#{old_name}` is not an attribute. " \
+ "Starting in Rails 7.2, alias_attribute with non-attribute targets will raise. " \
+ "Use `alias_method :#{new_name}, :#{old_name}` or define the method manually."
)
end
super
diff --git a/activerecord/lib/active_record/attribute_methods/primary_key.rb b/activerecord/lib/active_record/attribute_methods/primary_key.rb
index 57719f543a796..4adee074e9948 100644
--- a/activerecord/lib/active_record/attribute_methods/primary_key.rb
+++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb
@@ -15,7 +15,8 @@ def to_key
Array(key) if key
end
- # Returns the primary key column's value.
+ # Returns the primary key column's value. If the primary key is composite,
+ # returns an array of the primary key column values.
def id
return _read_attribute(@primary_key) unless @primary_key.is_a?(Array)
@@ -28,7 +29,8 @@ def primary_key_values_present? # :nodoc:
!!id
end
- # Sets the primary key column's value.
+ # Sets the primary key column's value. If the primary key is composite,
+ # raises TypeError when the set value not enumerable.
def id=(value)
if self.class.composite_primary_key?
raise TypeError, "Expected value matching #{self.class.primary_key.inspect}, got #{value.inspect}." unless value.is_a?(Enumerable)
@@ -38,7 +40,8 @@ def id=(value)
end
end
- # Queries the primary key column's value.
+ # Queries the primary key column's value. If the primary key is composite,
+ # all primary key column values must be queryable.
def id?
if self.class.composite_primary_key?
@primary_key.all? { |col| _query_attribute(col) }
@@ -47,7 +50,8 @@ def id?
end
end
- # Returns the primary key column's value before type cast.
+ # Returns the primary key column's value before type cast. If the primary key is composite,
+ # returns an array of primary key column values before type cast.
def id_before_type_cast
if self.class.composite_primary_key?
@primary_key.map { |col| attribute_before_type_cast(col) }
@@ -56,7 +60,8 @@ def id_before_type_cast
end
end
- # Returns the primary key column's previous value.
+ # Returns the primary key column's previous value. If the primary key is composite,
+ # returns an array of primary key column previous values.
def id_was
if self.class.composite_primary_key?
@primary_key.map { |col| attribute_was(col) }
@@ -65,7 +70,8 @@ def id_was
end
end
- # Returns the primary key column's value from the database.
+ # Returns the primary key column's value from the database. If the primary key is composite,
+ # returns an array of primary key column values from database.
def id_in_database
if self.class.composite_primary_key?
@primary_key.map { |col| attribute_in_database(col) }
diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb
index 91eb0bfcd47d8..50e6d48981de9 100644
--- a/activerecord/lib/active_record/autosave_association.rb
+++ b/activerecord/lib/active_record/autosave_association.rb
@@ -449,12 +449,16 @@ def save_has_one_association(reflection)
if autosave && record.marked_for_destruction?
record.destroy
elsif autosave != false
- key = reflection.options[:primary_key] ? _read_attribute(reflection.options[:primary_key].to_s) : id
+ primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
+ primary_key_value = primary_key.map { |key| _read_attribute(key) }
- if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, key)
+ if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, primary_key_value)
unless reflection.through_reflection
- Array(key).zip(Array(reflection.foreign_key)).each do |primary_key, foreign_key_column|
- record[foreign_key_column] = primary_key
+ foreign_key = Array(reflection.foreign_key)
+ primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
+
+ primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
+ record[foreign_key] = _read_attribute(primary_key)
end
association.set_inverse_instance(record)
end
@@ -534,6 +538,10 @@ def compute_primary_key(reflection, record)
query_constraints
elsif record.class.has_query_constraints? && !reflection.options[:foreign_key]
record.class.query_constraints_list
+ elsif record.class.composite_primary_key?
+ # If record has composite primary key of shape [:, :id], infer primary_key as :id
+ primary_key = record.class.primary_key
+ primary_key.include?("id") ? "id" : primary_key
else
record.class.primary_key
end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
index b2810112d7fde..b40a9e14313b9 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
@@ -633,7 +633,20 @@ def select(sql, name = nil, binds = [], prepare: false, async: false)
end
end
- def sql_for_insert(sql, _pk, binds, _returning)
+ def sql_for_insert(sql, pk, binds, returning) # :nodoc:
+ if supports_insert_returning?
+ if pk.nil?
+ # Extract the table from the insert sql. Yuck.
+ table_ref = extract_table_ref_from_insert_sql(sql)
+ pk = primary_key(table_ref) if table_ref
+ end
+
+ returning_columns = returning || Array(pk)
+
+ returning_columns_statement = returning_columns.map { |c| quote_column_name(c) }.join(", ")
+ sql = "#{sql} RETURNING #{returning_columns_statement}" if returning_columns.any?
+ end
+
[sql, binds]
end
@@ -657,6 +670,12 @@ def arel_from_relation(relation)
relation
end
end
+
+ def extract_table_ref_from_insert_sql(sql)
+ if sql =~ /into\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*/im
+ $1.strip
+ end
+ end
end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
index e5154f47f0b38..86b86f190a21d 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
@@ -16,7 +16,7 @@ def accept(o)
delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql,
:options_include_default?, :supports_indexes_in_create?, :use_foreign_keys?,
:quoted_columns_for_index, :supports_partial_index?, :supports_check_constraints?,
- :supports_index_include?, :supports_exclusion_constraints?, :supports_unique_keys?,
+ :supports_index_include?, :supports_exclusion_constraints?, :supports_unique_constraints?,
:supports_nulls_not_distinct?,
to: :@conn, private: true
@@ -65,8 +65,8 @@ def visit_TableDefinition(o)
statements.concat(o.exclusion_constraints.map { |exc| accept exc })
end
- if supports_unique_keys?
- statements.concat(o.unique_keys.map { |exc| accept exc })
+ if supports_unique_constraints?
+ statements.concat(o.unique_constraints.map { |exc| accept exc })
end
create_sql << "(#{statements.join(', ')})" if statements.present?
@@ -80,10 +80,12 @@ def visit_PrimaryKeyDefinition(o)
end
def visit_ForeignKeyDefinition(o)
+ quoted_columns = Array(o.column).map { |c| quote_column_name(c) }
+ quoted_primary_keys = Array(o.primary_key).map { |c| quote_column_name(c) }
sql = +<<~SQL
CONSTRAINT #{quote_column_name(o.name)}
- FOREIGN KEY (#{quote_column_name(o.column)})
- REFERENCES #{quote_table_name(o.to_table)} (#{quote_column_name(o.primary_key)})
+ FOREIGN KEY (#{quoted_columns.join(", ")})
+ REFERENCES #{quote_table_name(o.to_table)} (#{quoted_primary_keys.join(", ")})
SQL
sql << " #{action_sql('DELETE', o.on_delete)}" if o.on_delete
sql << " #{action_sql('UPDATE', o.on_update)}" if o.on_update
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
index e5e2a20b2ebda..15f06d2419af4 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
@@ -162,7 +162,7 @@ def export_name_on_schema_dump?
def defined_for?(to_table: nil, validate: nil, **options)
(to_table.nil? || to_table.to_s == self.to_table) &&
(validate.nil? || validate == self.options.fetch(:validate, validate)) &&
- options.all? { |k, v| self.options[k].to_s == v.to_s }
+ options.all? { |k, v| Array(self.options[k]).map(&:to_s) == Array(v).map(&:to_s) }
end
private
@@ -348,7 +348,7 @@ def #{column_type}(*names, **options)
# Inside migration files, the +t+ object in {create_table}[rdoc-ref:SchemaStatements#create_table]
# is actually of this type:
#
- # class SomeMigration < ActiveRecord::Migration[7.1]
+ # class SomeMigration < ActiveRecord::Migration[7.2]
# def up
# create_table :foo do |t|
# puts t.class # => "ActiveRecord::ConnectionAdapters::TableDefinition"
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
index 5c4771f4575c7..0a4bb26030799 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
@@ -1100,6 +1100,16 @@ def foreign_keys(table_name)
#
# ALTER TABLE "articles" ADD CONSTRAINT fk_rails_58ca3d3a82 FOREIGN KEY ("author_id") REFERENCES "users" ("lng_id")
#
+ # ====== Creating a composite foreign key
+ #
+ # Assuming "carts" table has "(shop_id, user_id)" as a primary key.
+ #
+ # add_foreign_key :orders, :carts, primary_key: [:shop_id, :user_id]
+ #
+ # generates:
+ #
+ # ALTER TABLE "orders" ADD CONSTRAINT fk_rails_6f5e4cb3a4 FOREIGN KEY ("cart_shop_id", "cart_user_id") REFERENCES "carts" ("shop_id", "user_id")
+ #
# ====== Creating a cascading foreign key
#
# add_foreign_key :articles, :authors, on_delete: :cascade
@@ -1110,9 +1120,11 @@ def foreign_keys(table_name)
#
# The +options+ hash can include the following keys:
# [:column]
- # The foreign key column name on +from_table+. Defaults to to_table.singularize + "_id"
+ # The foreign key column name on +from_table+. Defaults to to_table.singularize + "_id".
+ # Pass an array to create a composite foreign key.
# [:primary_key]
# The primary key column name on +to_table+. Defaults to +id+.
+ # Pass an array to create a composite foreign key.
# [:name]
# The constraint name. Defaults to fk_rails_.
# [:on_delete]
@@ -1195,15 +1207,33 @@ def foreign_key_exists?(from_table, to_table = nil, **options)
foreign_key_for(from_table, to_table: to_table, **options).present?
end
- def foreign_key_column_for(table_name) # :nodoc:
+ def foreign_key_column_for(table_name, column_name) # :nodoc:
name = strip_table_name_prefix_and_suffix(table_name)
- "#{name.singularize}_id"
+ "#{name.singularize}_#{column_name}"
end
def foreign_key_options(from_table, to_table, options) # :nodoc:
options = options.dup
- options[:column] ||= foreign_key_column_for(to_table)
+
+ if options[:primary_key].is_a?(Array)
+ options[:column] ||= options[:primary_key].map do |pk_column|
+ foreign_key_column_for(to_table, pk_column)
+ end
+ else
+ options[:column] ||= foreign_key_column_for(to_table, "id")
+ end
+
options[:name] ||= foreign_key_name(from_table, options)
+
+ if options[:column].is_a?(Array) || options[:primary_key].is_a?(Array)
+ if Array(options[:primary_key]).size != Array(options[:column]).size
+ raise ArgumentError, <<~MSG.squish
+ For composite primary keys, specify :column and :primary_key, where
+ :column must reference all the :primary_key columns from #{to_table.inspect}
+ MSG
+ end
+ end
+
options
end
@@ -1225,12 +1255,16 @@ def check_constraints(table_name)
# The +options+ hash can include the following keys:
# [:name]
# The constraint name. Defaults to chk_rails_.
+ # [:if_not_exists]
+ # Silently ignore if the constraint already exists, rather than raise an error.
# [:validate]
# (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+.
- def add_check_constraint(table_name, expression, **options)
+ def add_check_constraint(table_name, expression, if_not_exists: false, **options)
return unless supports_check_constraints?
options = check_constraint_options(table_name, expression, options)
+ return if if_not_exists && check_constraint_exists?(table_name, **options)
+
at = create_alter_table(table_name)
at.add_check_constraint(expression, options)
@@ -1256,10 +1290,10 @@ def check_constraint_options(table_name, expression, options) # :nodoc:
# The +expression+ parameter will be ignored if present. It can be helpful
# to provide this in a migration's +change+ method so it can be reverted.
# In that case, +expression+ will be used by #add_check_constraint.
- def remove_check_constraint(table_name, expression = nil, **options)
+ def remove_check_constraint(table_name, expression = nil, if_exists: false, **options)
return unless supports_check_constraints?
- return if options[:if_exists] && !check_constraint_exists?(table_name, **options)
+ return if if_exists && !check_constraint_exists?(table_name, **options)
chk_name_to_delete = check_constraint_for!(table_name, expression: expression, **options).name
@@ -1682,7 +1716,8 @@ def strip_table_name_prefix_and_suffix(table_name)
def foreign_key_name(table_name, options)
options.fetch(:name) do
- identifier = "#{table_name}_#{options.fetch(:column)}_fk"
+ columns = Array(options.fetch(:column)).map(&:to_s)
+ identifier = "#{table_name}_#{columns * '_and_'}_fk"
hashed_identifier = OpenSSL::Digest::SHA256.hexdigest(identifier).first(10)
"fk_rails_#{hashed_identifier}"
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
index cf00a9fb7cb0e..9a8d1f9595276 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
@@ -74,6 +74,32 @@ def nullify!
end
end
+ class TransactionInstrumenter
+ def initialize(payload = {})
+ @handle = nil
+ @started = false
+ @payload = nil
+ @base_payload = payload
+ end
+
+ def start
+ return if @started
+ @started = true
+
+ @payload = @base_payload.dup
+ @handle = ActiveSupport::Notifications.instrumenter.build_handle("transaction.active_record", @payload)
+ @handle.start
+ end
+
+ def finish(outcome)
+ return unless @started
+ @started = false
+
+ @payload[:outcome] = outcome
+ @handle.finish
+ end
+ end
+
class NullTransaction # :nodoc:
def initialize; end
def state; end
@@ -104,6 +130,7 @@ def initialize(connection, isolation: nil, joinable: true, run_commit_callbacks:
@run_commit_callbacks = run_commit_callbacks
@lazy_enrollment_records = nil
@dirty = false
+ @instrumenter = TransactionInstrumenter.new(connection: connection)
end
def dirty!
@@ -138,8 +165,13 @@ def restartable?
joinable? && !dirty?
end
+ def incomplete!
+ @instrumenter.finish(:incomplete)
+ end
+
def materialize!
@materialized = true
+ @instrumenter.start
end
def materialized?
@@ -303,7 +335,12 @@ def materialize!
end
def restart
- connection.rollback_to_savepoint(savepoint_name) if materialized?
+ return unless materialized?
+
+ @instrumenter.finish(:restart)
+ @instrumenter.start
+
+ connection.rollback_to_savepoint(savepoint_name)
end
def rollback
@@ -311,11 +348,13 @@ def rollback
connection.rollback_to_savepoint(savepoint_name) if materialized?
end
@state.rollback!
+ @instrumenter.finish(:rollback)
end
def commit
connection.release_savepoint(savepoint_name) if materialized?
@state.commit!
+ @instrumenter.finish(:commit)
end
def full_rollback?; false; end
@@ -336,7 +375,10 @@ def materialize!
def restart
return unless materialized?
+ @instrumenter.finish(:restart)
+
if connection.supports_restart_db_transaction?
+ @instrumenter.start
connection.restart_db_transaction
else
connection.rollback_db_transaction
@@ -347,11 +389,13 @@ def restart
def rollback
connection.rollback_db_transaction if materialized?
@state.full_rollback!
+ @instrumenter.finish(:rollback)
end
def commit
connection.commit_db_transaction if materialized?
@state.full_commit!
+ @instrumenter.finish(:commit)
end
end
@@ -526,7 +570,10 @@ def within_new_transaction(isolation: nil, joinable: true)
end
end
ensure
- @connection.throw_away! unless transaction&.state&.completed?
+ unless transaction&.state&.completed?
+ @connection.throw_away!
+ transaction&.incomplete!
+ end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
index ce8fa3f21e15a..f7cdd02504253 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -498,7 +498,7 @@ def supports_exclusion_constraints?
end
# Does this adapter support creating unique constraints?
- def supports_unique_keys?
+ def supports_unique_constraints?
false
end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
index 9e33a814cd418..8b2ae616f1f45 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
@@ -466,11 +466,13 @@ def foreign_keys(table_name)
scope = quoted_scope(table_name)
+ # MySQL returns 1 row for each column of composite foreign keys.
fk_info = internal_exec_query(<<~SQL, "SCHEMA")
SELECT fk.referenced_table_name AS 'to_table',
fk.referenced_column_name AS 'primary_key',
fk.column_name AS 'column',
fk.constraint_name AS 'name',
+ fk.ordinal_position AS 'position',
rc.update_rule AS 'on_update',
rc.delete_rule AS 'on_delete'
FROM information_schema.referential_constraints rc
@@ -483,15 +485,22 @@ def foreign_keys(table_name)
AND rc.table_name = #{scope[:name]}
SQL
- fk_info.map do |row|
+ grouped_fk = fk_info.group_by { |row| row["name"] }.values.each { |group| group.sort_by! { |row| row["position"] } }
+ grouped_fk.map do |group|
+ row = group.first
options = {
- column: unquote_identifier(row["column"]),
name: row["name"],
- primary_key: row["primary_key"]
+ on_update: extract_foreign_key_action(row["on_update"]),
+ on_delete: extract_foreign_key_action(row["on_delete"])
}
- options[:on_update] = extract_foreign_key_action(row["on_update"])
- options[:on_delete] = extract_foreign_key_action(row["on_delete"])
+ if group.one?
+ options[:column] = unquote_identifier(row["column"])
+ options[:primary_key] = row["primary_key"]
+ else
+ options[:column] = group.map { |row| unquote_identifier(row["column"]) }
+ options[:primary_key] = group.map { |row| row["primary_key"] }
+ end
ForeignKeyDefinition.new(table_name, unquote_identifier(row["to_table"]), options)
end
@@ -522,6 +531,13 @@ def check_constraints(table_name)
expression = row["expression"]
expression = expression[1..-2] if expression.start_with?("(") && expression.end_with?(")")
expression = strip_whitespace_characters(expression)
+
+ unless mariadb?
+ # MySQL returns check constraints expression in an already escaped form.
+ # This leads to duplicate escaping later (e.g. when the expression is used in the SchemaDumper).
+ expression = expression.gsub("\\'", "'")
+ end
+
CheckConstraintDefinition.new(table_name, expression, options)
end
else
diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb
index b35f3d7add57c..cfc351fa4b1cd 100644
--- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb
@@ -36,7 +36,7 @@ def indexes(table_name)
end
if row[:Expression]
- expression = row[:Expression]
+ expression = row[:Expression].gsub("\\'", "'")
expression = +"(#{expression})" unless expression.start_with?("(")
indexes.last[-2] << expression
indexes.last[-1][:expressions] ||= {}
diff --git a/activerecord/lib/active_record/connection_adapters/pool_config.rb b/activerecord/lib/active_record/connection_adapters/pool_config.rb
index 90e3fb7489a28..160cc448513a2 100644
--- a/activerecord/lib/active_record/connection_adapters/pool_config.rb
+++ b/activerecord/lib/active_record/connection_adapters/pool_config.rb
@@ -22,7 +22,7 @@ def discard_pools!
end
def disconnect_all!
- INSTANCES.each_key(&:disconnect!)
+ INSTANCES.each_key { |c| c.disconnect!(automatic_reconnect: true) }
end
end
@@ -44,7 +44,7 @@ def connection_name
end
end
- def disconnect!
+ def disconnect!(automatic_reconnect: false)
ActiveSupport::ForkTracker.check!
return unless @pool
@@ -52,7 +52,7 @@ def disconnect!
synchronize do
return unless @pool
- @pool.automatic_reconnect = false
+ @pool.automatic_reconnect = automatic_reconnect
@pool.disconnect!
end
diff --git a/activerecord/lib/active_record/connection_adapters/pool_manager.rb b/activerecord/lib/active_record/connection_adapters/pool_manager.rb
index 06d8cbc135e81..cb09694327886 100644
--- a/activerecord/lib/active_record/connection_adapters/pool_manager.rb
+++ b/activerecord/lib/active_record/connection_adapters/pool_manager.rb
@@ -8,7 +8,7 @@ def initialize
end
def shard_names
- @role_to_shard_mapping.values.flat_map { |shard_map| shard_map.keys }
+ @role_to_shard_mapping.values.flat_map { |shard_map| shard_map.keys }.uniq
end
def role_names
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
index 4afa3c5adc8fa..ec383d4eac6b1 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
@@ -75,22 +75,6 @@ def exec_delete(sql, name = nil, binds = []) # :nodoc:
end
alias :exec_update :exec_delete
- def sql_for_insert(sql, pk, binds, returning) # :nodoc:
- if pk.nil?
- # Extract the table from the insert sql. Yuck.
- table_ref = extract_table_ref_from_insert_sql(sql)
- pk = primary_key(table_ref) if table_ref
- end
-
- returning_columns = returning || Array(pk)
-
- returning_columns_statement = returning_columns.map { |c| quote_column_name(c) }.join(", ")
- sql = "#{sql} RETURNING #{returning_columns_statement}" if returning_columns.any?
-
- super
- end
- private :sql_for_insert
-
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil) # :nodoc:
if use_insert_returning? || pk == false
super
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb
index 91e591bd9f598..c28312d74fb34 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb
@@ -12,8 +12,8 @@ def visit_AlterTable(o)
sql << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ")
sql << o.exclusion_constraint_adds.map { |con| visit_AddExclusionConstraint con }.join(" ")
sql << o.exclusion_constraint_drops.map { |con| visit_DropExclusionConstraint con }.join(" ")
- sql << o.unique_key_adds.map { |con| visit_AddUniqueKey con }.join(" ")
- sql << o.unique_key_drops.map { |con| visit_DropUniqueKey con }.join(" ")
+ sql << o.unique_constraint_adds.map { |con| visit_AddUniqueConstraint con }.join(" ")
+ sql << o.unique_constraint_drops.map { |con| visit_DropUniqueConstraint con }.join(" ")
end
def visit_AddForeignKey(o)
@@ -49,7 +49,7 @@ def visit_ExclusionConstraintDefinition(o)
sql.join(" ")
end
- def visit_UniqueKeyDefinition(o)
+ def visit_UniqueConstraintDefinition(o)
column_name = Array(o.column).map { |column| quote_column_name(column) }.join(", ")
sql = ["CONSTRAINT"]
@@ -77,11 +77,11 @@ def visit_DropExclusionConstraint(name)
"DROP CONSTRAINT #{quote_column_name(name)}"
end
- def visit_AddUniqueKey(o)
+ def visit_AddUniqueConstraint(o)
"ADD #{accept(o)}"
end
- def visit_DropUniqueKey(name)
+ def visit_DropUniqueConstraint(name)
"DROP CONSTRAINT #{quote_column_name(name)}"
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
index a18413b7fa05d..7f416d582590b 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
@@ -211,7 +211,7 @@ def export_name_on_schema_dump?
end
end
- UniqueKeyDefinition = Struct.new(:table_name, :column, :options) do
+ UniqueConstraintDefinition = Struct.new(:table_name, :column, :options) do
def name
options[:name]
end
@@ -239,12 +239,12 @@ def defined_for?(name: nil, column: nil, **options)
class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
include ColumnMethods
- attr_reader :exclusion_constraints, :unique_keys, :unlogged
+ attr_reader :exclusion_constraints, :unique_constraints, :unlogged
def initialize(*, **)
super
@exclusion_constraints = []
- @unique_keys = []
+ @unique_constraints = []
@unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables
end
@@ -252,8 +252,8 @@ def exclusion_constraint(expression, **options)
exclusion_constraints << new_exclusion_constraint_definition(expression, options)
end
- def unique_key(column_name, **options)
- unique_keys << new_unique_key_definition(column_name, options)
+ def unique_constraint(column_name, **options)
+ unique_constraints << new_unique_constraint_definition(column_name, options)
end
def new_exclusion_constraint_definition(expression, options) # :nodoc:
@@ -261,9 +261,9 @@ def new_exclusion_constraint_definition(expression, options) # :nodoc:
ExclusionConstraintDefinition.new(name, expression, options)
end
- def new_unique_key_definition(column_name, options) # :nodoc:
- options = @conn.unique_key_options(name, column_name, options)
- UniqueKeyDefinition.new(name, column_name, options)
+ def new_unique_constraint_definition(column_name, options) # :nodoc:
+ options = @conn.unique_constraint_options(name, column_name, options)
+ UniqueConstraintDefinition.new(name, column_name, options)
end
def new_column_definition(name, type, **options) # :nodoc:
@@ -315,36 +315,36 @@ def remove_exclusion_constraint(*args)
@base.remove_exclusion_constraint(name, *args)
end
- # Adds an unique constraint.
+ # Adds a unique constraint.
#
- # t.unique_key(:position, name: 'unique_position', deferrable: :deferred)
+ # t.unique_constraint(:position, name: 'unique_position', deferrable: :deferred)
#
- # See {connection.add_unique_key}[rdoc-ref:SchemaStatements#add_unique_key]
- def unique_key(*args)
- @base.add_unique_key(name, *args)
+ # See {connection.add_unique_constraint}[rdoc-ref:SchemaStatements#add_unique_constraint]
+ def unique_constraint(*args)
+ @base.add_unique_constraint(name, *args)
end
# Removes the given unique constraint from the table.
#
- # t.remove_unique_key(name: "unique_position")
+ # t.remove_unique_constraint(name: "unique_position")
#
- # See {connection.remove_unique_key}[rdoc-ref:SchemaStatements#remove_unique_key]
- def remove_unique_key(*args)
- @base.remove_unique_key(name, *args)
+ # See {connection.remove_unique_constraint}[rdoc-ref:SchemaStatements#remove_unique_constraint]
+ def remove_unique_constraint(*args)
+ @base.remove_unique_constraint(name, *args)
end
end
# = Active Record PostgreSQL Adapter Alter \Table
class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable
- attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops, :unique_key_adds, :unique_key_drops
+ attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops, :unique_constraint_adds, :unique_constraint_drops
def initialize(td)
super
@constraint_validations = []
@exclusion_constraint_adds = []
@exclusion_constraint_drops = []
- @unique_key_adds = []
- @unique_key_drops = []
+ @unique_constraint_adds = []
+ @unique_constraint_drops = []
end
def validate_constraint(name)
@@ -359,12 +359,12 @@ def drop_exclusion_constraint(constraint_name)
@exclusion_constraint_drops << constraint_name
end
- def add_unique_key(column_name, options)
- @unique_key_adds << @td.new_unique_key_definition(column_name, options)
+ def add_unique_constraint(column_name, options)
+ @unique_constraint_adds << @td.new_unique_constraint_definition(column_name, options)
end
- def drop_unique_key(unique_key_name)
- @unique_key_drops << unique_key_name
+ def drop_unique_constraint(unique_constraint_name)
+ @unique_constraint_drops << unique_constraint_name
end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb
index a565be72a2375..2e758b39ebdcc 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb
@@ -28,6 +28,17 @@ def types(stream)
end
end
+ def schemas(stream)
+ schema_names = @connection.schema_names - ["public"]
+
+ if schema_names.any?
+ schema_names.sort.each do |name|
+ stream.puts " create_schema #{name.inspect}"
+ end
+ stream.puts
+ end
+ end
+
def exclusion_constraints_in_create(table, stream)
if (exclusion_constraints = @connection.exclusion_constraints(table)).any?
add_exclusion_constraint_statements = exclusion_constraints.map do |exclusion_constraint|
@@ -50,23 +61,23 @@ def exclusion_constraints_in_create(table, stream)
end
end
- def unique_keys_in_create(table, stream)
- if (unique_keys = @connection.unique_keys(table)).any?
- add_unique_key_statements = unique_keys.map do |unique_key|
+ def unique_constraints_in_create(table, stream)
+ if (unique_constraints = @connection.unique_constraints(table)).any?
+ add_unique_constraint_statements = unique_constraints.map do |unique_constraint|
parts = [
- "t.unique_key #{unique_key.column.inspect}"
+ "t.unique_constraint #{unique_constraint.column.inspect}"
]
- parts << "deferrable: #{unique_key.deferrable.inspect}" if unique_key.deferrable
+ parts << "deferrable: #{unique_constraint.deferrable.inspect}" if unique_constraint.deferrable
- if unique_key.export_name_on_schema_dump?
- parts << "name: #{unique_key.name.inspect}"
+ if unique_constraint.export_name_on_schema_dump?
+ parts << "name: #{unique_constraint.name.inspect}"
end
" #{parts.join(', ')}"
end
- stream.puts add_unique_key_statements.sort.join("\n")
+ stream.puts add_unique_constraint_statements.sort.join("\n")
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
index 26d141e71480d..64f303a3e4c99 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
@@ -117,12 +117,7 @@ def indexes(table_name) # :nodoc:
if indkey.include?(0)
columns = expressions
else
- columns = Hash[query(<<~SQL, "SCHEMA")].values_at(*indkey).compact
- SELECT a.attnum, a.attname
- FROM pg_attribute a
- WHERE a.attrelid = #{oid}
- AND a.attnum IN (#{indkey.join(",")})
- SQL
+ columns = column_names_from_column_numbers(oid, indkey)
# prevent INCLUDE columns from being matched
columns.reject! { |c| include_columns.include?(c) }
@@ -536,7 +531,7 @@ def add_foreign_key(from_table, to_table, **options)
def foreign_keys(table_name)
scope = quoted_scope(table_name)
fk_info = internal_exec_query(<<~SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
- SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred
+ SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred, c.conkey, c.confkey, c.conrelid, c.confrelid
FROM pg_constraint c
JOIN pg_class t1 ON c.conrelid = t1.oid
JOIN pg_class t2 ON c.confrelid = t2.oid
@@ -550,10 +545,22 @@ def foreign_keys(table_name)
SQL
fk_info.map do |row|
+ to_table = Utils.unquote_identifier(row["to_table"])
+ conkey = row["conkey"].scan(/\d+/).map(&:to_i)
+ confkey = row["confkey"].scan(/\d+/).map(&:to_i)
+
+ if conkey.size > 1
+ column = column_names_from_column_numbers(row["conrelid"], conkey)
+ primary_key = column_names_from_column_numbers(row["confrelid"], confkey)
+ else
+ column = Utils.unquote_identifier(row["column"])
+ primary_key = row["primary_key"]
+ end
+
options = {
- column: Utils.unquote_identifier(row["column"]),
+ column: column,
name: row["name"],
- primary_key: row["primary_key"]
+ primary_key: primary_key
}
options[:on_delete] = extract_foreign_key_action(row["on_delete"])
@@ -561,7 +568,6 @@ def foreign_keys(table_name)
options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"])
options[:validate] = row["valid"]
- to_table = Utils.unquote_identifier(row["to_table"])
ForeignKeyDefinition.new(table_name, to_table, options)
end
@@ -634,12 +640,12 @@ def exclusion_constraints(table_name)
end
# Returns an array of unique constraints for the given table.
- # The unique constraints are represented as UniqueKeyDefinition objects.
- def unique_keys(table_name)
+ # The unique constraints are represented as UniqueConstraintDefinition objects.
+ def unique_constraints(table_name)
scope = quoted_scope(table_name)
unique_info = internal_exec_query(<<~SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
- SELECT c.conname, c.conindid, c.condeferrable, c.condeferred
+ SELECT c.conname, c.conrelid, c.conkey, c.condeferrable, c.condeferred
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
JOIN pg_namespace n ON n.oid = c.connamespace
@@ -649,21 +655,17 @@ def unique_keys(table_name)
SQL
unique_info.map do |row|
- deferrable = extract_constraint_deferrable(row["condeferrable"], row["condeferred"])
+ conkey = row["conkey"].delete("{}").split(",").map(&:to_i)
+ columns = column_names_from_column_numbers(row["conrelid"], conkey)
- columns = query_values(<<~SQL, "SCHEMA")
- SELECT a.attname
- FROM pg_attribute a
- WHERE a.attrelid = #{row['conindid']}
- ORDER BY a.attnum
- SQL
+ deferrable = extract_constraint_deferrable(row["condeferrable"], row["condeferred"])
options = {
name: row["conname"],
deferrable: deferrable
}
- UniqueKeyDefinition.new(table_name, columns, options)
+ UniqueConstraintDefinition.new(table_name, columns, options)
end
end
@@ -715,7 +717,7 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
# Adds a new unique constraint to the table.
#
- # add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_position"
+ # add_unique_constraint :sections, [:position], deferrable: :deferred, name: "unique_position"
#
# generates:
#
@@ -723,7 +725,7 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
#
# If you want to change an existing unique index to deferrable, you can use :using_index to create deferrable unique constraints.
#
- # add_unique_key :sections, deferrable: :deferred, name: "unique_position", using_index: "index_sections_on_position"
+ # add_unique_constraint :sections, deferrable: :deferred, name: "unique_position", using_index: "index_sections_on_position"
#
# The +options+ hash can include the following keys:
# [:name]
@@ -732,15 +734,15 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
# Specify whether or not the unique constraint should be deferrable. Valid values are +false+ or +:immediate+ or +:deferred+ to specify the default behavior. Defaults to +false+.
# [:using_index]
# To specify an existing unique index name. Defaults to +nil+.
- def add_unique_key(table_name, column_name = nil, **options)
- options = unique_key_options(table_name, column_name, options)
+ def add_unique_constraint(table_name, column_name = nil, **options)
+ options = unique_constraint_options(table_name, column_name, options)
at = create_alter_table(table_name)
- at.add_unique_key(column_name, options)
+ at.add_unique_constraint(column_name, options)
execute schema_creation.accept(at)
end
- def unique_key_options(table_name, column_name, options) # :nodoc:
+ def unique_constraint_options(table_name, column_name, options) # :nodoc:
assert_valid_deferrable(options[:deferrable])
if column_name && options[:using_index]
@@ -748,22 +750,22 @@ def unique_key_options(table_name, column_name, options) # :nodoc:
end
options = options.dup
- options[:name] ||= unique_key_name(table_name, column: column_name, **options)
+ options[:name] ||= unique_constraint_name(table_name, column: column_name, **options)
options
end
# Removes the given unique constraint from the table.
#
- # remove_unique_key :sections, name: "unique_position"
+ # remove_unique_constraint :sections, name: "unique_position"
#
# The +column_name+ parameter will be ignored if present. It can be helpful
# to provide this in a migration's +change+ method so it can be reverted.
- # In that case, +column_name+ will be used by #add_unique_key.
- def remove_unique_key(table_name, column_name = nil, **options)
- unique_name_to_delete = unique_key_for!(table_name, column: column_name, **options).name
+ # In that case, +column_name+ will be used by #add_unique_constraint.
+ def remove_unique_constraint(table_name, column_name = nil, **options)
+ unique_name_to_delete = unique_constraint_for!(table_name, column: column_name, **options).name
at = create_alter_table(table_name)
- at.drop_unique_key(unique_name_to_delete)
+ at.drop_unique_constraint(unique_name_to_delete)
execute schema_creation.accept(at)
end
@@ -871,7 +873,7 @@ def validate_check_constraint(table_name, **options)
validate_constraint table_name, chk_name_to_validate
end
- def foreign_key_column_for(table_name) # :nodoc:
+ def foreign_key_column_for(table_name, column_name) # :nodoc:
_schema, table_name = extract_schema_qualified_name(table_name)
super
end
@@ -1036,7 +1038,7 @@ def exclusion_constraint_for!(table_name, expression: nil, **options)
raise(ArgumentError, "Table '#{table_name}' has no exclusion constraint for #{expression || options}")
end
- def unique_key_name(table_name, **options)
+ def unique_constraint_name(table_name, **options)
options.fetch(:name) do
column_or_index = Array(options[:column] || options[:using_index]).map(&:to_s)
identifier = "#{table_name}_#{column_or_index * '_and_'}_unique"
@@ -1046,13 +1048,13 @@ def unique_key_name(table_name, **options)
end
end
- def unique_key_for(table_name, **options)
- name = unique_key_name(table_name, **options) unless options.key?(:column)
- unique_keys(table_name).detect { |unique_key| unique_key.defined_for?(name: name, **options) }
+ def unique_constraint_for(table_name, **options)
+ name = unique_constraint_name(table_name, **options) unless options.key?(:column)
+ unique_constraints(table_name).detect { |unique_constraint| unique_constraint.defined_for?(name: name, **options) }
end
- def unique_key_for!(table_name, column: nil, **options)
- unique_key_for(table_name, column: column, **options) ||
+ def unique_constraint_for!(table_name, column: nil, **options)
+ unique_constraint_for(table_name, column: column, **options) ||
raise(ArgumentError, "Table '#{table_name}' has no unique constraint for #{column || options}")
end
@@ -1089,6 +1091,15 @@ def extract_schema_qualified_name(string)
name = Utils.extract_schema_qualified_name(string.to_s)
[name.schema, name.identifier]
end
+
+ def column_names_from_column_numbers(table_oid, column_numbers)
+ Hash[query(<<~SQL, "SCHEMA")].values_at(*column_numbers).compact
+ SELECT a.attnum, a.attname
+ FROM pg_attribute a
+ WHERE a.attrelid = #{table_oid}
+ AND a.attnum IN (#{column_numbers.join(", ")})
+ SQL
+ end
end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index 6f3b370e312b2..005047f167ff5 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -226,7 +226,7 @@ def supports_exclusion_constraints?
true
end
- def supports_unique_keys?
+ def supports_unique_constraints?
true
end
@@ -1089,11 +1089,6 @@ def column_definitions(table_name)
SQL
end
- def extract_table_ref_from_insert_sql(sql)
- sql[/into\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*/im]
- $1.strip if $1
- end
-
def arel_visitor
Arel::Visitors::PostgreSQL.new(self)
end
diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb
index edca197789936..e11658083481a 100644
--- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb
+++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb
@@ -471,6 +471,7 @@ def open(filename)
File.atomic_write(filename) do |file|
if File.extname(filename) == ".gz"
zipper = Zlib::GzipWriter.new file
+ zipper.mtime = 0
yield zipper
zipper.flush
zipper.close
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb
index 84d760fa471b5..dcabf64aaab6e 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb
@@ -138,10 +138,6 @@ def execute_batch(statements, name = nil)
end
end
- def last_inserted_id(result)
- @raw_connection.last_insert_row_id
- end
-
def build_fixture_statements(fixture_set)
fixture_set.flat_map do |table_name, fixtures|
next if fixtures.empty?
@@ -152,6 +148,10 @@ def build_fixture_statements(fixture_set)
def build_truncate_statement(table_name)
"DELETE FROM #{quote_table_name(table_name)}"
end
+
+ def returning_column_values(result)
+ result.rows.first
+ end
end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb
index d6c55ddde1a77..71a9b44bb7fae 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb
@@ -5,6 +5,12 @@ module ConnectionAdapters
module SQLite3
# = Active Record SQLite3 Adapter \Table Definition
class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
+ def change_column(column_name, type, **options)
+ name = column_name.to_s
+ @columns_hash[name] = nil
+ column(name, type, **options)
+ end
+
def references(*args, **options)
super(*args, type: :integer, **options)
end
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
index a5b3698ceec67..286cedd155044 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
@@ -102,8 +102,8 @@ def add_check_constraint(table_name, expression, **options)
end
end
- def remove_check_constraint(table_name, expression = nil, **options)
- return if options[:if_exists] && !check_constraint_exists?(table_name, **options)
+ def remove_check_constraint(table_name, expression = nil, if_exists: false, **options)
+ return if if_exists && !check_constraint_exists?(table_name, **options)
check_constraints = check_constraints(table_name)
chk_name_to_delete = check_constraint_for!(table_name, expression: expression, **options).name
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
index 88fdeedd2795e..8a049a8b3685a 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
@@ -126,6 +126,7 @@ def initialize(...)
@config[:strict] = ConnectionAdapters::SQLite3Adapter.strict_strings_by_default unless @config.key?(:strict)
@connection_parameters = @config.merge(database: @config[:database].to_s, results_as_hash: true)
+ @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true
end
def database_exists?
@@ -180,6 +181,10 @@ def supports_common_table_expressions?
database_version >= "3.8.3"
end
+ def supports_insert_returning?
+ database_version >= "3.35.0"
+ end
+
def supports_insert_on_conflict?
database_version >= "3.24.0"
end
@@ -195,6 +200,10 @@ def active?
@raw_connection && !@raw_connection.closed?
end
+ def return_value_after_insert?(column) # :nodoc:
+ column.auto_populated?
+ end
+
alias :reset! :reconnect!
# Disconnects from the database if already connected. Otherwise, this
@@ -328,10 +337,7 @@ def change_column_null(table_name, column_name, null, default = nil) # :nodoc:
def change_column(table_name, column_name, type, **options) # :nodoc:
alter_table(table_name) do |definition|
- definition[column_name].instance_eval do
- self.type = aliased_types(type.to_s, type)
- self.options.merge!(options)
- end
+ definition.change_column(column_name, type, **options)
end
end
@@ -360,14 +366,23 @@ def add_reference(table_name, ref_name, **options) # :nodoc:
alias :add_belongs_to :add_reference
def foreign_keys(table_name)
+ # SQLite returns 1 row for each column of composite foreign keys.
fk_info = internal_exec_query("PRAGMA foreign_key_list(#{quote(table_name)})", "SCHEMA")
- fk_info.map do |row|
+ grouped_fk = fk_info.group_by { |row| row["id"] }.values.each { |group| group.sort_by! { |row| row["seq"] } }
+ grouped_fk.map do |group|
+ row = group.first
options = {
- column: row["from"],
- primary_key: row["to"],
on_delete: extract_foreign_key_action(row["on_delete"]),
on_update: extract_foreign_key_action(row["on_update"])
}
+
+ if group.one?
+ options[:column] = row["from"]
+ options[:primary_key] = row["to"]
+ else
+ options[:column] = group.map { |row| row["from"] }
+ options[:primary_key] = group.map { |row| row["to"] }
+ end
ForeignKeyDefinition.new(table_name, row["table"], options)
end
end
@@ -387,6 +402,7 @@ def build_insert_sql(insert) # :nodoc:
end
end
+ sql << " RETURNING #{insert.returning}" if insert.returning
sql
end
@@ -394,6 +410,10 @@ def shared_cache? # :nodoc:
@config.fetch(:flags, 0).anybits?(::SQLite3::Constants::Open::SHAREDCACHE)
end
+ def use_insert_returning?
+ @use_insert_returning
+ end
+
def get_database_version # :nodoc:
SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)", "SCHEMA"))
end
@@ -445,10 +465,10 @@ def extract_value_from_default(default)
when /^null$/i
nil
# Quoted types
- when /^'(.*)'$/m
+ when /^'([^|]*)'$/m
$1.gsub("''", "'")
# Quoted types
- when /^"(.*)"$/m
+ when /^"([^|]*)"$/m
$1.gsub('""', '"')
# Numeric types
when /\A-?\d+(\.\d*)?\z/
@@ -468,7 +488,7 @@ def extract_default_function(default_value, default)
end
def has_default_function?(default_value, default)
- !default_value && %r{\w+\(.*\)|CURRENT_TIME|CURRENT_DATE|CURRENT_TIMESTAMP}.match?(default)
+ !default_value && %r{\w+\(.*\)|CURRENT_TIME|CURRENT_DATE|CURRENT_TIMESTAMP|\|\|}.match?(default)
end
# See: https://www.sqlite.org/lang_altertable.html
@@ -692,9 +712,40 @@ def reconnect
end
def configure_connection
- @raw_connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout])) if @config[:timeout]
+ if @config[:timeout] && @config[:retries]
+ raise ArgumentError, "Cannot specify both timeout and retries arguments"
+ elsif @config[:timeout]
+ @raw_connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout]))
+ elsif @config[:retries]
+ retries = self.class.type_cast_config_to_integer(@config[:retries])
+ raw_connection.busy_handler do |count|
+ count <= retries
+ end
+ end
+ # Enforce foreign key constraints
+ # https://www.sqlite.org/pragma.html#pragma_foreign_keys
+ # https://www.sqlite.org/foreignkeys.html
raw_execute("PRAGMA foreign_keys = ON", "SCHEMA")
+ unless @memory_database
+ # Journal mode WAL allows for greater concurrency (many readers + one writer)
+ # https://www.sqlite.org/pragma.html#pragma_journal_mode
+ raw_execute("PRAGMA journal_mode = WAL", "SCHEMA")
+ # Set more relaxed level of database durability
+ # 2 = "FULL" (sync on every write), 1 = "NORMAL" (sync every 1000 written pages) and 0 = "NONE"
+ # https://www.sqlite.org/pragma.html#pragma_synchronous
+ raw_execute("PRAGMA synchronous = NORMAL", "SCHEMA")
+ # Set the global memory map so all processes can share some data
+ # https://www.sqlite.org/pragma.html#pragma_mmap_size
+ # https://www.sqlite.org/mmap.html
+ raw_execute("PRAGMA mmap_size = #{128.megabytes}", "SCHEMA")
+ end
+ # Impose a limit on the WAL file to prevent unlimited growth
+ # https://www.sqlite.org/pragma.html#pragma_journal_size_limit
+ raw_execute("PRAGMA journal_size_limit = #{64.megabytes}", "SCHEMA")
+ # Set the local connection cache to 2000 pages
+ # https://www.sqlite.org/pragma.html#pragma_cache_size
+ raw_execute("PRAGMA cache_size = 2000", "SCHEMA")
end
end
ActiveSupport.run_load_hooks(:active_record_sqlite3adapter, SQLite3Adapter)
diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb
index 3000cadfe0602..aa91c74506254 100644
--- a/activerecord/lib/active_record/core.rb
+++ b/activerecord/lib/active_record/core.rb
@@ -344,11 +344,6 @@ def inspect # :nodoc:
end
end
- # Override the default class equality method to provide support for decorated models.
- def ===(object) # :nodoc:
- object.is_a?(self)
- end
-
# Returns an instance of +Arel::Table+ loaded with the current table name.
def arel_table # :nodoc:
@arel_table ||= Arel::Table.new(table_name, klass: self)
@@ -615,7 +610,9 @@ def strict_loading?
#
# user = User.first
# user.strict_loading! # => true
- # user.comments
+ # user.address.city
+ # => ActiveRecord::StrictLoadingViolationError
+ # user.comments.to_a
# => ActiveRecord::StrictLoadingViolationError
#
# ==== Parameters
@@ -629,12 +626,13 @@ def strict_loading?
#
# user = User.first
# user.strict_loading!(false) # => false
- # user.comments
- # => #
+ # user.address.city # => "Tatooine"
+ # user.comments.to_a # => [# "Tatooine"
- # user.comments
+ # user.comments.to_a # => [# ActiveRecord::StrictLoadingViolationError
def strict_loading!(value = true, mode: :all)
unless [:all, :n_plus_one_only].include?(mode)
diff --git a/activerecord/lib/active_record/encryption/encryptable_record.rb b/activerecord/lib/active_record/encryption/encryptable_record.rb
index d65be138d7873..64f81510eb352 100644
--- a/activerecord/lib/active_record/encryption/encryptable_record.rb
+++ b/activerecord/lib/active_record/encryption/encryptable_record.rb
@@ -30,6 +30,10 @@ module EncryptableRecord
# will use the oldest encryption scheme to encrypt new data by default. You can change this by setting
# deterministic: { fixed: false }. That will make it use the newest encryption scheme for encrypting new
# data.
+ # * :support_unencrypted_data - If `config.active_record.encryption.support_unencrypted_data` is +true+,
+ # you can set this to +false+ to opt out of unencrypted data support for this attribute. This is useful for
+ # scenarios where you encrypt one column, and want to disable support for unencrypted data without having to tweak
+ # the global setting.
# * :downcase - When true, it converts the encrypted content to downcase automatically. This allows to
# effectively ignore case when querying data. Notice that the case is lost. Use +:ignore_case+ if you are interested
# in preserving it.
@@ -42,11 +46,11 @@ module EncryptableRecord
# * :previous - List of previous encryption schemes. When provided, they will be used in order when trying to read
# the attribute. Each entry of the list can contain the properties supported by #encrypts. Also, when deterministic
# encryption is used, they will be used to generate additional ciphertexts to check in the queries.
- def encrypts(*names, key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, previous: [], **context_properties)
+ def encrypts(*names, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [], **context_properties)
self.encrypted_attributes ||= Set.new # not using :default because the instance would be shared across classes
names.each do |name|
- encrypt_attribute name, key_provider: key_provider, key: key, deterministic: deterministic, downcase: downcase, ignore_case: ignore_case, previous: previous, **context_properties
+ encrypt_attribute name, key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, downcase: downcase, ignore_case: ignore_case, previous: previous, **context_properties
end
end
@@ -63,9 +67,9 @@ def source_attribute_from_preserved_attribute(attribute_name)
end
private
- def scheme_for(key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, previous: [], **context_properties)
+ def scheme_for(key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [], **context_properties)
ActiveRecord::Encryption::Scheme.new(key_provider: key_provider, key: key, deterministic: deterministic,
- downcase: downcase, ignore_case: ignore_case, **context_properties).tap do |scheme|
+ support_unencrypted_data: support_unencrypted_data, downcase: downcase, ignore_case: ignore_case, **context_properties).tap do |scheme|
scheme.previous_schemes = global_previous_schemes_for(scheme) +
Array.wrap(previous).collect { |scheme_config| ActiveRecord::Encryption::Scheme.new(**scheme_config) }
end
@@ -77,14 +81,14 @@ def global_previous_schemes_for(scheme)
end
end
- def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, downcase: false, ignore_case: false, previous: [], **context_properties)
+ def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [], **context_properties)
encrypted_attributes << name.to_sym
attribute name do |cast_type|
- scheme = scheme_for key_provider: key_provider, key: key, deterministic: deterministic, downcase: downcase, \
- ignore_case: ignore_case, previous: previous, **context_properties
- ActiveRecord::Encryption::EncryptedAttributeType.new(scheme: scheme, cast_type: cast_type,
- default: columns_hash[name.to_s]&.default)
+ scheme = scheme_for key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, \
+ downcase: downcase, ignore_case: ignore_case, previous: previous, **context_properties
+
+ ActiveRecord::Encryption::EncryptedAttributeType.new(scheme: scheme, cast_type: cast_type, default: columns_hash[name.to_s]&.default)
end
preserve_original_encrypted(name) if ignore_case
diff --git a/activerecord/lib/active_record/encryption/encrypted_attribute_type.rb b/activerecord/lib/active_record/encryption/encrypted_attribute_type.rb
index 1d5882f164fdb..7fd792895bfbe 100644
--- a/activerecord/lib/active_record/encryption/encrypted_attribute_type.rb
+++ b/activerecord/lib/active_record/encryption/encrypted_attribute_type.rb
@@ -54,6 +54,10 @@ def previous_types # :nodoc:
@previous_types[support_unencrypted_data?] ||= build_previous_types_for(previous_schemes_including_clean_text)
end
+ def support_unencrypted_data?
+ ActiveRecord::Encryption.config.support_unencrypted_data && scheme.support_unencrypted_data? && !previous_type?
+ end
+
private
def previous_schemes_including_clean_text
previous_schemes.including((clean_text_scheme if support_unencrypted_data?)).compact
@@ -131,10 +135,6 @@ def encryptor
ActiveRecord::Encryption.encryptor
end
- def support_unencrypted_data?
- ActiveRecord::Encryption.config.support_unencrypted_data && !previous_type?
- end
-
def encryption_options
@encryption_options ||= { key_provider: key_provider, cipher_options: { deterministic: deterministic? } }.compact
end
diff --git a/activerecord/lib/active_record/encryption/extended_deterministic_queries.rb b/activerecord/lib/active_record/encryption/extended_deterministic_queries.rb
index 783172d1951d5..bf2fef53bd654 100644
--- a/activerecord/lib/active_record/encryption/extended_deterministic_queries.rb
+++ b/activerecord/lib/active_record/encryption/extended_deterministic_queries.rb
@@ -19,25 +19,26 @@ module Encryption
# * ActiveRecord::Base - Used in Contact.find_by_email_address(...)
# * ActiveRecord::Relation - Used in Contact.internal.find_by_email_address(...)
#
- # ActiveRecord::Base relies on ActiveRecord::Relation (ActiveRecord::QueryMethods) but it does
- # some prepared statements caching. That's why we need to intercept +ActiveRecord::Base+ as soon
- # as it's invoked (so that the proper prepared statement is cached).
- #
- # When modifying this file run performance tests in +test/performance/extended_deterministic_queries_performance_test.rb+ to
- # make sure performance overhead is acceptable.
- #
- # We will extend this to support previous "encryption context" versions in future iterations
- #
- # @TODO Experimental. Support for every kind of query is pending
- # @TODO It should not patch anything if not needed (no previous schemes or no support for previous encryption schemes)
+ # This module is included if `config.active_record.encryption.extend_queries` is `true`.
module ExtendedDeterministicQueries
def self.install_support
+ # ActiveRecord::Base relies on ActiveRecord::Relation (ActiveRecord::QueryMethods) but it does
+ # some prepared statements caching. That's why we need to intercept +ActiveRecord::Base+ as soon
+ # as it's invoked (so that the proper prepared statement is cached).
ActiveRecord::Relation.prepend(RelationQueries)
ActiveRecord::Base.include(CoreQueries)
ActiveRecord::Encryption::EncryptedAttributeType.prepend(ExtendedEncryptableType)
Arel::Nodes::HomogeneousIn.prepend(InWithAdditionalValues)
end
+ # When modifying this file run performance tests in
+ # +activerecord/test/cases/encryption/performance/extended_deterministic_queries_performance_test.rb+
+ # to make sure performance overhead is acceptable.
+ #
+ # @TODO We will extend this to support previous "encryption context" versions in future iterations
+ # @TODO Experimental. Support for every kind of query is pending
+ # @TODO It should not patch anything if not needed (no previous schemes or no support for previous encryption schemes)
+
module EncryptedQuery # :nodoc:
class << self
def process_arguments(owner, args, check_for_additional_values)
diff --git a/activerecord/lib/active_record/encryption/extended_deterministic_uniqueness_validator.rb b/activerecord/lib/active_record/encryption/extended_deterministic_uniqueness_validator.rb
index 96d49fcd868d0..68265bd44c7d9 100644
--- a/activerecord/lib/active_record/encryption/extended_deterministic_uniqueness_validator.rb
+++ b/activerecord/lib/active_record/encryption/extended_deterministic_uniqueness_validator.rb
@@ -14,7 +14,7 @@ def validate_each(record, attribute, value)
klass = record.class
if klass.deterministic_encrypted_attributes&.include?(attribute)
encrypted_type = klass.type_for_attribute(attribute)
- [ encrypted_type, *encrypted_type.previous_types ].each do |type|
+ encrypted_type.previous_types.each do |type|
encrypted_value = type.serialize(value)
ActiveRecord::Encryption.without_encryption do
super(record, attribute, encrypted_value)
diff --git a/activerecord/lib/active_record/encryption/scheme.rb b/activerecord/lib/active_record/encryption/scheme.rb
index 4d63a71f0d7d8..564a1ee69a673 100644
--- a/activerecord/lib/active_record/encryption/scheme.rb
+++ b/activerecord/lib/active_record/encryption/scheme.rb
@@ -10,7 +10,7 @@ module Encryption
class Scheme
attr_accessor :previous_schemes
- def initialize(key_provider: nil, key: nil, deterministic: nil, downcase: nil, ignore_case: nil,
+ def initialize(key_provider: nil, key: nil, deterministic: nil, support_unencrypted_data: nil, downcase: nil, ignore_case: nil,
previous_schemes: nil, **context_properties)
# Initializing all attributes to +nil+ as we want to allow a "not set" semantics so that we
# can merge schemes without overriding values with defaults. See +#merge+
@@ -18,6 +18,7 @@ def initialize(key_provider: nil, key: nil, deterministic: nil, downcase: nil, i
@key_provider_param = key_provider
@key = key
@deterministic = deterministic
+ @support_unencrypted_data = support_unencrypted_data
@downcase = downcase || ignore_case
@ignore_case = ignore_case
@previous_schemes_param = previous_schemes
@@ -39,6 +40,10 @@ def deterministic?
!!@deterministic
end
+ def support_unencrypted_data?
+ @support_unencrypted_data.nil? ? ActiveRecord::Encryption.config.support_unencrypted_data : @support_unencrypted_data
+ end
+
def fixed?
# by default deterministic encryption is fixed
@fixed ||= @deterministic && (!@deterministic.is_a?(Hash) || @deterministic[:fixed])
diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb
index 8b4cf89df4387..d63b391155a4b 100644
--- a/activerecord/lib/active_record/enum.rb
+++ b/activerecord/lib/active_record/enum.rb
@@ -118,6 +118,50 @@ module ActiveRecord
# class Conversation < ActiveRecord::Base
# enum :status, [ :active, :archived ], instance_methods: false
# end
+ #
+ # If you want the enum value to be validated before saving, use the option +:validate+:
+ #
+ # class Conversation < ActiveRecord::Base
+ # enum :status, [ :active, :archived ], validate: true
+ # end
+ #
+ # conversation = Conversation.new
+ #
+ # conversation.status = :unknown
+ # conversation.valid? # => false
+ #
+ # conversation.status = nil
+ # conversation.valid? # => false
+ #
+ # conversation.status = :active
+ # conversation.valid? # => true
+ #
+ # It is also possible to pass additional validation options:
+ #
+ # class Conversation < ActiveRecord::Base
+ # enum :status, [ :active, :archived ], validate: { allow_nil: true }
+ # end
+ #
+ # conversation = Conversation.new
+ #
+ # conversation.status = :unknown
+ # conversation.valid? # => false
+ #
+ # conversation.status = nil
+ # conversation.valid? # => true
+ #
+ # conversation.status = :active
+ # conversation.valid? # => true
+ #
+ # Otherwise +ArgumentError+ will raise:
+ #
+ # class Conversation < ActiveRecord::Base
+ # enum :status, [ :active, :archived ]
+ # end
+ #
+ # conversation = Conversation.new
+ #
+ # conversation.status = :unknown # 'unknown' is not a valid status (ArgumentError)
module Enum
def self.extended(base) # :nodoc:
base.class_attribute(:defined_enums, instance_writer: false, default: {})
@@ -135,10 +179,11 @@ def load_schema! # :nodoc:
class EnumType < Type::Value # :nodoc:
delegate :type, to: :subtype
- def initialize(name, mapping, subtype)
+ def initialize(name, mapping, subtype, raise_on_invalid_values: true)
@name = name
@mapping = mapping
@subtype = subtype
+ @_raise_on_invalid_values = raise_on_invalid_values
end
def cast(value)
@@ -164,6 +209,8 @@ def serializable?(value, &block)
end
def assert_valid_value(value)
+ return unless @_raise_on_invalid_values
+
unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
raise ArgumentError, "'#{value}' is not a valid #{name}"
end
@@ -193,7 +240,7 @@ def inherited(base)
super
end
- def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods: true, **options)
+ def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods: true, validate: false, **options)
assert_valid_enum_definition_values(values)
# statuses = { }
enum_values = ActiveSupport::HashWithIndifferentAccess.new
@@ -209,7 +256,7 @@ def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods
attribute(name, **options) do |subtype|
subtype = subtype.subtype if EnumType === subtype
- EnumType.new(name, enum_values, subtype)
+ EnumType.new(name, enum_values, subtype, raise_on_invalid_values: !validate)
end
value_method_names = []
@@ -241,6 +288,12 @@ def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods
end
end
detect_negative_enum_conditions!(value_method_names) if scopes
+
+ if validate
+ validate = {} unless Hash === validate
+ validates_inclusion_of name, in: enum_values.keys, **validate
+ end
+
enum_values.freeze
end
diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb
index c601df8e17a87..c5ed89cd6c4d8 100644
--- a/activerecord/lib/active_record/errors.rb
+++ b/activerecord/lib/active_record/errors.rb
@@ -483,6 +483,19 @@ class TransactionIsolationError < ActiveRecordError
# TransactionRollbackError will be raised when a transaction is rolled
# back by the database due to a serialization failure or a deadlock.
#
+ # These exceptions should not be generally rescued in nested transaction
+ # blocks, because they have side-effects in the actual enclosing transaction
+ # and internal Active Record state. They can be rescued if you are above the
+ # root transaction block, though.
+ #
+ # In that case, beware of transactional tests, however, because they run test
+ # cases in their own umbrella transaction. If you absolutely need to handle
+ # these exceptions in tests please consider disabling transactional tests in
+ # the affected test class (self.use_transactional_tests = false).
+ #
+ # Due to the aforementioned side-effects, this exception should not be raised
+ # manually by users.
+ #
# See the following:
#
# * https://www.postgresql.org/docs/current/static/transaction-iso.html
@@ -497,11 +510,17 @@ class AsynchronousQueryInsideTransactionError < ActiveRecordError
# SerializationFailure will be raised when a transaction is rolled
# back by the database due to a serialization failure.
+ #
+ # This is a subclass of TransactionRollbackError, please make sure to check
+ # its documentation to be aware of its caveats.
class SerializationFailure < TransactionRollbackError
end
# Deadlocked will be raised when a transaction is rolled
# back by the database when a deadlock is encountered.
+ #
+ # This is a subclass of TransactionRollbackError, please make sure to check
+ # its documentation to be aware of its caveats.
class Deadlocked < TransactionRollbackError
end
diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb
index bae053aeb2c4d..9cb7400126b0b 100644
--- a/activerecord/lib/active_record/fixtures.rb
+++ b/activerecord/lib/active_record/fixtures.rb
@@ -456,6 +456,41 @@ class FixtureClassNotFound < ActiveRecord::ActiveRecordError # :nodoc:
# In the above example, 'base' will be ignored when creating fixtures.
# This can be used for common attributes inheriting.
#
+ # == Composite Primary Key Fixtures
+ #
+ # Fixtures for composite primary key tables are fairly similar to normal tables.
+ # When using an id column, the column may be omitted as usual:
+ #
+ # # app/models/book.rb
+ # class Book < ApplicationRecord
+ # self.primary_key = [:author_id, :id]
+ # belongs_to :author
+ # end
+ #
+ # # books.yml
+ # alices_adventure_in_wonderland:
+ # author_id: <%= ActiveRecord::FixtureSet.identify(:lewis_carroll) %>
+ # title: "Alice's Adventures in Wonderland"
+ #
+ # However, in order to support composite primary key relationships,
+ # you must use the `composite_identify` method:
+ #
+ # # app/models/book_orders.rb
+ # class BookOrder < ApplicationRecord
+ # self.primary_key = [:shop_id, :id]
+ # belongs_to :order, query_constraints: [:shop_id, :order_id]
+ # belongs_to :book, query_constraints: [:author_id, :book_id]
+ # end
+ #
+ # # book_orders.yml
+ # alices_adventure_in_wonderland_in_books:
+ # author: lewis_carroll
+ # book_id: <%= ActiveRecord::FixtureSet.composite_identify(
+ # :alices_adventure_in_wonderland, Book.primary_key)[:id] %>
+ # shop: book_store
+ # order_id: <%= ActiveRecord::FixtureSet.composite_identify(
+ # :books, Order.primary_key)[:id] %>
+ #
# == Configure the fixture model class
#
# It's possible to set the fixture's model class directly in the YAML file.
diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb
index 312c6807102b4..669a6d2b28ae7 100644
--- a/activerecord/lib/active_record/gem_version.rb
+++ b/activerecord/lib/active_record/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb
index 27e3e833ce439..d3fc3b538c120 100644
--- a/activerecord/lib/active_record/migration.rb
+++ b/activerecord/lib/active_record/migration.rb
@@ -20,7 +20,7 @@ def initialize(message = nil)
# For example the following migration is not reversible.
# Rolling back this migration will raise an ActiveRecord::IrreversibleMigration error.
#
- # class IrreversibleMigrationExample < ActiveRecord::Migration[7.1]
+ # class IrreversibleMigrationExample < ActiveRecord::Migration[7.2]
# def change
# create_table :distributors do |t|
# t.string :zipcode
@@ -38,7 +38,7 @@ def initialize(message = nil)
#
# 1. Define #up and #down methods instead of #change:
#
- # class ReversibleMigrationExample < ActiveRecord::Migration[7.1]
+ # class ReversibleMigrationExample < ActiveRecord::Migration[7.2]
# def up
# create_table :distributors do |t|
# t.string :zipcode
@@ -63,7 +63,7 @@ def initialize(message = nil)
#
# 2. Use the #reversible method in #change method:
#
- # class ReversibleMigrationExample < ActiveRecord::Migration[7.1]
+ # class ReversibleMigrationExample < ActiveRecord::Migration[7.2]
# def change
# create_table :distributors do |t|
# t.string :zipcode
@@ -234,7 +234,7 @@ def initialize
#
# Example of a simple migration:
#
- # class AddSsl < ActiveRecord::Migration[7.1]
+ # class AddSsl < ActiveRecord::Migration[7.2]
# def up
# add_column :accounts, :ssl_enabled, :boolean, default: true
# end
@@ -254,7 +254,7 @@ def initialize
#
# Example of a more complex migration that also needs to initialize data:
#
- # class AddSystemSettings < ActiveRecord::Migration[7.1]
+ # class AddSystemSettings < ActiveRecord::Migration[7.2]
# def up
# create_table :system_settings do |t|
# t.string :name
@@ -382,7 +382,7 @@ def initialize
# bin/rails generate migration add_fieldname_to_tablename fieldname:string
#
# This will generate the file timestamp_add_fieldname_to_tablename.rb, which will look like this:
- # class AddFieldnameToTablename < ActiveRecord::Migration[7.1]
+ # class AddFieldnameToTablename < ActiveRecord::Migration[7.2]
# def change
# add_column :tablenames, :fieldname, :string
# end
@@ -408,7 +408,7 @@ def initialize
#
# Not all migrations change the schema. Some just fix the data:
#
- # class RemoveEmptyTags < ActiveRecord::Migration[7.1]
+ # class RemoveEmptyTags < ActiveRecord::Migration[7.2]
# def up
# Tag.all.each { |tag| tag.destroy if tag.pages.empty? }
# end
@@ -421,7 +421,7 @@ def initialize
#
# Others remove columns when they migrate up instead of down:
#
- # class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration[7.1]
+ # class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration[7.2]
# def up
# remove_column :items, :incomplete_items_count
# remove_column :items, :completed_items_count
@@ -435,7 +435,7 @@ def initialize
#
# And sometimes you need to do something in SQL not abstracted directly by migrations:
#
- # class MakeJoinUnique < ActiveRecord::Migration[7.1]
+ # class MakeJoinUnique < ActiveRecord::Migration[7.2]
# def up
# execute "ALTER TABLE `pages_linked_pages` ADD UNIQUE `page_id_linked_page_id` (`page_id`,`linked_page_id`)"
# end
@@ -452,7 +452,7 @@ def initialize
# Base#reset_column_information in order to ensure that the model has the
# latest column data from after the new column was added. Example:
#
- # class AddPeopleSalary < ActiveRecord::Migration[7.1]
+ # class AddPeopleSalary < ActiveRecord::Migration[7.2]
# def up
# add_column :people, :salary, :integer
# Person.reset_column_information
@@ -510,7 +510,7 @@ def initialize
# To define a reversible migration, define the +change+ method in your
# migration like this:
#
- # class TenderloveMigration < ActiveRecord::Migration[7.1]
+ # class TenderloveMigration < ActiveRecord::Migration[7.2]
# def change
# create_table(:horses) do |t|
# t.column :content, :text
@@ -540,7 +540,7 @@ def initialize
# can't execute inside a transaction though, and for these situations
# you can turn the automatic transactions off.
#
- # class ChangeEnum < ActiveRecord::Migration[7.1]
+ # class ChangeEnum < ActiveRecord::Migration[7.2]
# disable_ddl_transaction!
#
# def up
@@ -810,7 +810,7 @@ def execution_strategy
# and create the table 'apples' on the way up, and the reverse
# on the way down.
#
- # class FixTLMigration < ActiveRecord::Migration[7.1]
+ # class FixTLMigration < ActiveRecord::Migration[7.2]
# def change
# revert do
# create_table(:horses) do |t|
@@ -829,7 +829,7 @@ def execution_strategy
#
# require_relative "20121212123456_tenderlove_migration"
#
- # class FixupTLMigration < ActiveRecord::Migration[7.1]
+ # class FixupTLMigration < ActiveRecord::Migration[7.2]
# def change
# revert TenderloveMigration
#
@@ -880,7 +880,7 @@ def down
# when the three columns 'first_name', 'last_name' and 'full_name' exist,
# even when migrating down:
#
- # class SplitNameMigration < ActiveRecord::Migration[7.1]
+ # class SplitNameMigration < ActiveRecord::Migration[7.2]
# def change
# add_column :users, :first_name, :string
# add_column :users, :last_name, :string
@@ -908,7 +908,7 @@ def reversible
# In the following example, the new column +published+ will be given
# the value +true+ for all existing records.
#
- # class AddPublishedToPosts < ActiveRecord::Migration[7.1]
+ # class AddPublishedToPosts < ActiveRecord::Migration[7.2]
# def change
# add_column :posts, :published, :boolean, default: false
# up_only do
diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb
index b26a76f9d21bc..656bbaa085184 100644
--- a/activerecord/lib/active_record/migration/command_recorder.rb
+++ b/activerecord/lib/active_record/migration/command_recorder.rb
@@ -12,7 +12,7 @@ class Migration
# * add_foreign_key
# * add_check_constraint
# * add_exclusion_constraint
- # * add_unique_key
+ # * add_unique_constraint
# * add_index
# * add_reference
# * add_timestamps
@@ -33,7 +33,7 @@ class Migration
# * remove_foreign_key (must supply a second table)
# * remove_check_constraint
# * remove_exclusion_constraint
- # * remove_unique_key
+ # * remove_unique_constraint
# * remove_index
# * remove_reference
# * remove_timestamps
@@ -53,7 +53,7 @@ class CommandRecorder
:change_column_comment, :change_table_comment,
:add_check_constraint, :remove_check_constraint,
:add_exclusion_constraint, :remove_exclusion_constraint,
- :add_unique_key, :remove_unique_key,
+ :add_unique_constraint, :remove_unique_constraint,
:create_enum, :drop_enum, :rename_enum, :add_enum_value, :rename_enum_value,
]
include JoinTable
@@ -161,7 +161,7 @@ module StraightReversions # :nodoc:
add_foreign_key: :remove_foreign_key,
add_check_constraint: :remove_check_constraint,
add_exclusion_constraint: :remove_exclusion_constraint,
- add_unique_key: :remove_unique_key,
+ add_unique_constraint: :remove_unique_constraint,
enable_extension: :disable_extension,
create_enum: :drop_enum
}.each do |cmd, inv|
@@ -308,12 +308,19 @@ def invert_change_table_comment(args)
end
def invert_add_check_constraint(args)
- args.last.delete(:validate) if args.last.is_a?(Hash)
+ if (options = args.last).is_a?(Hash)
+ options.delete(:validate)
+ options[:if_exists] = options.delete(:if_not_exists) if options.key?(:if_not_exists)
+ end
super
end
def invert_remove_check_constraint(args)
raise ActiveRecord::IrreversibleMigration, "remove_check_constraint is only reversible if given an expression." if args.size < 2
+
+ if (options = args.last).is_a?(Hash)
+ options[:if_not_exists] = options.delete(:if_exists) if options.key?(:if_exists)
+ end
super
end
@@ -322,17 +329,17 @@ def invert_remove_exclusion_constraint(args)
super
end
- def invert_add_unique_key(args)
+ def invert_add_unique_constraint(args)
options = args.dup.extract_options!
- raise ActiveRecord::IrreversibleMigration, "add_unique_key is not reversible if given an using_index." if options[:using_index]
+ raise ActiveRecord::IrreversibleMigration, "add_unique_constraint is not reversible if given an using_index." if options[:using_index]
super
end
- def invert_remove_unique_key(args)
+ def invert_remove_unique_constraint(args)
_table, columns = args.dup.tap(&:extract_options!)
- raise ActiveRecord::IrreversibleMigration, "remove_unique_key is only reversible if given an column_name." if columns.blank?
+ raise ActiveRecord::IrreversibleMigration, "remove_unique_constraint is only reversible if given an column_name." if columns.blank?
super
end
diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb
index 29cd887ee1d60..4d5f50cf34aee 100644
--- a/activerecord/lib/active_record/migration/compatibility.rb
+++ b/activerecord/lib/active_record/migration/compatibility.rb
@@ -21,8 +21,7 @@ def self.find(version)
# New migration functionality that will never be backward compatible should be added directly to `ActiveRecord::Migration`.
#
# There are classes for each prior Rails version. Each class descends from the *next* Rails version, so:
- # 7.0 < 7.1
- # 5.2 < 6.0 < 6.1 < 7.0 < 7.1
+ # 5.2 < 6.0 < 6.1 < 7.0 < 7.1 < 7.2
#
# If you are introducing new migration functionality that should only apply from Rails 7 onward, then you should
# find the class that immediately precedes it (6.1), and override the relevant migration methods to undo your changes.
@@ -30,7 +29,10 @@ def self.find(version)
# For example, Rails 6 added a default value for the `precision` option on datetime columns. So in this file, the `V5_2`
# class sets the value of `precision` to `nil` if it's not explicitly provided. This way, the default value will not apply
# for migrations written for 5.2, but will for migrations written for 6.0.
- V7_1 = Current
+ V7_2 = Current
+
+ class V7_1 < V7_2
+ end
class V7_0 < V7_1
module LegacyIndexName
diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb
index 4384ec7c5db7d..a7c988a616c94 100644
--- a/activerecord/lib/active_record/model_schema.rb
+++ b/activerecord/lib/active_record/model_schema.rb
@@ -6,6 +6,13 @@ module ActiveRecord
module ModelSchema
extend ActiveSupport::Concern
+ ##
+ # :method: id_value
+ # :call-seq: id_value
+ #
+ # Returns the underlying column value for a column named "id". Useful when defining
+ # a composite primary key including an "id" column so that the value is readable.
+
##
# :singleton-method: primary_key_prefix_type
# :call-seq: primary_key_prefix_type
@@ -518,7 +525,7 @@ def content_columns
# when just after creating a table you want to populate it with some default
# values, e.g.:
#
- # class CreateJobLevels < ActiveRecord::Migration[7.1]
+ # class CreateJobLevels < ActiveRecord::Migration[7.2]
# def up
# create_table :job_levels do |t|
# t.integer :id
diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb
index 041989837796f..2dbc4c82197ba 100644
--- a/activerecord/lib/active_record/nested_attributes.rb
+++ b/activerecord/lib/active_record/nested_attributes.rb
@@ -355,12 +355,17 @@ def accepts_nested_attributes_for(*attr_names)
options.update(attr_names.extract_options!)
options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
+ options[:class] = self
attr_names.each do |association_name|
if reflection = _reflect_on_association(association_name)
reflection.autosave = true
define_autosave_validation_callbacks(reflection)
+ if nested_attributes_options.dig(association_name.to_sym, :class) == self
+ raise ArgumentError, "Already declared #{association_name} as an accepts_nested_attributes association for this class."
+ end
+
nested_attributes_options = self.nested_attributes_options.dup
nested_attributes_options[association_name.to_sym] = options
self.nested_attributes_options = nested_attributes_options
diff --git a/activerecord/lib/active_record/normalization.rb b/activerecord/lib/active_record/normalization.rb
index 41687a92c973c..459d8982c250e 100644
--- a/activerecord/lib/active_record/normalization.rb
+++ b/activerecord/lib/active_record/normalization.rb
@@ -32,7 +32,7 @@ module ClassMethods
# Declares a normalization for one or more attributes. The normalization
# is applied when the attribute is assigned or updated, and the normalized
# value will be persisted to the database. The normalization is also
- # applied to the corresponding keyword argument of finder methods. This
+ # applied to the corresponding keyword argument of query methods. This
# allows a record to be created and later queried using unnormalized
# values.
#
@@ -51,7 +51,8 @@ module ClassMethods
#
# ==== Options
#
- # * +:with+ - The normalization to apply.
+ # * +:with+ - Any callable object that accepts the attribute's value as
+ # its sole argument, and returns it normalized.
# * +:apply_to_nil+ - Whether to apply the normalization to +nil+ values.
# Defaults to +false+.
#
@@ -69,6 +70,9 @@ module ClassMethods
# user.email # => "cruise-control@example.com"
# user.email_before_type_cast # => "cruise-control@example.com"
#
+ # User.where(email: "\tCRUISE-CONTROL@EXAMPLE.COM ").count # => 1
+ # User.where(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]).count # => 0
+ #
# User.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
# User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
#
diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb
index 6edd80a5f2a04..61f809487ad1e 100644
--- a/activerecord/lib/active_record/persistence.rb
+++ b/activerecord/lib/active_record/persistence.rb
@@ -1272,7 +1272,7 @@ def verify_readonly_attribute(name)
def _raise_record_not_destroyed
@_association_destroy_exception ||= nil
key = self.class.primary_key
- raise @_association_destroy_exception || RecordNotDestroyed.new("Failed to destroy #{self.class} with #{key}=#{send(key)}", self)
+ raise @_association_destroy_exception || RecordNotDestroyed.new("Failed to destroy #{self.class} with #{key}=#{id}", self)
ensure
@_association_destroy_exception = nil
end
diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb
index ee23f07b5324a..fc2f6fdc3f060 100644
--- a/activerecord/lib/active_record/railtie.rb
+++ b/activerecord/lib/active_record/railtie.rb
@@ -38,6 +38,7 @@ class Railtie < Rails::Railtie # :nodoc:
config.active_record.cache_query_log_tags = false
config.active_record.raise_on_assign_to_attr_readonly = false
config.active_record.belongs_to_required_validates_foreign_key = true
+ config.active_record.generate_secure_token_on = :create
config.active_record.queues = ActiveSupport::InheritableOptions.new
@@ -66,7 +67,7 @@ class Railtie < Rails::Railtie # :nodoc:
unless ActiveSupport::Logger.logger_outputs_to?(Rails.logger, STDERR, STDOUT)
console = ActiveSupport::Logger.new(STDERR)
console.level = Rails.logger.level
- Rails.logger.extend ActiveSupport::Logger.broadcast console
+ Rails.logger = ActiveSupport::BroadcastLogger.new(Rails.logger, console)
end
ActiveRecord.verbose_query_logs = false
end
diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb
index 3dcb4adb4eeb9..67c6f70867487 100644
--- a/activerecord/lib/active_record/reflection.rb
+++ b/activerecord/lib/active_record/reflection.rb
@@ -500,7 +500,6 @@ def join_table
def foreign_key(infer_from_inverse_of: true)
@foreign_key ||= if options[:query_constraints]
- # composite foreign keys support
options[:query_constraints].map { |fk| fk.to_s.freeze }.freeze
elsif options[:foreign_key]
options[:foreign_key].to_s
@@ -508,7 +507,7 @@ def foreign_key(infer_from_inverse_of: true)
derived_fk = derive_foreign_key(infer_from_inverse_of: infer_from_inverse_of)
if active_record.has_query_constraints?
- derived_fk = derive_fk_query_constraints(active_record, derived_fk)
+ derived_fk = derive_fk_query_constraints(derived_fk)
end
derived_fk
@@ -533,6 +532,10 @@ def active_record_primary_key
end
elsif active_record.has_query_constraints? || options[:query_constraints]
active_record.query_constraints_list
+ elsif active_record.composite_primary_key?
+ # If active_record has composite primary key of shape [:, :id], infer primary_key as :id
+ primary_key = primary_key(active_record)
+ primary_key.include?("id") ? "id" : primary_key.freeze
else
primary_key(active_record).freeze
end
@@ -767,13 +770,13 @@ def derive_foreign_key(infer_from_inverse_of: true)
end
end
- def derive_fk_query_constraints(klass, foreign_key)
- primary_query_constraints = klass.query_constraints_list
- owner_pk = klass.primary_key
+ def derive_fk_query_constraints(foreign_key)
+ primary_query_constraints = active_record.query_constraints_list
+ owner_pk = active_record.primary_key
if primary_query_constraints.size != 2
raise ArgumentError, <<~MSG.squish
- The query constraints list on the `#{klass}` model has more than 2
+ The query constraints list on the `#{active_record}` model has more than 2
attributes. Active Record is unable to derive the query constraints
for the association. You need to explicitly define the query constraints
for this association.
@@ -782,19 +785,13 @@ def derive_fk_query_constraints(klass, foreign_key)
if !primary_query_constraints.include?(owner_pk)
raise ArgumentError, <<~MSG.squish
- The query constraints on the `#{klass}` model does not include the primary
+ The query constraints on the `#{active_record}` model does not include the primary
key so Active Record is unable to derive the foreign key constraints for
the association. You need to explicitly define the query constraints for this
association.
MSG
end
- # The primary key and foreign key are both already in the query constraints
- # so we don't want to derive the key. In this case we want a single key.
- if primary_query_constraints.include?(owner_pk) && primary_query_constraints.include?(foreign_key)
- return foreign_key
- end
-
first_key, last_key = primary_query_constraints
if first_key == owner_pk
@@ -804,7 +801,7 @@ def derive_fk_query_constraints(klass, foreign_key)
else
raise ArgumentError, <<~MSG.squish
Active Record couldn't correctly interpret the query constraints
- for the `#{klass}` model. The query constraints on `#{klass}` are
+ for the `#{active_record}` model. The query constraints on `#{active_record}` are
`#{primary_query_constraints}` and the foreign key is `#{foreign_key}`.
You need to explicitly set the query constraints for this association.
MSG
@@ -859,10 +856,14 @@ def association_class
# klass option is necessary to support loading polymorphic associations
def association_primary_key(klass = nil)
- if !polymorphic? && ((klass || self.klass).has_query_constraints? || options[:query_constraints])
- (klass || self.klass).composite_query_constraints_list
- elsif primary_key = options[:primary_key]
+ if primary_key = options[:primary_key]
@association_primary_key ||= -primary_key.to_s
+ elsif !polymorphic? && ((klass || self.klass).has_query_constraints? || options[:query_constraints])
+ (klass || self.klass).composite_query_constraints_list
+ elsif (klass || self.klass).composite_primary_key?
+ # If klass has composite primary key of shape [:, :id], infer primary_key as :id
+ primary_key = (klass || self.klass).primary_key
+ primary_key.include?("id") ? "id" : primary_key
else
primary_key(klass || self.klass)
end
diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb
index 3f33b175c69f2..425b2c63df96b 100644
--- a/activerecord/lib/active_record/relation/finder_methods.rb
+++ b/activerecord/lib/active_record/relation/finder_methods.rb
@@ -6,7 +6,9 @@ module ActiveRecord
module FinderMethods
ONE_AS_ONE = "1 AS one"
- # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]).
+ # Find by id - This can either be a specific id (ID), a list of ids (ID, ID, ID), or an array of ids ([ID, ID, ID]).
+ # `ID` refers to an "identifier". For models with a single-column primary key, `ID` will be a single value,
+ # and for models with a composite primary key, it will be an array of values.
# If one or more records cannot be found for the requested ids, then ActiveRecord::RecordNotFound will be raised.
# If the primary key is an integer, find by id coerces its arguments by using +to_i+.
#
@@ -14,10 +16,31 @@ module FinderMethods
# Person.find("1") # returns the object for ID = 1
# Person.find("31-sarah") # returns the object for ID = 31
# Person.find(1, 2, 6) # returns an array for objects with IDs in (1, 2, 6)
- # Person.find([7, 17]) # returns an array for objects with IDs in (7, 17)
+ # Person.find([7, 17]) # returns an array for objects with IDs in (7, 17), or with composite primary key [7, 17]
# Person.find([1]) # returns an array for the object with ID = 1
# Person.where("administrator = 1").order("created_on DESC").find(1)
#
+ # ==== Find a record for a composite primary key model
+ # TravelRoute.primary_key = [:origin, :destination]
+ #
+ # TravelRoute.find(["Ottawa", "London"])
+ # => #
+ #
+ # TravelRoute.find([["Paris", "Montreal"]])
+ # => [#]
+ #
+ # TravelRoute.find(["New York", "Las Vegas"], ["New York", "Portland"])
+ # => [
+ # #,
+ # #
+ # ]
+ #
+ # TravelRoute.find([["Berlin", "London"], ["Barcelona", "Lisbon"]])
+ # => [
+ # #,
+ # #
+ # ]
+ #
# NOTE: The returned records are in the same order as the ids you provide.
# If you want the results to be sorted by database, you can use ActiveRecord::QueryMethods#where
# method and provide an explicit ActiveRecord::QueryMethods#order option.
@@ -504,12 +527,7 @@ def find_one(id)
def find_some(ids)
return find_some_ordered(ids) unless order_values.present?
- relation = if klass.composite_primary_key?
- ids.map { |values_set| where(primary_key.zip(values_set).to_h) }.inject(&:or)
- else
- where(primary_key => ids)
- end
-
+ relation = where(primary_key => ids)
relation = relation.select(table[primary_key]) unless select_values.empty?
result = relation.to_a
@@ -536,11 +554,7 @@ def find_some_ordered(ids)
ids = ids.slice(offset_value || 0, limit_value || ids.size) || []
relation = except(:limit, :offset)
- relation = if klass.composite_primary_key?
- ids.map { |values_set| relation.where(primary_key.zip(values_set).to_h) }.inject(&:or)
- else
- relation.where(primary_key => ids)
- end
+ relation = relation.where(primary_key => ids)
relation = relation.select(table[primary_key]) unless select_values.empty?
result = relation.records
diff --git a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb
index fd2762959a644..8f5c68131046d 100644
--- a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb
+++ b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb
@@ -34,19 +34,17 @@ def primary_key(value)
end
def klass(value)
- case value
- when Base
+ if value.is_a?(Base)
value.class
- when Relation
+ elsif value.is_a?(Relation)
value.klass
end
end
def convert_to_id(value)
- case value
- when Base
+ if value.is_a?(Base)
value._read_attribute(primary_key(value))
- when Relation
+ elsif value.is_a?(Relation)
value.select(primary_key(value))
else
value
diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb
index d0c535266db28..a545b0cbd0c07 100644
--- a/activerecord/lib/active_record/relation/query_methods.rb
+++ b/activerecord/lib/active_record/relation/query_methods.rb
@@ -883,6 +883,12 @@ def left_outer_joins!(*args) # :nodoc:
# PriceEstimate.where(estimate_of: treasure)
# PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: treasure)
#
+ # Hash conditions may also be specified in a tuple-like syntax. Hash keys may be
+ # an array of columns with an array of tuples as values.
+ #
+ # Article.where([:author_id, :id] => [[15, 1], [15, 2]])
+ # # SELECT * FROM articles WHERE author_id = 15 AND id = 1 OR author_id = 15 AND id = 2
+ #
# === Joins
#
# If the relation is the result of a join, you may create a condition which uses any of the
diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb
index b55d82c3095df..f2fcc0095e61a 100644
--- a/activerecord/lib/active_record/relation/spawn_methods.rb
+++ b/activerecord/lib/active_record/relation/spawn_methods.rb
@@ -42,6 +42,21 @@ def merge(other, *rest)
def merge!(other, *rest) # :nodoc:
options = rest.extract_options!
+
+ if options.key?(:rewhere)
+ if options[:rewhere]
+ ActiveRecord.deprecator.warn(<<-MSG.squish)
+ Specifying `Relation#merge(rewhere: true)` is deprecated, as that has now been
+ the default since Rails 7.0. Setting the rewhere option will error in Rails 7.2
+ MSG
+ else
+ ActiveRecord.deprecator.warn(<<-MSG.squish)
+ `Relation#merge(rewhere: false)` is deprecated without replacement,
+ and will be removed in Rails 7.2
+ MSG
+ end
+ end
+
if other.is_a?(Hash)
Relation::HashMerger.new(self, other, options[:rewhere]).merge
elsif other.is_a?(Relation)
diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb
index 0d6cd96ecddec..18e81b3f95d60 100644
--- a/activerecord/lib/active_record/schema_dumper.rb
+++ b/activerecord/lib/active_record/schema_dumper.rb
@@ -57,6 +57,7 @@ def generate_options(config)
def dump(stream)
header(stream)
+ schemas(stream)
extensions(stream)
types(stream)
tables(stream)
@@ -119,6 +120,10 @@ def extensions(stream)
def types(stream)
end
+ # schemas are only supported by PostgreSQL
+ def schemas(stream)
+ end
+
def tables(stream)
sorted_tables = @connection.tables.sort
@@ -188,7 +193,7 @@ def table(table, stream)
indexes_in_create(table, tbl)
check_constraints_in_create(table, tbl) if @connection.supports_check_constraints?
exclusion_constraints_in_create(table, tbl) if @connection.supports_exclusion_constraints?
- unique_keys_in_create(table, tbl) if @connection.supports_unique_keys?
+ unique_constraints_in_create(table, tbl) if @connection.supports_unique_constraints?
tbl.puts " end"
tbl.puts
@@ -224,10 +229,10 @@ def indexes_in_create(table, stream)
indexes = indexes.reject { |index| exclusion_constraint_names.include?(index.name) }
end
- if @connection.supports_unique_keys? && (unique_keys = @connection.unique_keys(table)).any?
- unique_key_names = unique_keys.collect(&:name)
+ if @connection.supports_unique_constraints? && (unique_constraints = @connection.unique_constraints(table)).any?
+ unique_constraint_names = unique_constraints.collect(&:name)
- indexes = indexes.reject { |index| unique_key_names.include?(index.name) }
+ indexes = indexes.reject { |index| unique_constraint_names.include?(index.name) }
end
index_statements = indexes.map do |index|
@@ -283,7 +288,7 @@ def foreign_keys(table, stream)
remove_prefix_and_suffix(foreign_key.to_table).inspect,
]
- if foreign_key.column != @connection.foreign_key_column_for(foreign_key.to_table)
+ if foreign_key.column != @connection.foreign_key_column_for(foreign_key.to_table, "id")
parts << "column: #{foreign_key.column.inspect}"
end
diff --git a/activerecord/lib/active_record/secure_token.rb b/activerecord/lib/active_record/secure_token.rb
index d2aa81f559791..fea4437cd1d20 100644
--- a/activerecord/lib/active_record/secure_token.rb
+++ b/activerecord/lib/active_record/secure_token.rb
@@ -40,9 +40,10 @@ module ClassMethods
# The callback when the value is generated. When called with on:
# :initialize, the value is generated in an
# after_initialize callback, otherwise the value will be used
- # in a before_ callback. It will default to :create.
- #
- def has_secure_token(attribute = :token, length: MINIMUM_TOKEN_LENGTH, on: :create)
+ # in a before_ callback. When not specified, +:on+ will use the value of
+ # config.active_record.generate_secure_token_on, which defaults to +:initialize+
+ # starting in \Rails 7.1.
+ def has_secure_token(attribute = :token, length: MINIMUM_TOKEN_LENGTH, on: ActiveRecord.generate_secure_token_on)
if length < MINIMUM_TOKEN_LENGTH
raise MinimumLengthError, "Token requires a minimum length of #{MINIMUM_TOKEN_LENGTH} characters."
end
@@ -51,7 +52,9 @@ def has_secure_token(attribute = :token, length: MINIMUM_TOKEN_LENGTH, on: :crea
require "active_support/core_ext/securerandom"
define_method("regenerate_#{attribute}") { update! attribute => self.class.generate_unique_secure_token(length: length) }
set_callback on, on == :initialize ? :after : :before do
- send("#{attribute}=", self.class.generate_unique_secure_token(length: length)) unless send("#{attribute}?")
+ if new_record? && !query_attribute(attribute)
+ write_attribute(attribute, self.class.generate_unique_secure_token(length: length))
+ end
end
end
diff --git a/activerecord/lib/active_record/table_metadata.rb b/activerecord/lib/active_record/table_metadata.rb
index 6f1a630ebbcb3..5915646da4c20 100644
--- a/activerecord/lib/active_record/table_metadata.rb
+++ b/activerecord/lib/active_record/table_metadata.rb
@@ -19,7 +19,7 @@ def type(column_name)
end
def has_column?(column_name)
- klass&.columns_hash.key?(column_name)
+ klass&.columns_hash&.key?(column_name)
end
def associated_with?(table_name)
diff --git a/activerecord/lib/active_record/token_for.rb b/activerecord/lib/active_record/token_for.rb
index 1afe18a79b075..3b52192244359 100644
--- a/activerecord/lib/active_record/token_for.rb
+++ b/activerecord/lib/active_record/token_for.rb
@@ -11,8 +11,7 @@ module TokenFor
class_attribute :generated_token_verifier, instance_accessor: false, instance_predicate: false
end
- # :nodoc:
- TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do
+ TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do # :nodoc:
def full_purpose
@full_purpose ||= [defining_class.name, purpose, expires_in].join("\n")
end
@@ -56,9 +55,9 @@ module ClassMethods
# JSON. Later, when fetching the record with #find_by_token_for, the block
# will be evaluated again in the context of the fetched record. If the two
# JSON values do not match, the token will be treated as invalid. Note
- # that the value returned by the block should not contain
- # sensitive information because it will be embedded in the token
- # as human-readable plaintext JSON.
+ # that the value returned by the block should not contain sensitive
+ # information because it will be embedded in the token as
+ # human-readable plaintext JSON.
#
# ==== Examples
#
@@ -105,7 +104,7 @@ def find_by_token_for!(purpose, token)
# Generates a token for a predefined +purpose+.
#
- # Use ClassMethods::generates_token_for to define a token purpose and
+ # Use ClassMethods#generates_token_for to define a token purpose and
# behavior.
def generate_token_for(purpose)
self.class.token_definitions.fetch(purpose).generate_token(self)
diff --git a/activerecord/lib/arel/nodes/and.rb b/activerecord/lib/arel/nodes/and.rb
index bf516db35fa74..919ffbaa03005 100644
--- a/activerecord/lib/arel/nodes/and.rb
+++ b/activerecord/lib/arel/nodes/and.rb
@@ -18,6 +18,10 @@ def right
children[1]
end
+ def fetch_attribute(&block)
+ children.any? && children.all? { |child| child.fetch_attribute(&block) }
+ end
+
def hash
children.hash
end
diff --git a/activerecord/test/cases/active_record_test.rb b/activerecord/test/cases/active_record_test.rb
new file mode 100644
index 0000000000000..5d569f56d0573
--- /dev/null
+++ b/activerecord/test/cases/active_record_test.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "rack"
+
+class ActiveRecordTest < ActiveRecord::TestCase
+ unless in_memory_db?
+ test ".disconnect_all! closes all connections" do
+ ActiveRecord::Base.connection.active?
+ assert_predicate ActiveRecord::Base, :connected?
+
+ ActiveRecord.disconnect_all!
+ assert_not_predicate ActiveRecord::Base, :connected?
+
+ ActiveRecord::Base.connection.active?
+ assert_predicate ActiveRecord::Base, :connected?
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb
index 69c06bfcd1603..7c84ca5dacdb9 100644
--- a/activerecord/test/cases/adapter_test.rb
+++ b/activerecord/test/cases/adapter_test.rb
@@ -533,11 +533,7 @@ def teardown
test "materialized transaction state can be restored after a reconnect" do
@connection.begin_transaction
assert_predicate @connection, :transaction_open?
- # +materialize_transactions+ currently automatically dirties the
- # connection, which would make it unrestorable
- @connection.transaction_manager.stub(:dirty_current_transaction, nil) do
- @connection.materialize_transactions
- end
+ @connection.materialize_transactions
assert raw_transaction_open?(@connection)
@connection.reconnect!(restore_transactions: true)
assert_predicate @connection, :transaction_open?
@@ -627,12 +623,7 @@ def teardown
test "active transaction is restored after remote disconnection" do
assert_operator Post.count, :>, 0
Post.transaction do
- # +materialize_transactions+ currently automatically dirties the
- # connection, which would make it unrestorable
- @connection.transaction_manager.stub(:dirty_current_transaction, nil) do
- @connection.materialize_transactions
- end
-
+ @connection.materialize_transactions
remote_disconnect @connection
# Regular queries are not retryable, so the only abstract operation we can
diff --git a/activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rb b/activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rb
index 523029b46ca82..0988fda3c4df9 100644
--- a/activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rb
+++ b/activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rb
@@ -6,8 +6,6 @@
class ConnectionTest < ActiveRecord::AbstractMysqlTestCase
include ConnectionHelper
- fixtures :comments
-
def setup
super
@subscriber = SQLSubscriber.new
@@ -202,7 +200,7 @@ def test_get_and_release_advisory_lock
got_lock = @connection.get_advisory_lock(lock_name)
assert got_lock, "get_advisory_lock should have returned true but it didn't"
- assert_equal test_lock_free(lock_name), false,
+ assert_equal false, test_lock_free(lock_name),
"expected the test advisory lock to be held but it wasn't"
released_lock = @connection.release_advisory_lock(lock_name)
@@ -214,7 +212,7 @@ def test_get_and_release_advisory_lock
def test_release_non_existent_advisory_lock
lock_name = "fake lock'n'name"
released_non_existent_lock = @connection.release_advisory_lock(lock_name)
- assert_equal released_non_existent_lock, false,
+ assert_equal false, released_non_existent_lock,
"expected release_advisory_lock to return false when there was no lock to release"
end
diff --git a/activerecord/test/cases/adapters/mysql2/check_constraint_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/check_constraint_quoting_test.rb
new file mode 100644
index 0000000000000..a6e58a5602ff1
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/check_constraint_quoting_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_check_constraints?
+ class Mysql2CheckConstraintQuotingTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "trades", force: true do |t|
+ t.string :name
+ end
+ end
+
+ teardown do
+ @connection.drop_table "trades", if_exists: true rescue nil
+ end
+
+ def test_check_constraint_no_duplicate_expression_quoting
+ @connection.add_check_constraint :trades, "name != 'forbidden_string'"
+
+ check_constraints = @connection.check_constraints("trades")
+ assert_equal 1, check_constraints.size
+
+ expression = check_constraints.first.expression
+ if ActiveRecord::Base.connection.mariadb?
+ assert_equal "`name` <> 'forbidden_string'", expression
+ else
+ assert_equal "`name` <> _utf8mb4'forbidden_string'", expression
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb
index 14df538e41f20..7726915020f7d 100644
--- a/activerecord/test/cases/adapters/postgresql/connection_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb
@@ -10,8 +10,6 @@ class PostgresqlConnectionTest < ActiveRecord::PostgreSQLTestCase
class NonExistentTable < ActiveRecord::Base
end
- fixtures :comments
-
def setup
super
@subscriber = SQLSubscriber.new
@@ -57,6 +55,8 @@ def test_connection_options
# Verify the connection param has been applied.
expect = NonExistentTable.connection.query("show geqo").first.first
assert_equal "off", expect
+ ensure
+ NonExistentTable.remove_connection
end
def test_reset
@@ -152,7 +152,7 @@ def test_set_session_variable_true
run_without_connection do |orig_connection|
ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: true }))
set_true = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN"
- assert_equal set_true.rows, [["on"]]
+ assert_equal [["on"]], set_true.rows
end
end
@@ -160,7 +160,7 @@ def test_set_session_variable_false
run_without_connection do |orig_connection|
ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: false }))
set_false = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN"
- assert_equal set_false.rows, [["off"]]
+ assert_equal [["off"]], set_false.rows
end
end
@@ -213,7 +213,7 @@ def test_release_non_existent_advisory_lock
fake_lock_id = 2940075057017742022
with_warning_suppression do
released_non_existent_lock = @connection.release_advisory_lock(fake_lock_id)
- assert_equal released_non_existent_lock, false,
+ assert_equal false, released_non_existent_lock,
"expected release_advisory_lock to return false when there was no lock to release"
end
end
diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb
index 2053e97bd5979..e52ee907bead8 100644
--- a/activerecord/test/cases/adapters/postgresql/schema_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb
@@ -17,6 +17,7 @@ def with_schema_search_path(schema_search_path)
class SchemaTest < ActiveRecord::PostgreSQLTestCase
include PGSchemaHelper
+ include SchemaDumpingHelper
self.use_transactional_tests = false
SCHEMA_NAME = "test_schema"
@@ -487,6 +488,14 @@ def test_rename_index
assert @connection.index_name_exists?("#{SCHEMA_NAME}.#{TABLE_NAME}", new_name)
end
+ def test_dumping_schemas
+ output = dump_all_table_schema(/./)
+
+ assert_no_match %r{create_schema "public"}, output
+ assert_match %r{create_schema "test_schema"}, output
+ assert_match %r{create_schema "test_schema2"}, output
+ end
+
private
def columns(table_name)
@connection.send(:column_definitions, table_name).map do |name, type, default|
diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
index eae13c24d8d4e..e5a552452362e 100644
--- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
+++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
@@ -136,6 +136,26 @@ def test_encoding
assert_equal "UTF-8", @conn.encoding
end
+ def test_default_pragmas
+ if in_memory_db?
+ assert_equal [{ "foreign_keys" => 1 }], @conn.execute("PRAGMA foreign_keys")
+ assert_equal [{ "journal_mode" => "memory" }], @conn.execute("PRAGMA journal_mode")
+ assert_equal [{ "synchronous" => 2 }], @conn.execute("PRAGMA synchronous")
+ assert_equal [{ "journal_size_limit" => 67108864 }], @conn.execute("PRAGMA journal_size_limit")
+ assert_equal [], @conn.execute("PRAGMA mmap_size")
+ assert_equal [{ "cache_size" => 2000 }], @conn.execute("PRAGMA cache_size")
+ else
+ with_file_connection do |conn|
+ assert_equal [{ "foreign_keys" => 1 }], conn.execute("PRAGMA foreign_keys")
+ assert_equal [{ "journal_mode" => "wal" }], conn.execute("PRAGMA journal_mode")
+ assert_equal [{ "synchronous" => 1 }], conn.execute("PRAGMA synchronous")
+ assert_equal [{ "journal_size_limit" => 67108864 }], conn.execute("PRAGMA journal_size_limit")
+ assert_equal [{ "mmap_size" => 134217728 }], conn.execute("PRAGMA mmap_size")
+ assert_equal [{ "cache_size" => 2000 }], conn.execute("PRAGMA cache_size")
+ end
+ end
+ end
+
def test_exec_no_binds
with_example_table "id int, data string" do
result = @conn.exec_query("SELECT id, data FROM ex")
@@ -223,7 +243,12 @@ def test_insert_logged
with_example_table do
sql = "INSERT INTO ex (number) VALUES (10)"
name = "foo"
- assert_logged [[sql, name, []]] do
+
+ sqlite_version_query = ["SELECT sqlite_version(*)", "SCHEMA", []]
+ pragma_query = ["PRAGMA table_info(\"ex\")", "SCHEMA", []]
+ schema_query = ["SELECT sql FROM (SELECT * FROM sqlite_master UNION ALL SELECT * FROM sqlite_temp_master) WHERE type = 'table' AND name = 'ex'", "SCHEMA", []]
+ modified_insert_query = [(sql + ' RETURNING "id"'), name, []]
+ assert_logged [sqlite_version_query, pragma_query, schema_query, modified_insert_query] do
@conn.insert(sql, name)
end
end
@@ -238,6 +263,32 @@ def test_insert_id_value_returned
end
end
+ def test_exec_insert_with_returning_disabled
+ original_conn = @conn
+ @conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ insert_returning: false
+ with_example_table do
+ result = @conn.exec_insert("insert into ex (number) VALUES ('foo')", nil, [], "id")
+ expect = @conn.query("select max(id) from ex").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+ @conn = original_conn
+ end
+
+ def test_exec_insert_default_values_with_returning_disabled
+ original_conn = @conn
+ @conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ insert_returning: false
+ with_example_table do
+ result = @conn.exec_insert("insert into ex DEFAULT VALUES", nil, [], "id")
+ expect = @conn.query("select max(id) from ex").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+ @conn = original_conn
+ end
+
def test_select_rows
with_example_table do
2.times do |i|
@@ -444,44 +495,51 @@ def test_no_primary_key
end
class Barcode < ActiveRecord::Base
+ end
+
+ class BarcodeCustomPk < ActiveRecord::Base
self.primary_key = "code"
end
def test_copy_table_with_existing_records_have_custom_primary_key
- connection = Barcode.connection
- connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) do |t|
+ connection = BarcodeCustomPk.connection
+ connection.create_table(:barcode_custom_pks, primary_key: "code", id: :string, limit: 42, force: true) do |t|
t.text :other_attr
end
code = "214fe0c2-dd47-46df-b53b-66090b3c1d40"
- Barcode.create!(code: code, other_attr: "xxx")
+ BarcodeCustomPk.create!(code: code, other_attr: "xxx")
- connection.remove_column("barcodes", "other_attr")
+ connection.remove_column("barcode_custom_pks", "other_attr")
- assert_equal code, Barcode.first.id
+ assert_equal code, BarcodeCustomPk.first.id
ensure
- Barcode.reset_column_information
+ BarcodeCustomPk.reset_column_information
+ end
+
+ class BarcodeCpk < ActiveRecord::Base
+ self.primary_key = ["region", "code"]
end
def test_copy_table_with_composite_primary_keys
- connection = Barcode.connection
- connection.create_table(:barcodes, primary_key: ["region", "code"], force: true) do |t|
+ connection = BarcodeCpk.connection
+ connection.create_table(:barcode_cpks, primary_key: ["region", "code"], force: true) do |t|
t.string :region
t.string :code
t.text :other_attr
end
region = "US"
code = "214fe0c2-dd47-46df-b53b-66090b3c1d40"
- Barcode.create!(region: region, code: code, other_attr: "xxx")
+ BarcodeCpk.create!(region: region, code: code, other_attr: "xxx")
- connection.remove_column("barcodes", "other_attr")
+ connection.remove_column("barcode_cpks", "other_attr")
- assert_equal ["region", "code"], connection.primary_keys("barcodes")
+ assert_equal ["region", "code"], connection.primary_keys("barcode_cpks")
- barcode = Barcode.first
+ barcode = BarcodeCpk.first
assert_equal region, barcode.region
assert_equal code, barcode.code
ensure
- Barcode.reset_column_information
+ BarcodeCpk.reset_column_information
end
def test_custom_primary_key_in_create_table
@@ -777,6 +835,17 @@ def with_strict_strings_by_default
ensure
SQLite3Adapter.strict_strings_by_default = false
end
+
+ def with_file_connection(options = {})
+ options = options.dup
+ db_config = ActiveRecord::Base.configurations.configurations.find { |config| !config.database.include?(":memory:") }
+ options[:database] ||= db_config.database
+ conn = ActiveRecord::Base.sqlite3_connection(options)
+
+ yield(conn)
+ ensure
+ conn.disconnect! if conn
+ end
end
end
end
diff --git a/activerecord/test/cases/arel/nodes_test.rb b/activerecord/test/cases/arel/nodes_test.rb
index 9021de0d20b09..e042ca16039a6 100644
--- a/activerecord/test/cases/arel/nodes_test.rb
+++ b/activerecord/test/cases/arel/nodes_test.rb
@@ -16,11 +16,12 @@ def test_every_arel_nodes_have_hash_eql_eqeq_from_same_class
node_descendants.delete(Arel::Nodes::NodeExpression)
bad_node_descendants = node_descendants.reject do |subnode|
- eqeq_owner = subnode.instance_method(:==).owner
+ eqeq_method = subnode.instance_method(:==)
+ eqeq_owner = eqeq_method.owner
eql_owner = subnode.instance_method(:eql?).owner
hash_owner = subnode.instance_method(:hash).owner
- eqeq_owner < Arel::Nodes::Node &&
+ eqeq_method.super_method && # Not using the ruby default #== method
eqeq_owner == eql_owner &&
eqeq_owner == hash_owner
end
diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb
index 237019390af27..ad75027160736 100644
--- a/activerecord/test/cases/associations/belongs_to_associations_test.rb
+++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb
@@ -1180,7 +1180,7 @@ def test_belongs_to_invalid_dependent_option_raises_exception
error = assert_raise ArgumentError do
Class.new(Author).belongs_to :special_author_address, dependent: :nullify
end
- assert_equal error.message, "The :dependent option must be one of [:destroy, :delete, :destroy_async], but is :nullify"
+ assert_equal "The :dependent option must be one of [:destroy, :delete, :destroy_async], but is :nullify", error.message
end
class EssayDestroy < ActiveRecord::Base
@@ -1225,7 +1225,7 @@ def test_dependency_should_halt_parent_destruction_with_cascaded_three_levels
def test_attributes_are_being_set_when_initialized_from_belongs_to_association_with_where_clause
new_firm = accounts(:signals37).build_firm(name: "Apple")
- assert_equal new_firm.name, "Apple"
+ assert_equal "Apple", new_firm.name
end
def test_attributes_are_set_without_error_when_initialized_from_belongs_to_association_with_array_in_where_clause
@@ -1784,7 +1784,7 @@ def self.name; "Temp"; end
end
assert_equal(<<~MESSAGE.squish, error.message)
- Association Cpk::BrokenBook#order primary key ["shop_id", "id"]
+ Association Cpk::BrokenBook#order primary key ["shop_id", "status"]
doesn't match with foreign key order_id. Please specify query_constraints, or primary_key and foreign_key values.
MESSAGE
end
diff --git a/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb
index 88221b012eba5..7c621177eea11 100644
--- a/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb
+++ b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb
@@ -38,6 +38,6 @@ def test_bidirectional_dependence_when_destroying_item_with_has_one_association_
2.times { content.destroy }
- assert_equal content.destroyed?, true
+ assert_equal true, content.destroyed?
end
end
diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb
index 4c70c289b6f7a..986efd2656a84 100644
--- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb
+++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb
@@ -167,7 +167,7 @@ def test_preload_through_missing_records
def test_eager_association_loading_with_missing_first_record
posts = Post.where(id: 3).preload(author: { comments: :post }).to_a
- assert_equal posts.size, 1
+ assert_equal 1, posts.size
end
def test_eager_association_loading_with_recursive_cascading_four_levels_has_many_through
diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb
index 2c75bfbd79211..fe5fe6debed9b 100644
--- a/activerecord/test/cases/associations/eager_test.rb
+++ b/activerecord/test/cases/associations/eager_test.rb
@@ -299,7 +299,7 @@ def test_associations_loaded_for_all_records
second_category = Category.create! name: "Second!", posts: [post]
categories = Category.where(id: [first_category.id, second_category.id]).includes(posts: :special_comments)
- assert_equal categories.map { |category| category.posts.first.special_comments.loaded? }, [true, true]
+ assert_equal [true, true], categories.map { |category| category.posts.first.special_comments.loaded? }
end
def test_finding_with_includes_on_has_many_association_with_same_include_includes_only_once
@@ -1697,11 +1697,10 @@ def test_preloading_has_many_through_with_custom_scope
assert_equal 3, comments_collection.size
end.last
- if current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
- assert_match(/WHERE `sharded_comments`.`blog_id` IN \(.+\) AND `sharded_comments`.`blog_post_id` IN \(.+\)/, sql)
- else
- assert_match(/WHERE "sharded_comments"."blog_id" IN \(.+\) AND "sharded_comments"."blog_post_id" IN \(.+\)/, sql)
- end
+ c = Sharded::BlogPost.connection
+ quoted_blog_id = Regexp.escape(c.quote_table_name("sharded_comments.blog_id"))
+ quoted_blog_post_id = Regexp.escape(c.quote_table_name("sharded_comments.blog_post_id"))
+ assert_match(/WHERE #{quoted_blog_id} IN \(.+\) AND #{quoted_blog_post_id} IN \(.+\)/, sql)
end
test "preloading has_many association associated by a composite query_constraints" do
diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb
index 96d68feff21d3..cd4662a6ba259 100644
--- a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb
+++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb
@@ -840,13 +840,13 @@ def test_caching_of_columns
def test_attributes_are_being_set_when_initialized_from_habtm_association_with_where_clause
new_developer = projects(:action_controller).developers.where(name: "Marcelo").build
- assert_equal new_developer.name, "Marcelo"
+ assert_equal "Marcelo", new_developer.name
end
def test_attributes_are_being_set_when_initialized_from_habtm_association_with_multiple_where_clauses
new_developer = projects(:action_controller).developers.where(name: "Marcelo").where(salary: 90_000).build
- assert_equal new_developer.name, "Marcelo"
- assert_equal new_developer.salary, 90_000
+ assert_equal "Marcelo", new_developer.name
+ assert_equal 90_000, new_developer.salary
end
def test_include_method_in_has_and_belongs_to_many_association_should_return_true_for_instance_added_with_build
diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb
index dec2c4539d384..56685b384c3eb 100644
--- a/activerecord/test/cases/associations/has_many_associations_test.rb
+++ b/activerecord/test/cases/associations/has_many_associations_test.rb
@@ -1338,7 +1338,7 @@ def test_has_many_without_counter_cache_option
assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter?
# Count should come from sql count() of treasures rather than treasures_count attribute
- assert_equal ship.treasures.size, 0
+ assert_equal 0, ship.treasures.size
assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do
ship.treasures.create(name: "Gold")
@@ -2545,13 +2545,13 @@ class NullifyModel < ActiveRecord::Base
def test_attributes_are_being_set_when_initialized_from_has_many_association_with_where_clause
new_comment = posts(:welcome).comments.where(body: "Some content").build
- assert_equal new_comment.body, "Some content"
+ assert_equal "Some content", new_comment.body
end
def test_attributes_are_being_set_when_initialized_from_has_many_association_with_multiple_where_clauses
new_comment = posts(:welcome).comments.where(body: "Some content").where(type: "SpecialComment").build
- assert_equal new_comment.body, "Some content"
- assert_equal new_comment.type, "SpecialComment"
+ assert_equal "Some content", new_comment.body
+ assert_equal "SpecialComment", new_comment.type
assert_equal new_comment.post_id, posts(:welcome).id
end
@@ -2888,7 +2888,7 @@ def test_association_with_rewhere_doesnt_set_inverse_instance_key
assert_predicate pirate, :valid?
assert_not pirate.valid?(:conference)
- assert_equal "can’t be blank", ship.errors[:name].first
+ assert_equal "can't be blank", ship.errors[:name].first
end
test "association with instance dependent scope" do
@@ -3199,7 +3199,7 @@ def test_key_ensuring_owner_was_is_valid_when_dependent_option_is_destroy_async
end
assert_equal(<<~MESSAGE.squish, error.message)
- Association Cpk::BrokenOrder#books primary key ["shop_id", "id"]
+ Association Cpk::BrokenOrder#books primary key ["shop_id", "status"]
doesn't match with foreign key broken_order_id. Please specify query_constraints, or primary_key and foreign_key values.
MESSAGE
end
@@ -3211,7 +3211,7 @@ def test_key_ensuring_owner_was_is_valid_when_dependent_option_is_destroy_async
end
assert_equal(<<~MESSAGE.squish, error.message)
- Association Cpk::BrokenOrderWithNonCpkBooks#books primary key [\"shop_id\", \"id\"]
+ Association Cpk::BrokenOrderWithNonCpkBooks#books primary key [\"shop_id\", \"status\"]
doesn't match with foreign key broken_order_with_non_cpk_books_id. Please specify query_constraints, or primary_key and foreign_key values.
MESSAGE
end
diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb
index b44f10a1c6f1e..e2253e6412c9d 100644
--- a/activerecord/test/cases/associations/has_many_through_associations_test.rb
+++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb
@@ -452,6 +452,21 @@ def test_destroy_all_on_composite_primary_key_model
assert_empty(tag.orders.reload)
end
+ def test_composite_primary_key_join_table
+ order = Cpk::Order.create(shop_id: 1, status: "open")
+ tag = cpk_tags(:cpk_tag_loyal_customer)
+
+ order_tag = Cpk::OrderTag.create(order_id: order.id_value, tag_id: tag.id, attached_by: "Nikita")
+
+ assert_equal(order, order_tag.order)
+ assert_equal(tag, order_tag.tag)
+ order_tag.update(attached_reason: "This is our loyal customer")
+
+ order_tag = order.order_tags.find { |order_tag| order_tag.tag_id == tag.id }
+
+ assert_equal("This is our loyal customer", order_tag.attached_reason)
+ end
+
def test_destroy_all_on_association_clears_scope
post = Post.create!(title: "Rails 6", body: "")
people = post.people
@@ -1053,13 +1068,13 @@ def test_build_a_model_from_hm_through_association_with_where_clause
def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_where_clause
new_subscriber = books(:awdr).subscribers.where(nick: "marklazz").build
- assert_equal new_subscriber.nick, "marklazz"
+ assert_equal "marklazz", new_subscriber.nick
end
def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_multiple_where_clauses
new_subscriber = books(:awdr).subscribers.where(nick: "marklazz").where(name: "Marcelo Giorgi").build
- assert_equal new_subscriber.nick, "marklazz"
- assert_equal new_subscriber.name, "Marcelo Giorgi"
+ assert_equal "marklazz", new_subscriber.nick
+ assert_equal "Marcelo Giorgi", new_subscriber.name
end
def test_include_method_in_association_through_should_return_true_for_instance_added_with_build
@@ -1330,8 +1345,8 @@ def test_has_many_through_associations_sum_on_columns
active_persons = Person.joins(:readers).joins(:posts).distinct(true).where("posts.title" => "active")
- assert_equal active_persons.map(&:followers_count).reduce(:+), 10
- assert_equal active_persons.sum(:followers_count), 10
+ assert_equal 10, active_persons.map(&:followers_count).reduce(:+)
+ assert_equal 10, active_persons.sum(:followers_count)
assert_equal active_persons.sum(:followers_count), active_persons.map(&:followers_count).reduce(:+)
end
diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb
index 486f533a92d2c..7b5d0a75426c7 100644
--- a/activerecord/test/cases/associations/has_one_associations_test.rb
+++ b/activerecord/test/cases/associations/has_one_associations_test.rb
@@ -54,8 +54,8 @@ def test_has_one_cache_nils
end
def test_with_select
- assert_equal Firm.find(1).account_with_select.attributes.size, 2
- assert_equal Firm.all.merge!(includes: :account_with_select).find(1).account_with_select.attributes.size, 2
+ assert_equal 2, Firm.find(1).account_with_select.attributes.size
+ assert_equal 2, Firm.all.merge!(includes: :account_with_select).find(1).account_with_select.attributes.size
end
def test_finding_using_primary_key
@@ -528,7 +528,7 @@ def test_create_respects_hash_condition
def test_attributes_are_being_set_when_initialized_from_has_one_association_with_where_clause
new_account = companies(:first_firm).build_account(firm_name: "Account")
- assert_equal new_account.firm_name, "Account"
+ assert_equal "Account", new_account.firm_name
end
def test_create_association_replaces_existing_without_dependent_option
@@ -922,7 +922,7 @@ def test_has_one_with_touch_option_on_nonpersisted_built_associations_doesnt_upd
end
assert_equal(<<~MESSAGE.squish, error.message)
- Association Cpk::BrokenOrder#book primary key ["shop_id", "id"]
+ Association Cpk::BrokenOrder#book primary key ["shop_id", "status"]
doesn't match with foreign key broken_order_id. Please specify query_constraints, or primary_key and foreign_key values.
MESSAGE
end
@@ -934,7 +934,7 @@ def test_has_one_with_touch_option_on_nonpersisted_built_associations_doesnt_upd
end
assert_equal(<<~MESSAGE.squish, error.message)
- Association Cpk::BrokenOrderWithNonCpkBooks#book primary key [\"shop_id\", \"id\"]
+ Association Cpk::BrokenOrderWithNonCpkBooks#book primary key [\"shop_id\", \"status\"]
doesn't match with foreign key broken_order_with_non_cpk_books_id. Please specify query_constraints, or primary_key and foreign_key values.
MESSAGE
end
diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb
index fd0ca393520bc..937ba2e597cb2 100644
--- a/activerecord/test/cases/associations/inverse_associations_test.rb
+++ b/activerecord/test/cases/associations/inverse_associations_test.rb
@@ -631,8 +631,8 @@ def test_raise_record_not_found_error_when_no_ids_are_passed
exception = assert_raise(ActiveRecord::RecordNotFound) { human.interests.load.find() }
- assert_equal exception.model, "Interest"
- assert_equal exception.primary_key, "id"
+ assert_equal "Interest", exception.model
+ assert_equal "id", exception.primary_key
end
def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error
diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb
index a79c555b85cb6..71c660a5570ab 100644
--- a/activerecord/test/cases/associations_test.rb
+++ b/activerecord/test/cases/associations_test.rb
@@ -260,6 +260,13 @@ def test_belongs_to_association_does_not_use_parent_query_constraints_if_not_con
assert_equal(blog_post, comment.blog_post_by_id)
end
+ def test_preloads_model_with_query_constraints_by_explicitly_configured_fk_and_pk
+ comment = sharded_comments(:great_comment_blog_post_one)
+ comments = Sharded::Comment.where(id: comment.id).preload(:blog_post_by_id).to_a
+ comment = comments.first
+ assert_equal(comment.blog_post_by_id, comment.blog_post)
+ end
+
def test_append_composite_foreign_key_has_many_association
blog_post = sharded_blog_posts(:great_post_blog_one)
comment = Sharded::Comment.new(body: "Great post! :clap:")
@@ -504,14 +511,14 @@ def test_save_on_parent_saves_children
def test_create_via_association_with_block
post = authors(:david).posts.create(title: "New on Edge") { |p| p.body = "More cool stuff!" }
- assert_equal post.title, "New on Edge"
- assert_equal post.body, "More cool stuff!"
+ assert_equal "New on Edge", post.title
+ assert_equal "More cool stuff!", post.body
end
def test_create_with_bang_via_association_with_block
post = authors(:david).posts.create!(title: "New on Edge") { |p| p.body = "More cool stuff!" }
- assert_equal post.title, "New on Edge"
- assert_equal post.body, "More cool stuff!"
+ assert_equal "New on Edge", post.title
+ assert_equal "More cool stuff!", post.body
end
def test_reload_returns_association
diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb
index f30ff7878dd57..820743dd15c28 100644
--- a/activerecord/test/cases/attribute_methods_test.rb
+++ b/activerecord/test/cases/attribute_methods_test.rb
@@ -1039,6 +1039,25 @@ def name
end
end
+ test "#define_attribute_methods brings back undefined aliases" do
+ topic_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+
+ alias_attribute :title_alias_to_be_undefined, :title
+ end
+
+ topic = topic_class.new(title: "New topic")
+ assert_equal("New topic", topic.title_alias_to_be_undefined)
+ topic_class.undefine_attribute_methods
+
+ assert_not_respond_to topic, :title_alias_to_be_undefined
+
+ topic_class.define_attribute_methods
+
+ assert_respond_to topic, :title_alias_to_be_undefined
+ assert_equal "New topic", topic.title_alias_to_be_undefined
+ end
+
test "define_attribute_method works with both symbol and string" do
klass = Class.new(ActiveRecord::Base)
klass.table_name = "foo"
@@ -1209,9 +1228,10 @@ def title_was
test "#alias_attribute with an overridden original method issues a deprecation" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehavior model aliases `title` and has a method called
- `title_was` defined. Starting in Rails 7.2 `subject_was` will not be calling `title_was` anymore.
- You may want to additionally define `subject_was` to preserve the current behavior.
+ AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehavior model aliases
+ `title` and has a method called `title_was` defined.
+ Starting in Rails 7.2 `subject_was` will not be calling `title_was` anymore.
+ You may want to additionally define `subject_was` to preserve the current behavior.
MESSAGE
obj = assert_deprecated(message, ActiveRecord.deprecator) do
@@ -1236,9 +1256,10 @@ def title_was
test "#alias_attribute with an overridden original method from a module issues a deprecation" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehaviorFromModule model aliases `title` and has a method
- called `title_was` defined. Starting in Rails 7.2 `subject_was` will not be calling `title_was` anymore.
- You may want to additionally define `subject_was` to preserve the current behavior.
+ AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehaviorFromModule model aliases
+ `title` and has a method called `title_was` defined.
+ Starting in Rails 7.2 `subject_was` will not be calling `title_was` anymore.
+ You may want to additionally define `subject_was` to preserve the current behavior.
MESSAGE
obj = assert_deprecated(message, ActiveRecord.deprecator) do
@@ -1324,8 +1345,10 @@ class ChildWithDeprecatedBehaviorResolved < ClassWithDeprecatedAliasAttributeBeh
test "#alias_attribute with an _in_database method issues a deprecation warning" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithGeneratedAttributeMethodTarget model aliases `title_in_database`, but title_in_database is not an attribute.
- Starting in Rails 7.2 `, alias_attribute with non-attribute targets will raise. Use `alias_method :saved_title`, :title_in_database or define the method manually.
+ AttributeMethodsTest::ClassWithGeneratedAttributeMethodTarget model aliases
+ `title_in_database`, but `title_in_database` is not an attribute.
+ Starting in Rails 7.2, alias_attribute with non-attribute targets will raise.
+ Use `alias_method :saved_title, :title_in_database` or define the method manually.
MESSAGE
obj = assert_deprecated(message, ActiveRecord.deprecator) do
@@ -1350,9 +1373,7 @@ class ChildWithDeprecatedBehaviorResolved < ClassWithDeprecatedAliasAttributeBeh
test "#alias_attribute with enum method issues a deprecation warning" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithEnumMethodTarget model aliases `pending?`, but pending? is not an attribute.
- Starting in Rails 7.2 `, alias_attribute with non-attribute targets will raise.
- Use `alias_method :is_pending?`, :pending? or define the method manually.
+ AttributeMethodsTest::ClassWithEnumMethodTarget model aliases `pending?`, but `pending?` is not an attribute. Starting in Rails 7.2, alias_attribute with non-attribute targets will raise. Use `alias_method :is_pending?, :pending?` or define the method manually.
MESSAGE
obj = assert_deprecated(message, ActiveRecord.deprecator) do
@@ -1372,9 +1393,7 @@ class ChildWithDeprecatedBehaviorResolved < ClassWithDeprecatedAliasAttributeBeh
test "#alias_attribute with an association method issues a deprecation warning" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithAssociationTarget model aliases `author`, but author is not an attribute.
- Starting in Rails 7.2 `, alias_attribute with non-attribute targets will raise.
- Use `alias_method :written_by`, :author or define the method manually.
+ AttributeMethodsTest::ClassWithAssociationTarget model aliases `author`, but `author` is not an attribute. Starting in Rails 7.2, alias_attribute with non-attribute targets will raise. Use `alias_method :written_by, :author` or define the method manually.
MESSAGE
obj = assert_deprecated(message, ActiveRecord.deprecator) do
@@ -1395,9 +1414,10 @@ def publish
test "#alias_attribute with a manually defined method issues a deprecation warning" do
message = <<~MESSAGE.gsub("\n", " ")
- AttributeMethodsTest::ClassWithAliasedManuallyDefinedMethod model aliases `publish`, but publish is not an attribute.
- Starting in Rails 7.2 `, alias_attribute with non-attribute targets will raise.
- Use `alias_method :print`, :publish or define the method manually.
+ AttributeMethodsTest::ClassWithAliasedManuallyDefinedMethod model aliases `publish`,
+ but `publish` is not an attribute.
+ Starting in Rails 7.2, alias_attribute with non-attribute targets will raise.
+ Use `alias_method :print, :publish` or define the method manually.
MESSAGE
assert_deprecated(message, ActiveRecord.deprecator) do
diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb
index 67a4c408a6203..ae8e4a8f82181 100644
--- a/activerecord/test/cases/autosave_association_test.rb
+++ b/activerecord/test/cases/autosave_association_test.rb
@@ -536,8 +536,8 @@ def test_errors_should_be_indexed_when_global_flag_is_set
assert_not_predicate invalid_electron, :valid?
assert_predicate valid_electron, :valid?
assert_not_predicate molecule, :valid?
- assert_equal ["can’t be blank"], molecule.errors["electrons[1].name"]
- assert_not_equal ["can’t be blank"], molecule.errors["electrons.name"]
+ assert_equal ["can't be blank"], molecule.errors["electrons[1].name"]
+ assert_not_equal ["can't be blank"], molecule.errors["electrons.name"]
ensure
ActiveRecord.index_nested_attribute_errors = old_attribute_config
end
@@ -644,7 +644,7 @@ def self.name; "Person"; end
assert_predicate reference_valid, :valid?
assert_not_predicate reference_invalid, :valid?
assert_not_predicate p, :valid?
- assert_equal ["should be favorite", "can’t be blank"], p.errors.full_messages
+ assert_equal ["should be favorite", "can't be blank"], p.errors.full_messages
ensure
ActiveModel::Error.i18n_customize_full_message = old_i18n_customize_full_message
I18n.backend = I18n::Backend::Simple.new
@@ -687,14 +687,14 @@ def self.name; "Person"; end
p = person.new
assert_not_predicate p, :valid?
- assert_equal ["Super reference can’t be blank"], p.errors.full_messages
+ assert_equal ["Super reference can't be blank"], p.errors.full_messages
reference_invalid = reference.new(favorite: false)
p.reference = reference_invalid
assert_not_predicate reference_invalid, :valid?
assert_not_predicate p, :valid?
- assert_equal [" should be favorite", "Reference job can’t be blank"], p.errors.full_messages
+ assert_equal [" should be favorite", "Reference job can't be blank"], p.errors.full_messages
ensure
I18n.backend = I18n::Backend::Simple.new
end
@@ -1525,7 +1525,7 @@ def test_should_not_ignore_different_error_messages_on_the_same_attribute
@pirate.ship.name = ""
@pirate.catchphrase = nil
assert_predicate @pirate, :invalid?
- assert_equal ["can’t be blank", "is invalid"], @pirate.errors[:"ship.name"]
+ assert_equal ["can't be blank", "is invalid"], @pirate.errors[:"ship.name"]
ensure
Ship._validators = old_validators if old_validators
Ship._validate_callbacks = old_callbacks if old_callbacks
@@ -1820,7 +1820,7 @@ def test_should_automatically_validate_the_associated_models
@pirate.public_send(@association_name).each { |child| child.name = "" }
assert_not_predicate @pirate, :valid?
- assert_equal ["can’t be blank"], @pirate.errors["#{@association_name}.name"]
+ assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"]
assert_empty @pirate.errors[@association_name]
end
@@ -1828,7 +1828,7 @@ def test_should_not_use_default_invalid_error_on_associated_models
@pirate.public_send(@association_name).build(name: "")
assert_not_predicate @pirate, :valid?
- assert_equal ["can’t be blank"], @pirate.errors["#{@association_name}.name"]
+ assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"]
assert_empty @pirate.errors[@association_name]
end
@@ -1852,7 +1852,7 @@ def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it
@pirate.catchphrase = nil
assert_not_predicate @pirate, :valid?
- assert_equal ["can’t be blank"], @pirate.errors["#{@association_name}.name"]
+ assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"]
assert_predicate @pirate.errors[:catchphrase], :any?
end
diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb
index 0f3b6953213bf..ca5f1e7b09c21 100644
--- a/activerecord/test/cases/batches_test.rb
+++ b/activerecord/test/cases/batches_test.rb
@@ -548,7 +548,7 @@ def test_in_batches_executes_range_queries_when_constrained_and_opted_in_into_ra
def test_in_batches_no_subqueries_for_whole_tables_batching
c = Post.connection
quoted_posts_id = Regexp.escape(c.quote_table_name("posts.id"))
- assert_sql(/DELETE FROM #{c.quote_table_name("posts")} WHERE #{quoted_posts_id} > .+ AND #{quoted_posts_id} <=/i) do
+ assert_sql(/DELETE FROM #{Regexp.escape(c.quote_table_name("posts"))} WHERE #{quoted_posts_id} > .+ AND #{quoted_posts_id} <=/i) do
Post.in_batches(of: 2).delete_all
end
end
@@ -565,7 +565,7 @@ def test_in_batches_shouldnt_execute_query_unless_needed
def test_in_batches_should_quote_batch_order
c = Post.connection
- assert_sql(/ORDER BY #{c.quote_table_name('posts')}\.#{c.quote_column_name('id')}/) do
+ assert_sql(/ORDER BY #{Regexp.escape(c.quote_table_name('posts'))}\.#{Regexp.escape(c.quote_column_name('id'))}/) do
Post.in_batches(of: 1) do |relation|
assert_kind_of ActiveRecord::Relation, relation
assert_kind_of Post, relation.first
diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb
index 1a4d2278e59f4..155c96ef22a20 100644
--- a/activerecord/test/cases/calculations_test.rb
+++ b/activerecord/test/cases/calculations_test.rb
@@ -812,10 +812,10 @@ def test_from_option_with_table_different_than_class
def test_distinct_is_honored_when_used_with_count_operation_after_group
# Count the number of authors for approved topics
approved_topics_count = Topic.group(:approved).count(:author_name)[true]
- assert_equal approved_topics_count, 4
+ assert_equal 4, approved_topics_count
# Count the number of distinct authors for approved Topics
distinct_authors_for_approved_count = Topic.group(:approved).distinct.count(:author_name)[true]
- assert_equal distinct_authors_for_approved_count, 3
+ assert_equal 3, distinct_authors_for_approved_count
end
def test_pluck
diff --git a/activerecord/test/cases/comment_test.rb b/activerecord/test/cases/comment_test.rb
index 58a0243fbd9f3..5c466154659b4 100644
--- a/activerecord/test/cases/comment_test.rb
+++ b/activerecord/test/cases/comment_test.rb
@@ -122,7 +122,7 @@ def test_rename_column_preserves_comment
column = Commented.columns_hash["new_rating"]
assert_equal :string, column.type
- assert_equal column.comment, "I am running out of imagination"
+ assert_equal "I am running out of imagination", column.comment
end
def test_schema_dump_with_comments
diff --git a/activerecord/test/cases/connection_adapters/connection_handlers_sharding_db_test.rb b/activerecord/test/cases/connection_adapters/connection_handlers_sharding_db_test.rb
index 6df45a8312af3..1e887c0d8b4fe 100644
--- a/activerecord/test/cases/connection_adapters/connection_handlers_sharding_db_test.rb
+++ b/activerecord/test/cases/connection_adapters/connection_handlers_sharding_db_test.rb
@@ -38,13 +38,14 @@ def test_establish_connection_using_3_levels_config
@prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
ActiveRecord::Base.connects_to(shards: {
- default: { writing: :primary },
- shard_one: { writing: :primary_shard_one }
+ default: { writing: :primary, reading: :primary },
+ shard_one: { writing: :primary_shard_one, reading: :primary_shard_one }
})
base_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool("ActiveRecord::Base")
default_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool("ActiveRecord::Base", shard: :default)
+ assert_equal [:default, :shard_one], ActiveRecord::Base.connection_handler.send(:get_pool_manager, "ActiveRecord::Base").shard_names
assert_equal base_pool, default_pool
assert_equal "test/db/primary.sqlite3", default_pool.db_config.database
assert_equal "primary", default_pool.db_config.name
diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb
index 1a2ca50015dbe..4cbd84c2a9ecb 100644
--- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb
+++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb
@@ -334,6 +334,27 @@ def test_marshal_dump_and_load_with_gzip
tempfile.unlink
end
+ def test_gzip_dumps_identical
+ # Create an empty cache.
+ cache = new_bound_reflection
+
+ tempfile_a = Tempfile.new(["schema_cache-", ".dump.gz"])
+ # Dump it. It should get populated before dumping.
+ cache.dump_to(tempfile_a.path)
+ digest_a = Digest::MD5.file(tempfile_a).hexdigest
+ sleep(1) # ensure timestamp changes
+ tempfile_b = Tempfile.new(["schema_cache-", ".dump.gz"])
+ # Dump it. It should get populated before dumping.
+ cache.dump_to(tempfile_b.path)
+ digest_b = Digest::MD5.file(tempfile_b).hexdigest
+
+
+ assert_equal digest_a, digest_b
+ ensure
+ tempfile_a.unlink
+ tempfile_b.unlink
+ end
+
def test_data_source_exist
assert @cache.data_source_exists?("courses")
assert_not @cache.data_source_exists?("foo")
diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb
index 5ee00ec0ad5f6..1f5d39b8787ac 100644
--- a/activerecord/test/cases/counter_cache_test.rb
+++ b/activerecord/test/cases/counter_cache_test.rb
@@ -3,6 +3,7 @@
require "cases/helper"
require "models/topic"
require "models/bulb"
+require "models/person"
require "models/car"
require "models/aircraft"
require "models/wheel"
@@ -12,7 +13,6 @@
require "models/categorization"
require "models/dog"
require "models/dog_lover"
-require "models/person"
require "models/friendship"
require "models/subscriber"
require "models/subscription"
@@ -397,6 +397,11 @@ class ::SpecialReply < ::Reply
end
end
+ test "counter_cache_column?" do
+ assert Person.counter_cache_column?("cars_count")
+ assert_not Car.counter_cache_column?("cars_count")
+ end
+
private
def assert_touching(record, *attributes)
record.update_columns attributes.index_with(5.minutes.ago)
diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb
index 948da822eee89..ec8ec74ad00db 100644
--- a/activerecord/test/cases/defaults_test.rb
+++ b/activerecord/test/cases/defaults_test.rb
@@ -14,7 +14,7 @@ def test_nil_defaults_for_not_null_columns
end
end
- if current_adapter?(:PostgreSQLAdapter)
+ if current_adapter?(:PostgreSQLAdapter) || current_adapter?(:SQLite3Adapter)
def test_multiline_default_text
record = Default.new
# older postgres versions represent the default with escapes ("\\012" for a newline)
@@ -309,7 +309,7 @@ class Sqlite3DefaultExpressionTest < ActiveRecord::TestCase
assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output
assert_match %r/t\.datetime\s+"modified_time_without_precision",\s+precision: nil,\s+default: -> { "CURRENT_TIMESTAMP" }/, output
assert_match %r/t\.datetime\s+"modified_time_with_precision_0",\s+precision: 0,\s+default: -> { "CURRENT_TIMESTAMP" }/, output
- assert_match %r/t\.integer\s+"random_number",\s+default: -> { "random\(\)" }/, output
+ assert_match %r/t\.integer\s+"random_number",\s+default: -> { "ABS\(RANDOM\(\)\)" }/, output
end
end
end
diff --git a/activerecord/test/cases/encryption/configurable_test.rb b/activerecord/test/cases/encryption/configurable_test.rb
index c82a01063d823..63bde39c78968 100644
--- a/activerecord/test/cases/encryption/configurable_test.rb
+++ b/activerecord/test/cases/encryption/configurable_test.rb
@@ -79,7 +79,7 @@ class ActiveRecord::Encryption::ConfigurableTest < ActiveRecord::EncryptionTestC
end
end
- assert_equal application.config.filter_parameters, []
+ assert_equal [], application.config.filter_parameters
ActiveRecord::Encryption.config.excluded_from_filter_parameters = []
end
diff --git a/activerecord/test/cases/encryption/encryptable_record_test.rb b/activerecord/test/cases/encryption/encryptable_record_test.rb
index 92d1a4eb91894..de64a3f0b0575 100644
--- a/activerecord/test/cases/encryption/encryptable_record_test.rb
+++ b/activerecord/test/cases/encryption/encryptable_record_test.rb
@@ -298,6 +298,8 @@ def name
end
test "loading records with encrypted attributes defined on columns with default values" do
+ skip unless supports_insert_on_duplicate_update?
+
EncryptedBook.insert({ format: "ebook" })
book = EncryptedBook.last
assert_equal "", book.name
diff --git a/activerecord/test/cases/encryption/extended_deterministic_queries_test.rb b/activerecord/test/cases/encryption/extended_deterministic_queries_test.rb
index fa4873faf5509..6b441f1e3811c 100644
--- a/activerecord/test/cases/encryption/extended_deterministic_queries_test.rb
+++ b/activerecord/test/cases/encryption/extended_deterministic_queries_test.rb
@@ -9,19 +9,19 @@ class ActiveRecord::Encryption::ExtendedDeterministicQueriesTest < ActiveRecord:
end
test "Finds records when data is unencrypted" do
- ActiveRecord::Encryption.without_encryption { UnencryptedBook.create! name: "Dune" }
+ UnencryptedBook.create!(name: "Dune")
assert EncryptedBook.find_by(name: "Dune") # core
assert EncryptedBook.where("id > 0").find_by(name: "Dune") # relation
end
test "Finds records when data is encrypted" do
- UnencryptedBook.create! name: "Dune"
+ EncryptedBook.create!(name: "Dune")
assert EncryptedBook.find_by(name: "Dune") # core
assert EncryptedBook.where("id > 0").find_by(name: "Dune") # relation
end
test "Works well with downcased attributes" do
- ActiveRecord::Encryption.without_encryption { EncryptedBookWithDowncaseName.create! name: "Dune" }
+ EncryptedBookWithDowncaseName.create! name: "Dune"
assert EncryptedBookWithDowncaseName.find_by(name: "DUNE")
end
@@ -30,7 +30,7 @@ class ActiveRecord::Encryption::ExtendedDeterministicQueriesTest < ActiveRecord:
assert EncryptedBook.find_by("name" => "Dune")
end
- test "find_or_create works" do
+ test "find_or_create_by works" do
EncryptedBook.find_or_create_by!(name: "Dune")
assert EncryptedBook.find_by(name: "Dune")
@@ -38,13 +38,44 @@ class ActiveRecord::Encryption::ExtendedDeterministicQueriesTest < ActiveRecord:
assert EncryptedBook.find_by(name: "Dune")
end
+ test "does not mutate arguments" do
+ props = { name: "Dune" }
+
+ assert_equal "Dune", EncryptedBook.find_or_initialize_by(props).name
+ assert_equal "Dune", props[:name]
+ end
+
test "where(...).first_or_create works" do
EncryptedBook.where(name: "Dune").first_or_create
assert EncryptedBook.exists?(name: "Dune")
end
test "exists?(...) works" do
- ActiveRecord::Encryption.without_encryption { EncryptedBook.create! name: "Dune" }
+ EncryptedBook.create! name: "Dune"
assert EncryptedBook.exists?(name: "Dune")
end
+
+ test "If support_unencrypted_data is opted out at the attribute level, cannot find unencrypted data" do
+ UnencryptedBook.create! name: "Dune"
+ assert_nil EncryptedBookWithUnencryptedDataOptedOut.find_by(name: "Dune") # core
+ assert_nil EncryptedBookWithUnencryptedDataOptedOut.where("id > 0").find_by(name: "Dune") # relation
+ end
+
+ test "If support_unencrypted_data is opted out at the attribute level, can find encrypted data" do
+ EncryptedBook.create! name: "Dune"
+ assert EncryptedBookWithUnencryptedDataOptedOut.find_by(name: "Dune") # core
+ assert EncryptedBookWithUnencryptedDataOptedOut.where("id > 0").find_by(name: "Dune") # relation
+ end
+
+ test "If support_unencrypted_data is opted in at the attribute level, can find unencrypted data" do
+ UnencryptedBook.create! name: "Dune"
+ assert EncryptedBookWithUnencryptedDataOptedIn.find_by(name: "Dune") # core
+ assert EncryptedBookWithUnencryptedDataOptedIn.where("id > 0").find_by(name: "Dune") # relation
+ end
+
+ test "If support_unencrypted_data is opted in at the attribute level, can find encrypted data" do
+ EncryptedBook.create! name: "Dune"
+ assert EncryptedBookWithUnencryptedDataOptedIn.find_by(name: "Dune") # core
+ assert EncryptedBookWithUnencryptedDataOptedIn.where("id > 0").find_by(name: "Dune") # relation
+ end
end
diff --git a/activerecord/test/cases/encryption/uniqueness_validations_test.rb b/activerecord/test/cases/encryption/uniqueness_validations_test.rb
index 19947cfa4e6cc..a444c07bebe18 100644
--- a/activerecord/test/cases/encryption/uniqueness_validations_test.rb
+++ b/activerecord/test/cases/encryption/uniqueness_validations_test.rb
@@ -15,10 +15,27 @@ class ActiveRecord::Encryption::UniquenessValidationsTest < ActiveRecord::Encryp
test "uniqueness validations work when mixing encrypted an unencrypted data" do
ActiveRecord::Encryption.config.support_unencrypted_data = true
- ActiveRecord::Encryption.without_encryption { EncryptedBookWithDowncaseName.create! name: "dune" }
+ UnencryptedBook.create! name: "dune"
+ assert_raises ActiveRecord::RecordInvalid do
+ EncryptedBookWithDowncaseName.create!(name: "DUNE")
+ end
+ end
+
+ test "uniqueness validations do not work when mixing encrypted an unencrypted data and unencrypted data is opted out per-attribute" do
+ ActiveRecord::Encryption.config.support_unencrypted_data = true
+
+ UnencryptedBook.create! name: "dune"
+ assert_nothing_raised do
+ EncryptedBookWithUnencryptedDataOptedOut.create!(name: "dune")
+ end
+ end
+
+ test "uniqueness validations work when mixing encrypted an unencrypted data and unencrypted data is opted in per-attribute" do
+ ActiveRecord::Encryption.config.support_unencrypted_data = true
+ UnencryptedBook.create! name: "dune"
assert_raises ActiveRecord::RecordInvalid do
- EncryptedBookWithDowncaseName.create!(name: "dune")
+ EncryptedBookWithUnencryptedDataOptedIn.create!(name: "dune")
end
end
@@ -38,4 +55,11 @@ class ActiveRecord::Encryption::UniquenessValidationsTest < ActiveRecord::Encryp
OldEncryptionBook.create! name: "DUNE"
end
end
+
+ test "uniqueness validation does not revalidate the attribute with current encryption type" do
+ EncryptedBookWithUniquenessValidation.create!(name: "dune")
+ record = EncryptedBookWithUniquenessValidation.create(name: "dune")
+
+ assert_equal 1, record.errors.count
+ end
end
diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb
index 1845bcb88ece4..3c033245dd57d 100644
--- a/activerecord/test/cases/enum_test.rb
+++ b/activerecord/test/cases/enum_test.rb
@@ -313,6 +313,44 @@ class EnumTest < ActiveRecord::TestCase
assert_equal "'unknown' is not a valid status", e.message
end
+ test "validation with 'validate: true' option" do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Book"; end
+ enum :status, [:proposed, :written], validate: true
+ end
+
+ valid_book = klass.new(status: "proposed")
+ assert_predicate valid_book, :valid?
+
+ valid_book = klass.new(status: "written")
+ assert_predicate valid_book, :valid?
+
+ invalid_book = klass.new(status: nil)
+ assert_not_predicate invalid_book, :valid?
+
+ invalid_book = klass.new(status: "unknown")
+ assert_not_predicate invalid_book, :valid?
+ end
+
+ test "validation with 'validate: hash' option" do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Book"; end
+ enum :status, [:proposed, :written], validate: { allow_nil: true }
+ end
+
+ valid_book = klass.new(status: "proposed")
+ assert_predicate valid_book, :valid?
+
+ valid_book = klass.new(status: "written")
+ assert_predicate valid_book, :valid?
+
+ valid_book = klass.new(status: nil)
+ assert_predicate valid_book, :valid?
+
+ invalid_book = klass.new(status: "unknown")
+ assert_not_predicate invalid_book, :valid?
+ end
+
test "NULL values from database should be casted to nil" do
Book.where(id: @book.id).update_all("status = NULL")
assert_nil @book.reload.status
@@ -577,7 +615,6 @@ def self.name; "Book"; end
enum status: [:proposed, :written]
validates_inclusion_of :status, in: ["written"]
end
- klass.delete_all
invalid_book = klass.new(status: "proposed")
assert_not_predicate invalid_book, :valid?
valid_book = klass.new(status: "written")
diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb
index 38d50260b0c81..92daaf71df4c0 100644
--- a/activerecord/test/cases/finder_test.rb
+++ b/activerecord/test/cases/finder_test.rb
@@ -149,8 +149,8 @@ def test_find_with_ids_and_offset
def test_find_with_ids_with_no_id_passed
exception = assert_raises(ActiveRecord::RecordNotFound) { Topic.find }
- assert_equal exception.model, "Topic"
- assert_equal exception.primary_key, "id"
+ assert_equal "Topic", exception.model
+ assert_equal "id", exception.primary_key
end
def test_find_with_ids_with_id_out_of_range
@@ -158,8 +158,8 @@ def test_find_with_ids_with_id_out_of_range
Topic.find("9999999999999999999999999999999")
end
- assert_equal exception.model, "Topic"
- assert_equal exception.primary_key, "id"
+ assert_equal "Topic", exception.model
+ assert_equal "id", exception.primary_key
end
def test_find_passing_active_record_object_is_not_permitted
diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb
index e0fd880d8f75a..ab0b5d47fc7f9 100644
--- a/activerecord/test/cases/fixtures_test.rb
+++ b/activerecord/test/cases/fixtures_test.rb
@@ -564,12 +564,12 @@ def test_omap_fixtures
end
def test_yml_file_in_subdirectory
- assert_equal(categories(:sub_special_1).name, "A special category in a subdir file")
+ assert_equal("A special category in a subdir file", categories(:sub_special_1).name)
assert_equal(categories(:sub_special_1).class, SpecialCategory)
end
def test_subsubdir_file_with_arbitrary_name
- assert_equal(categories(:sub_special_3).name, "A special category in an arbitrarily named subsubdir file")
+ assert_equal("A special category in an arbitrarily named subsubdir file", categories(:sub_special_3).name)
assert_equal(categories(:sub_special_3).class, SpecialCategory)
end
diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb
index fbb00c5fcfc41..d0e49298b63aa 100644
--- a/activerecord/test/cases/helper.rb
+++ b/activerecord/test/cases/helper.rb
@@ -70,5 +70,7 @@ def in_time_zone(zone)
deterministic_key: "test deterministic key",
key_derivation_salt: "testing key derivation salt"
+# Simulate https://github.com/rails/rails/blob/735cba5bed7a54c7397dfeec1bed16033ae286f8/activerecord/lib/active_record/railtie.rb#L392
+ActiveRecord::Encryption.config.extend_queries = true
ActiveRecord::Encryption::ExtendedDeterministicQueries.install_support
ActiveRecord::Encryption::ExtendedDeterministicUniquenessValidator.install_support
diff --git a/activerecord/test/cases/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb
index ac4098ac83295..3af0b6e9fda77 100644
--- a/activerecord/test/cases/insert_all_test.rb
+++ b/activerecord/test/cases/insert_all_test.rb
@@ -64,6 +64,8 @@ def test_insert_all
end
def test_insert_all_should_handle_empty_arrays
+ skip unless supports_insert_on_duplicate_update?
+
assert_empty Book.insert_all([])
assert_empty Book.insert_all!([])
assert_empty Book.upsert_all([])
@@ -277,6 +279,8 @@ def test_insert_all_logs_message_including_model_name
end
def test_insert_all_and_upsert_all_with_aliased_attributes
+ skip unless supports_insert_on_duplicate_update?
+
if supports_insert_returning?
assert_difference "Book.count" do
result = Book.insert_all [{ title: "Remote", author_id: 1 }], returning: :title
@@ -284,18 +288,18 @@ def test_insert_all_and_upsert_all_with_aliased_attributes
end
end
- if supports_insert_on_duplicate_update?
- Book.upsert_all [{ id: 101, title: "Perelandra", author_id: 7, isbn: "1974522598" }]
- Book.upsert_all [{ id: 101, title: "Perelandra 2", author_id: 6, isbn: "111111" }], update_only: %i[ title isbn ]
+ Book.upsert_all [{ id: 101, title: "Perelandra", author_id: 7, isbn: "1974522598" }]
+ Book.upsert_all [{ id: 101, title: "Perelandra 2", author_id: 6, isbn: "111111" }], update_only: %i[ title isbn ]
- book = Book.find(101)
- assert_equal "Perelandra 2", book.title, "Should have updated the title"
- assert_equal "111111", book.isbn, "Should have updated the isbn"
- assert_equal 7, book.author_id, "Should not have updated the author_id"
- end
+ book = Book.find(101)
+ assert_equal "Perelandra 2", book.title, "Should have updated the title"
+ assert_equal "111111", book.isbn, "Should have updated the isbn"
+ assert_equal 7, book.author_id, "Should not have updated the author_id"
end
def test_insert_all_and_upsert_all_with_sti
+ skip unless supports_insert_on_duplicate_update?
+
assert_difference -> { Category.count }, 2 do
SpecialCategory.insert_all [{ name: "First" }, { name: "Second", type: nil }]
end
@@ -304,15 +308,13 @@ def test_insert_all_and_upsert_all_with_sti
assert_equal "SpecialCategory", first.type
assert_nil second.type
- if supports_insert_on_duplicate_update?
- SpecialCategory.upsert_all [{ id: 103, name: "First" }, { id: 104, name: "Second", type: nil }]
+ SpecialCategory.upsert_all [{ id: 103, name: "First" }, { id: 104, name: "Second", type: nil }]
- category3 = Category.find(103)
- assert_equal "SpecialCategory", category3.type
+ category3 = Category.find(103)
+ assert_equal "SpecialCategory", category3.type
- category4 = Category.find(104)
- assert_nil category4.type
- end
+ category4 = Category.find(104)
+ assert_nil category4.type
end
def test_upsert_logs_message_including_model_name
diff --git a/activerecord/test/cases/message_pack_test.rb b/activerecord/test/cases/message_pack_test.rb
index 1da01db060770..8b66653454df5 100644
--- a/activerecord/test/cases/message_pack_test.rb
+++ b/activerecord/test/cases/message_pack_test.rb
@@ -9,6 +9,8 @@
require "active_record/message_pack"
class ActiveRecordMessagePackTest < ActiveRecord::TestCase
+ fixtures :posts, :comments, :authors
+
test "enshrines type IDs" do
expected = {
119 => ActiveModel::Type::Binary::Data,
diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb
index 6e50f481d0782..87e7ace8731b4 100644
--- a/activerecord/test/cases/migration/change_schema_test.rb
+++ b/activerecord/test/cases/migration/change_schema_test.rb
@@ -21,14 +21,14 @@ def setup
def test_create_table_without_id
testing_table_with_only_foo_attribute do
- assert_equal connection.columns(:testings).size, 1
+ assert_equal 1, connection.columns(:testings).size
end
end
def test_add_column_with_primary_key_attribute
testing_table_with_only_foo_attribute do
connection.add_column :testings, :id, :primary_key
- assert_equal connection.columns(:testings).size, 2
+ assert_equal 2, connection.columns(:testings).size
end
end
diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb
index a590a8af0b06b..89fac5878067b 100644
--- a/activerecord/test/cases/migration/change_table_test.rb
+++ b/activerecord/test/cases/migration/change_table_test.rb
@@ -177,17 +177,17 @@ def test_remove_exclusion_constraint_removes_exclusion_constraint
end
end
- def test_unique_key_creates_unique_key
+ def test_unique_constraint_creates_unique_constraint
with_change_table do |t|
- expect :add_unique_key, nil, [:delete_me, :foo, deferrable: :deferred, name: "unique_key"]
- t.unique_key :foo, deferrable: :deferred, name: "unique_key"
+ expect :add_unique_constraint, nil, [:delete_me, :foo, deferrable: :deferred, name: "unique_constraint"]
+ t.unique_constraint :foo, deferrable: :deferred, name: "unique_constraint"
end
end
- def test_remove_unique_key_removes_unique_key
+ def test_remove_unique_constraint_removes_unique_constraint
with_change_table do |t|
- expect :remove_unique_key, nil, [:delete_me, name: "unique_key"]
- t.remove_unique_key name: "unique_key"
+ expect :remove_unique_constraint, nil, [:delete_me, name: "unique_constraint"]
+ t.remove_unique_constraint name: "unique_constraint"
end
end
end
diff --git a/activerecord/test/cases/migration/check_constraint_test.rb b/activerecord/test/cases/migration/check_constraint_test.rb
index 4bbf4ffce8a82..d4169f43eccc5 100644
--- a/activerecord/test/cases/migration/check_constraint_test.rb
+++ b/activerecord/test/cases/migration/check_constraint_test.rb
@@ -123,6 +123,14 @@ def test_add_check_constraint
end
end
+ def test_add_check_constraint_with_if_not_exists_options
+ @connection.add_check_constraint :trades, "quantity > 0"
+
+ assert_nothing_raised do
+ @connection.add_check_constraint :trades, "quantity > 0", if_not_exists: true
+ end
+ end
+
if supports_non_unique_constraint_name?
def test_add_constraint_with_same_name_to_different_table
@connection.add_check_constraint :trades, "quantity > 0", name: "greater_than_zero"
@@ -259,7 +267,9 @@ def test_remove_check_constraint
def test_removing_check_constraint_with_if_exists_option
@connection.add_check_constraint :trades, "quantity > 0", name: "quantity_check"
- @connection.remove_check_constraint :trades, name: "quantity_check"
+ assert_nothing_raised do
+ @connection.remove_check_constraint :trades, name: "quantity_check", if_exists: true
+ end
error = assert_raises ArgumentError do
@connection.remove_check_constraint :trades, name: "quantity_check"
diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb
index 7811517e555c5..4c5d3ff8ae6cd 100644
--- a/activerecord/test/cases/migration/columns_test.rb
+++ b/activerecord/test/cases/migration/columns_test.rb
@@ -311,6 +311,15 @@ def test_change_column_default_preserves_existing_column_default_function
assert_equal "CURRENT_TIMESTAMP", TestModel.columns_hash["edited_at"].default_function
end
+ def test_change_column_default_supports_default_function_with_concatenation_operator
+ skip unless current_adapter?(:SQLite3Adapter)
+
+ add_column "test_models", "ruby_on_rails", :string
+ connection.change_column_default "test_models", "ruby_on_rails", -> { "('Ruby ' || 'on ' || 'Rails')" }
+ TestModel.reset_column_information
+ assert_equal "'Ruby ' || 'on ' || 'Rails'", TestModel.columns_hash["ruby_on_rails"].default_function
+ end
+
def test_change_column_null_false
add_column "test_models", "first_name", :string
connection.change_column_null "test_models", "first_name", false
diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb
index 8af8d750d1ff9..b5df48fab0fac 100644
--- a/activerecord/test/cases/migration/command_recorder_test.rb
+++ b/activerecord/test/cases/migration/command_recorder_test.rb
@@ -460,6 +460,16 @@ def test_invert_transaction_with_irreversible_inside_is_irreversible
end
end
+ def test_invert_add_check_constraint
+ enable = @recorder.inverse_of :add_check_constraint, [:dogs, "speed > 0", name: "speed_check"]
+ assert_equal [:remove_check_constraint, [:dogs, "speed > 0", name: "speed_check"], nil], enable
+ end
+
+ def test_invert_add_check_constraint_if_not_exists
+ enable = @recorder.inverse_of :add_check_constraint, [:dogs, "speed > 0", name: "speed_check", if_not_exists: true]
+ assert_equal [:remove_check_constraint, [:dogs, "speed > 0", name: "speed_check", if_exists: true], nil], enable
+ end
+
def test_invert_remove_check_constraint
enable = @recorder.inverse_of :remove_check_constraint, [:dogs, "speed > 0", name: "speed_check"]
assert_equal [:add_check_constraint, [:dogs, "speed > 0", name: "speed_check"], nil], enable
@@ -471,25 +481,30 @@ def test_invert_remove_check_constraint_without_expression
end
end
- def test_invert_add_unique_key_constraint_with_using_index
+ def test_invert_remove_check_constraint_if_exists
+ enable = @recorder.inverse_of :remove_check_constraint, [:dogs, "speed > 0", name: "speed_check", if_exists: true]
+ assert_equal [:add_check_constraint, [:dogs, "speed > 0", name: "speed_check", if_not_exists: true], nil], enable
+ end
+
+ def test_invert_add_unique_constraint_constraint_with_using_index
assert_raises(ActiveRecord::IrreversibleMigration) do
- @recorder.inverse_of :add_unique_key, [:dogs, using_index: "unique_index"]
+ @recorder.inverse_of :add_unique_constraint, [:dogs, using_index: "unique_index"]
end
end
- def test_invert_remove_unique_key_constraint
- enable = @recorder.inverse_of :remove_unique_key, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"]
- assert_equal [:add_unique_key, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"], nil], enable
+ def test_invert_remove_unique_constraint_constraint
+ enable = @recorder.inverse_of :remove_unique_constraint, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"]
+ assert_equal [:add_unique_constraint, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"], nil], enable
end
- def test_invert_remove_unique_key_constraint_without_options
- enable = @recorder.inverse_of :remove_unique_key, [:dogs, ["speed"]]
- assert_equal [:add_unique_key, [:dogs, ["speed"]], nil], enable
+ def test_invert_remove_unique_constraint_constraint_without_options
+ enable = @recorder.inverse_of :remove_unique_constraint, [:dogs, ["speed"]]
+ assert_equal [:add_unique_constraint, [:dogs, ["speed"]], nil], enable
end
- def test_invert_remove_unique_key_constraint_without_columns
+ def test_invert_remove_unique_constraint_constraint_without_columns
assert_raises(ActiveRecord::IrreversibleMigration) do
- @recorder.inverse_of :remove_unique_key, [:dogs, name: "uniq_speed"]
+ @recorder.inverse_of :remove_unique_constraint, [:dogs, name: "uniq_speed"]
end
end
diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb
index 36414bc5f65e5..9ace07c6080ce 100644
--- a/activerecord/test/cases/migration/compatibility_test.rb
+++ b/activerecord/test/cases/migration/compatibility_test.rb
@@ -336,22 +336,6 @@ def migrate(x)
connection.drop_table :more_testings rescue nil
end
- def test_datetime_doesnt_set_precision_on_create_table
- migration = Class.new(ActiveRecord::Migration[6.1]) {
- def migrate(x)
- create_table :more_testings do |t|
- t.datetime :published_at
- end
- end
- }.new
-
- ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate
-
- assert connection.column_exists?(:more_testings, :published_at, **precision_implicit_default)
- ensure
- connection.drop_table :more_testings rescue nil
- end
-
def test_datetime_doesnt_set_precision_on_add_column_5_0
migration = Class.new(ActiveRecord::Migration[5.0]) {
def migrate(x)
@@ -376,28 +360,6 @@ def migrate(x)
assert connection.column_exists?(:testings, :published_at, **precision_implicit_default)
end
- def test_datetime_doesnt_set_precision_on_change_column_6_1
- create_migration = Class.new(ActiveRecord::Migration[6.1]) {
- def migrate(x)
- create_table :more_testings do |t|
- t.date :published_at
- end
- end
- }.new(nil, 0)
-
- change_migration = Class.new(ActiveRecord::Migration[6.1]) {
- def migrate(x)
- change_column :more_testings, :published_at, :datetime
- end
- }.new(nil, 1)
-
- ActiveRecord::Migrator.new(:up, [create_migration, change_migration], @schema_migration, @internal_metadata).migrate
-
- assert connection.column_exists?(:more_testings, :published_at, **precision_implicit_default)
- ensure
- connection.drop_table :more_testings rescue nil
- end
-
def test_change_table_allows_if_exists_option_on_7_0
migration = Class.new(ActiveRecord::Migration[7.0]) {
def migrate(x)
@@ -723,6 +685,44 @@ def migrate(x)
connection.drop_table :more_testings rescue nil
end
+ def test_datetime_doesnt_set_precision_on_create_table
+ migration = Class.new(migration_class) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.datetime :published_at
+ end
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate
+
+ assert connection.column_exists?(:more_testings, :published_at, **precision_implicit_default)
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
+
+ def test_datetime_doesnt_set_precision_on_change_column
+ create_migration = Class.new(migration_class) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.date :published_at
+ end
+ end
+ }.new(nil, 0)
+
+ change_migration = Class.new(migration_class) {
+ def migrate(x)
+ change_column :more_testings, :published_at, :datetime
+ end
+ }.new(nil, 1)
+
+ ActiveRecord::Migrator.new(:up, [create_migration, change_migration], @schema_migration, @internal_metadata).migrate
+
+ assert connection.column_exists?(:more_testings, :published_at, **precision_implicit_default)
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
+
private
def precision_implicit_default
if current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
@@ -757,6 +757,44 @@ def migrate(x)
ensure
connection.drop_table :more_testings rescue nil
end
+
+ def test_datetime_sets_precision_6_on_create_table
+ migration = Class.new(migration_class) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.datetime :published_at
+ end
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate
+
+ assert connection.column_exists?(:more_testings, :published_at, precision: 6)
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
+
+ def test_datetime_sets_precision_6_on_change_column
+ create_migration = Class.new(migration_class) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.date :published_at
+ end
+ end
+ }.new(nil, 0)
+
+ change_migration = Class.new(migration_class) {
+ def migrate(x)
+ change_column :more_testings, :published_at, :datetime
+ end
+ }.new(nil, 1)
+
+ ActiveRecord::Migrator.new(:up, [create_migration, change_migration], @schema_migration, @internal_metadata).migrate
+
+ assert connection.column_exists?(:more_testings, :published_at, precision: 6)
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
end
class BaseCompatibilityTest < ActiveRecord::TestCase
diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb
index 05c8be8480767..edaab6972a6af 100644
--- a/activerecord/test/cases/migration/foreign_key_test.rb
+++ b/activerecord/test/cases/migration/foreign_key_test.rb
@@ -797,6 +797,96 @@ def column_for(table_name, column_name)
@connection.columns(table_name).find { |column| column.name == column_name.to_s }
end
end
+
+ class CompositeForeignKeyTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :rockets, primary_key: [:tenant_id, :id], force: true do |t|
+ t.integer :tenant_id
+ t.integer :id
+ end
+ @connection.create_table :astronauts, force: true do |t|
+ t.integer :rocket_id
+ t.integer :rocket_tenant_id
+ end
+ end
+
+ teardown do
+ @connection.drop_table :astronauts, if_exists: true rescue nil
+ @connection.drop_table :rockets, if_exists: true rescue nil
+ end
+
+ def test_add_composite_foreign_key_raises_without_options
+ error = assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.add_foreign_key :astronauts, :rockets
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ assert_match(/there is no unique constraint matching given keys for referenced table "rockets"/, error.message)
+ elsif current_adapter?(:SQLite3Adapter)
+ assert_match(/foreign key mismatch - "astronauts" referencing "rockets"/, error.message)
+ else
+ # MariaDB and different versions of MySQL generate different error messages.
+ [
+ /Foreign key constraint is incorrectly formed/i,
+ /Failed to add the foreign key constraint/i,
+ /Cannot add foreign key constraint/i
+ ].any? { |message| error.message.match?(message) }
+ end
+ end
+
+ def test_add_composite_foreign_key_infers_column
+ @connection.add_foreign_key :astronauts, :rockets, primary_key: [:tenant_id, :id]
+
+ foreign_keys = @connection.foreign_keys(:astronauts)
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal ["rocket_tenant_id", "rocket_id"], fk.column
+ end
+
+ def test_add_composite_foreign_key_raises_if_column_and_primary_key_sizes_mismatch
+ assert_raises(ArgumentError, match: ":column must reference all the :primary_key columns") do
+ @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id, primary_key: [:tenant_id, :id]
+ end
+ end
+
+ def test_foreign_key_exists
+ @connection.add_foreign_key :astronauts, :rockets, primary_key: [:tenant_id, :id]
+
+ assert @connection.foreign_key_exists?(:astronauts, :rockets)
+ assert_not @connection.foreign_key_exists?(:astronauts, :stars)
+ end
+
+ def test_foreign_key_exists_by_options
+ @connection.add_foreign_key :astronauts, :rockets, primary_key: [:tenant_id, :id]
+
+ assert @connection.foreign_key_exists?(:astronauts, :rockets, primary_key: [:tenant_id, :id])
+ assert @connection.foreign_key_exists?(:astronauts, :rockets, column: [:rocket_tenant_id, :rocket_id], primary_key: [:tenant_id, :id])
+
+ assert_not @connection.foreign_key_exists?(:astronauts, :rockets, primary_key: [:id, :tenant_id])
+ assert_not @connection.foreign_key_exists?(:astronauts, :rockets, primary_key: :id)
+ assert_not @connection.foreign_key_exists?(:astronauts, :rockets, column: :rocket_id)
+ end
+
+ def test_remove_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, primary_key: [:tenant_id, :id]
+ assert_equal 1, @connection.foreign_keys(:astronauts).size
+
+ @connection.remove_foreign_key :astronauts, :rockets
+ assert_empty @connection.foreign_keys(:astronauts)
+ end
+
+ def test_schema_dumping
+ @connection.add_foreign_key :astronauts, :rockets, primary_key: [:tenant_id, :id]
+
+ output = dump_table_schema "astronauts"
+
+ assert_match %r{\s+add_foreign_key "astronauts", "rockets", column: \["rocket_tenant_id", "rocket_id"\], primary_key: \["tenant_id", "id"\]$}, output
+ end
+ end
end
end
end
diff --git a/activerecord/test/cases/migration/unique_constraint_test.rb b/activerecord/test/cases/migration/unique_constraint_test.rb
new file mode 100644
index 0000000000000..624a8562730b9
--- /dev/null
+++ b/activerecord/test/cases/migration/unique_constraint_test.rb
@@ -0,0 +1,220 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_unique_constraints?
+ module ActiveRecord
+ class Migration
+ class UniqueConstraintTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ class Section < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "sections", force: true do |t|
+ t.integer "position", null: false
+ end
+ end
+
+ teardown do
+ @connection.drop_table "sections", if_exists: true
+ end
+
+ def test_unique_constraints
+ unique_constraints = @connection.unique_constraints("test_unique_constraints")
+
+ expected_constraints = [
+ {
+ name: "test_unique_constraints_position_deferrable_false",
+ deferrable: false,
+ column: ["position_1"]
+ }, {
+ name: "test_unique_constraints_position_deferrable_immediate",
+ deferrable: :immediate,
+ column: ["position_2"]
+ }, {
+ name: "test_unique_constraints_position_deferrable_deferred",
+ deferrable: :deferred,
+ column: ["position_3"]
+ }
+ ]
+
+ assert_equal expected_constraints.size, unique_constraints.size
+
+ expected_constraints.each do |expected_constraint|
+ constraint = unique_constraints.find { |constraint| constraint.name == expected_constraint[:name] }
+ assert_equal "test_unique_constraints", constraint.table_name
+ assert_equal expected_constraint[:name], constraint.name
+ assert_equal expected_constraint[:column], constraint.column
+ assert_equal expected_constraint[:deferrable], constraint.deferrable
+ end
+ end
+
+ def test_unique_constraints_scoped_to_schemas
+ @connection.add_unique_constraint :sections, [:position]
+
+ assert_no_changes -> { @connection.unique_constraints("sections").size } do
+ @connection.create_schema "test_schema"
+ @connection.create_table "test_schema.sections" do |t|
+ t.integer :position
+ end
+ @connection.add_unique_constraint "test_schema.sections", [:position]
+ end
+ ensure
+ @connection.drop_schema "test_schema"
+ end
+
+ def test_add_unique_constraint_without_deferrable
+ @connection.add_unique_constraint :sections, [:position]
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "uniq_rails_1e07660b77", constraint.name
+ assert_equal false, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_deferrable_false
+ @connection.add_unique_constraint :sections, [:position], deferrable: false
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "uniq_rails_1e07660b77", constraint.name
+ assert_equal false, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_deferrable_immediate
+ @connection.add_unique_constraint :sections, [:position], deferrable: :immediate
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "uniq_rails_1e07660b77", constraint.name
+ assert_equal :immediate, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_deferrable_deferred
+ @connection.add_unique_constraint :sections, [:position], deferrable: :deferred
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "uniq_rails_1e07660b77", constraint.name
+ assert_equal :deferred, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_deferrable_invalid
+ error = assert_raises(ArgumentError) do
+ @connection.add_unique_constraint :sections, [:position], deferrable: true
+ end
+
+ assert_equal "deferrable must be `:immediate` or `:deferred`, got: `true`", error.message
+ end
+
+ def test_added_deferrable_initially_immediate_unique_constraint
+ @connection.add_unique_constraint :sections, [:position], deferrable: :immediate, name: "unique_section_position"
+
+ section = Section.create!(position: 1)
+
+ assert_raises(ActiveRecord::StatementInvalid) do
+ Section.transaction(requires_new: true) do
+ Section.create!(position: 1)
+ section.update!(position: 2)
+ end
+ end
+
+ assert_nothing_raised do
+ Section.transaction(requires_new: true) do
+ Section.connection.exec_query("SET CONSTRAINTS unique_section_position DEFERRED")
+ Section.create!(position: 1)
+ section.update!(position: 2)
+
+ # NOTE: Clear `SET CONSTRAINTS` statement at the end of transaction.
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ def test_add_unique_constraint_with_name_and_using_index
+ @connection.add_index :sections, [:position], name: "unique_index", unique: true
+ @connection.add_unique_constraint :sections, name: "unique_constraint", deferrable: :immediate, using_index: "unique_index"
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "unique_constraint", constraint.name
+ assert_equal ["position"], constraint.column
+ assert_equal :immediate, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_only_using_index
+ @connection.add_index :sections, [:position], name: "unique_index", unique: true
+ @connection.add_unique_constraint :sections, using_index: "unique_index"
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal "uniq_rails_79b901ffb4", constraint.name
+ assert_equal ["position"], constraint.column
+ assert_equal false, constraint.deferrable
+ end
+
+ def test_add_unique_constraint_with_columns_and_using_index
+ @connection.add_index :sections, [:position], name: "unique_index", unique: true
+
+ assert_raises(ArgumentError) do
+ @connection.add_unique_constraint :sections, [:position], using_index: "unique_index"
+ end
+ end
+
+ def test_remove_unique_constraint
+ @connection.add_unique_constraint :sections, [:position], name: :unique_section_position
+ assert_equal 1, @connection.unique_constraints("sections").size
+ @connection.remove_unique_constraint :sections, name: :unique_section_position
+ assert_empty @connection.unique_constraints("sections")
+ end
+
+ def test_remove_unique_constraint_by_column
+ @connection.add_unique_constraint :sections, [:position]
+ assert_equal 1, @connection.unique_constraints("sections").size
+ @connection.remove_unique_constraint :sections, [:position]
+ assert_empty @connection.unique_constraints("sections")
+ end
+
+ def test_remove_non_existing_unique_constraint
+ assert_raises(ArgumentError, match: /Table 'sections' has no unique constraint/) do
+ @connection.remove_unique_constraint :sections, name: "nonexistent"
+ end
+ end
+
+ def test_renamed_unique_constraint
+ @connection.add_unique_constraint :sections, [:position]
+ @connection.rename_column :sections, :position, :new_position
+
+ unique_constraints = @connection.unique_constraints("sections")
+ assert_equal 1, unique_constraints.size
+
+ constraint = unique_constraints.first
+ assert_equal "sections", constraint.table_name
+ assert_equal ["new_position"], constraint.column
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/unique_key_test.rb b/activerecord/test/cases/migration/unique_key_test.rb
deleted file mode 100644
index 0c74a9b1c4d07..0000000000000
--- a/activerecord/test/cases/migration/unique_key_test.rb
+++ /dev/null
@@ -1,208 +0,0 @@
-# frozen_string_literal: true
-
-require "cases/helper"
-require "support/schema_dumping_helper"
-
-if ActiveRecord::Base.connection.supports_unique_keys?
- module ActiveRecord
- class Migration
- class UniqueKeyTest < ActiveRecord::TestCase
- include SchemaDumpingHelper
-
- class Section < ActiveRecord::Base
- end
-
- setup do
- @connection = ActiveRecord::Base.connection
- @connection.create_table "sections", force: true do |t|
- t.integer "position", null: false
- end
- end
-
- teardown do
- @connection.drop_table "sections", if_exists: true
- end
-
- def test_unique_keys
- unique_keys = @connection.unique_keys("test_unique_keys")
-
- expected_constraints = [
- {
- name: "test_unique_keys_position_deferrable_false",
- deferrable: false,
- column: ["position_1"]
- }, {
- name: "test_unique_keys_position_deferrable_immediate",
- deferrable: :immediate,
- column: ["position_2"]
- }, {
- name: "test_unique_keys_position_deferrable_deferred",
- deferrable: :deferred,
- column: ["position_3"]
- }
- ]
-
- assert_equal expected_constraints.size, unique_keys.size
-
- expected_constraints.each do |expected_constraint|
- constraint = unique_keys.find { |constraint| constraint.name == expected_constraint[:name] }
- assert_equal "test_unique_keys", constraint.table_name
- assert_equal expected_constraint[:name], constraint.name
- assert_equal expected_constraint[:column], constraint.column
- assert_equal expected_constraint[:deferrable], constraint.deferrable
- end
- end
-
- def test_unique_keys_scoped_to_schemas
- @connection.add_unique_key :sections, [:position]
-
- assert_no_changes -> { @connection.unique_keys("sections").size } do
- @connection.create_schema "test_schema"
- @connection.create_table "test_schema.sections" do |t|
- t.integer :position
- end
- @connection.add_unique_key "test_schema.sections", [:position]
- end
- ensure
- @connection.drop_schema "test_schema"
- end
-
- def test_add_unique_key_without_deferrable
- @connection.add_unique_key :sections, [:position]
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "uniq_rails_1e07660b77", constraint.name
- assert_equal false, constraint.deferrable
- end
-
- def test_add_unique_key_with_deferrable_false
- @connection.add_unique_key :sections, [:position], deferrable: false
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "uniq_rails_1e07660b77", constraint.name
- assert_equal false, constraint.deferrable
- end
-
- def test_add_unique_key_with_deferrable_immediate
- @connection.add_unique_key :sections, [:position], deferrable: :immediate
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "uniq_rails_1e07660b77", constraint.name
- assert_equal :immediate, constraint.deferrable
- end
-
- def test_add_unique_key_with_deferrable_deferred
- @connection.add_unique_key :sections, [:position], deferrable: :deferred
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "uniq_rails_1e07660b77", constraint.name
- assert_equal :deferred, constraint.deferrable
- end
-
- def test_add_unique_key_with_deferrable_invalid
- error = assert_raises(ArgumentError) do
- @connection.add_unique_key :sections, [:position], deferrable: true
- end
-
- assert_equal "deferrable must be `:immediate` or `:deferred`, got: `true`", error.message
- end
-
- def test_added_deferrable_initially_immediate_unique_key
- @connection.add_unique_key :sections, [:position], deferrable: :immediate, name: "unique_section_position"
-
- section = Section.create!(position: 1)
-
- assert_raises(ActiveRecord::StatementInvalid) do
- Section.transaction(requires_new: true) do
- Section.create!(position: 1)
- section.update!(position: 2)
- end
- end
-
- assert_nothing_raised do
- Section.transaction(requires_new: true) do
- Section.connection.exec_query("SET CONSTRAINTS unique_section_position DEFERRED")
- Section.create!(position: 1)
- section.update!(position: 2)
-
- # NOTE: Clear `SET CONSTRAINTS` statement at the end of transaction.
- raise ActiveRecord::Rollback
- end
- end
- end
-
- def test_add_unique_key_with_name_and_using_index
- @connection.add_index :sections, [:position], name: "unique_index", unique: true
- @connection.add_unique_key :sections, name: "unique_constraint", deferrable: :immediate, using_index: "unique_index"
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "unique_constraint", constraint.name
- assert_equal ["position"], constraint.column
- assert_equal :immediate, constraint.deferrable
- end
-
- def test_add_unique_key_with_only_using_index
- @connection.add_index :sections, [:position], name: "unique_index", unique: true
- @connection.add_unique_key :sections, using_index: "unique_index"
-
- unique_keys = @connection.unique_keys("sections")
- assert_equal 1, unique_keys.size
-
- constraint = unique_keys.first
- assert_equal "sections", constraint.table_name
- assert_equal "uniq_rails_79b901ffb4", constraint.name
- assert_equal ["position"], constraint.column
- assert_equal false, constraint.deferrable
- end
-
- def test_add_unique_key_with_columns_and_using_index
- @connection.add_index :sections, [:position], name: "unique_index", unique: true
-
- assert_raises(ArgumentError) do
- @connection.add_unique_key :sections, [:position], using_index: "unique_index"
- end
- end
-
- def test_remove_unique_key
- @connection.add_unique_key :sections, [:position], name: :unique_section_position
- assert_equal 1, @connection.unique_keys("sections").size
- @connection.remove_unique_key :sections, name: :unique_section_position
- assert_empty @connection.unique_keys("sections")
- end
-
- def test_remove_unique_key_by_column
- @connection.add_unique_key :sections, [:position]
- assert_equal 1, @connection.unique_keys("sections").size
- @connection.remove_unique_key :sections, [:position]
- assert_empty @connection.unique_keys("sections")
- end
-
- def test_remove_non_existing_unique_key
- assert_raises(ArgumentError, match: /Table 'sections' has no unique constraint/) do
- @connection.remove_unique_key :sections, name: "nonexistent"
- end
- end
- end
- end
- end
-end
diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb
index 1a5837864b454..fb8a036370cea 100644
--- a/activerecord/test/cases/nested_attributes_test.rb
+++ b/activerecord/test/cases/nested_attributes_test.rb
@@ -18,6 +18,7 @@
class TestNestedAttributesInGeneral < ActiveRecord::TestCase
teardown do
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, allow_destroy: true, reject_if: proc(&:empty?)
end
@@ -74,6 +75,7 @@ def test_should_raise_an_UnknownAttributeError_for_non_existing_nested_attribute
end
def test_should_disable_allow_destroy_by_default
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship
pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
@@ -92,6 +94,7 @@ def test_a_model_should_respond_to_underscore_destroy_and_return_if_it_is_marked
end
def test_reject_if_method_without_arguments
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, reject_if: :new_record?
pirate = Pirate.new(catchphrase: "Stop wastin' me time")
@@ -100,6 +103,7 @@ def test_reject_if_method_without_arguments
end
def test_reject_if_method_with_arguments
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, reject_if: :reject_empty_ships_on_create
pirate = Pirate.new(catchphrase: "Stop wastin' me time")
@@ -113,6 +117,7 @@ def test_reject_if_method_with_arguments
end
def test_reject_if_with_indifferent_keys
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| attributes[:name].blank? }
pirate = Pirate.new(catchphrase: "Stop wastin' me time")
@@ -121,6 +126,7 @@ def test_reject_if_with_indifferent_keys
end
def test_reject_if_with_a_proc_which_returns_true_always_for_has_one
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| true }
pirate = Pirate.create(catchphrase: "Stop wastin' me time")
ship = pirate.create_ship(name: "s1")
@@ -143,6 +149,7 @@ def test_do_not_allow_assigning_foreign_key_when_reusing_existing_new_record
end
def test_reject_if_with_a_proc_which_returns_true_always_for_has_many
+ Human.nested_attributes_options.delete :interests
Human.accepts_nested_attributes_for :interests, reject_if: proc { |attributes| true }
human = Human.create(name: "John")
interest = human.interests.create(topic: "photography")
@@ -151,6 +158,7 @@ def test_reject_if_with_a_proc_which_returns_true_always_for_has_many
end
def test_destroy_works_independent_of_reject_if
+ Human.nested_attributes_options.delete :interests
Human.accepts_nested_attributes_for :interests, reject_if: proc { |attributes| true }, allow_destroy: true
human = Human.create(name: "Jon")
interest = human.interests.create(topic: "the ladies")
@@ -159,6 +167,7 @@ def test_destroy_works_independent_of_reject_if
end
def test_reject_if_is_not_short_circuited_if_allow_destroy_is_false
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, reject_if: ->(a) { a[:name] == "The Golden Hind" }, allow_destroy: false
pirate = Pirate.create!(catchphrase: "Stop wastin' me time", ship_attributes: { name: "White Pearl", _destroy: "1" })
@@ -172,6 +181,7 @@ def test_reject_if_is_not_short_circuited_if_allow_destroy_is_false
end
def test_has_many_association_updating_a_single_record
+ Human.nested_attributes_options.delete(:interests)
Human.accepts_nested_attributes_for(:interests)
human = Human.create(name: "John")
interest = human.interests.create(topic: "photography")
@@ -180,6 +190,7 @@ def test_has_many_association_updating_a_single_record
end
def test_reject_if_with_blank_nested_attributes_id
+ Pirate.nested_attributes_options.delete :ship
# When using a select list to choose an existing 'ship' id, with include_blank: true
Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| attributes[:id].blank? }
@@ -189,6 +200,7 @@ def test_reject_if_with_blank_nested_attributes_id
end
def test_first_and_array_index_zero_methods_return_the_same_value_when_nested_attributes_are_set_to_update_existing_record
+ Human.nested_attributes_options.delete(:interests)
Human.accepts_nested_attributes_for(:interests)
human = Human.create(name: "John")
interest = human.interests.create topic: "gardening"
@@ -198,6 +210,7 @@ def test_first_and_array_index_zero_methods_return_the_same_value_when_nested_at
end
def test_allows_class_to_override_setter_and_call_super
+ Pirate.nested_attributes_options.delete :parrot
mean_pirate_class = Class.new(Pirate) do
accepts_nested_attributes_for :parrot
def parrot_attributes=(attrs)
@@ -222,6 +235,7 @@ def test_accepts_nested_attributes_for_can_be_overridden_in_subclasses
end
def test_should_not_create_duplicates_with_create_with
+ Human.nested_attributes_options.delete(:interests)
Human.accepts_nested_attributes_for(:interests)
assert_difference("Interest.count", 1) do
@@ -338,12 +352,14 @@ def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
end
def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, allow_destroy: false, reject_if: proc(&:empty?)
@pirate.update(ship_attributes: { id: @pirate.ship.id, _destroy: "1" })
assert_equal @ship, @pirate.reload.ship
+ Pirate.nested_attributes_options.delete :ship
Pirate.accepts_nested_attributes_for :ship, allow_destroy: true, reject_if: proc(&:empty?)
end
@@ -411,6 +427,7 @@ def test_should_update_existing_when_update_only_is_true_and_id_is_given
end
def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction
+ Pirate.nested_attributes_options.delete :update_only_ship
Pirate.accepts_nested_attributes_for :update_only_ship, update_only: true, allow_destroy: true
@ship.delete
@ship = @pirate.create_update_only_ship(name: "Nights Dirty Lightning")
@@ -420,6 +437,7 @@ def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is
assert_nil @pirate.reload.ship
assert_raise(ActiveRecord::RecordNotFound) { Ship.find(@ship.id) }
+ Pirate.nested_attributes_options.delete :update_only_ship
Pirate.accepts_nested_attributes_for :update_only_ship, update_only: true, allow_destroy: false
end
end
@@ -532,11 +550,13 @@ def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
end
def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
+ Ship.nested_attributes_options.delete :pirate
Ship.accepts_nested_attributes_for :pirate, allow_destroy: false, reject_if: proc(&:empty?)
@ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: "1" })
assert_nothing_raised { @ship.pirate.reload }
ensure
+ Ship.nested_attributes_options.delete :pirate
Ship.accepts_nested_attributes_for :pirate, allow_destroy: true, reject_if: proc(&:empty?)
end
@@ -588,6 +608,7 @@ def test_should_update_existing_when_update_only_is_true_and_id_is_given
end
def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction
+ Ship.nested_attributes_options.delete :update_only_pirate
Ship.accepts_nested_attributes_for :update_only_pirate, update_only: true, allow_destroy: true
@pirate.delete
@pirate = @ship.create_update_only_pirate(catchphrase: "Aye")
@@ -596,6 +617,7 @@ def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is
assert_raise(ActiveRecord::RecordNotFound) { @pirate.reload }
+ Ship.nested_attributes_options.delete :update_only_pirate
Ship.accepts_nested_attributes_for :update_only_pirate, update_only: true, allow_destroy: false
end
end
@@ -820,6 +842,7 @@ def test_should_automatically_enable_autosave_on_the_association
end
def test_validate_presence_of_parent_works_with_inverse_of
+ Human.nested_attributes_options.delete(:interests)
Human.accepts_nested_attributes_for(:interests)
assert_equal :human, Human.reflect_on_association(:interests).options[:inverse_of]
assert_equal :interests, Interest.reflect_on_association(:human).options[:inverse_of]
@@ -842,6 +865,7 @@ def test_can_use_symbols_as_object_identifier
end
def test_numeric_column_changes_from_zero_to_no_empty_string
+ Human.nested_attributes_options.delete(:interests)
Human.accepts_nested_attributes_for(:interests)
repair_validations(Interest) do
@@ -909,6 +933,7 @@ def setup
module NestedAttributesLimitTests
def teardown
+ Pirate.nested_attributes_options.delete :parrots
Pirate.accepts_nested_attributes_for :parrots, allow_destroy: true, reject_if: proc(&:empty?)
end
@@ -933,6 +958,7 @@ def test_limit_with_exceeding_records
class TestNestedAttributesLimitNumeric < ActiveRecord::TestCase
def setup
+ Pirate.nested_attributes_options.delete :parrots
Pirate.accepts_nested_attributes_for :parrots, limit: 2
@pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
@@ -943,6 +969,7 @@ def setup
class TestNestedAttributesLimitSymbol < ActiveRecord::TestCase
def setup
+ Pirate.nested_attributes_options.delete :parrots
Pirate.accepts_nested_attributes_for :parrots, limit: :parrots_limit
@pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?", parrots_limit: 2)
@@ -953,6 +980,7 @@ def setup
class TestNestedAttributesLimitProc < ActiveRecord::TestCase
def setup
+ Pirate.nested_attributes_options.delete :parrots
Pirate.accepts_nested_attributes_for :parrots, limit: proc { 2 }
@pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
@@ -965,6 +993,7 @@ class TestNestedAttributesWithNonStandardPrimaryKeys < ActiveRecord::TestCase
fixtures :owners, :pets
def setup
+ Owner.nested_attributes_options.delete :pets
Owner.accepts_nested_attributes_for :pets, allow_destroy: true
@owner = owners(:ashley)
@@ -1105,7 +1134,7 @@ def setup
part = ShipPart.new(name: "Stern", ship_attributes: { name: nil })
assert_not_predicate part, :valid?
- assert_equal ["Ship name can’t be blank"], part.errors.full_messages
+ assert_equal ["Ship name can't be blank"], part.errors.full_messages
end
end
@@ -1132,3 +1161,22 @@ def test_should_build_a_new_record_based_on_the_delegated_type
assert_equal "Hello world!", @entry.entryable.subject
end
end
+
+class TestPreDeclaredNestedAttributesAssociation < ActiveRecord::TestCase
+ setup do
+ assert @current_options = Developer.nested_attributes_options[:projects]
+ end
+
+ def test_should_raise_an_argument_error_with_similar_options
+ assert_raises ArgumentError do
+ Developer.accepts_nested_attributes_for :projects, **@current_options
+ end
+ end
+
+ def test_should_raise_an_argument_error_with_varying_options
+ assert_equal false, @current_options[:update_only]
+ assert_raises ArgumentError do
+ Developer.accepts_nested_attributes_for :projects, **@current_options.merge(update_only: true)
+ end
+ end
+end
diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb
index 008f4400b86a0..35124804095b0 100644
--- a/activerecord/test/cases/persistence_test.rb
+++ b/activerecord/test/cases/persistence_test.rb
@@ -47,14 +47,14 @@ def test_fills_auto_populated_columns_on_creation
record_with_defaults = Default.create
assert_not_nil record_with_defaults.id
assert_equal "Ruby on Rails", record_with_defaults.ruby_on_rails
- assert_not_nil record_with_defaults.virtual_stored_number
- assert_not_nil record_with_defaults.rand_number
+ assert_not_nil record_with_defaults.virtual_stored_number if current_adapter?(:PostgreSQLAdapter)
+ assert_not_nil record_with_defaults.random_number
assert_not_nil record_with_defaults.modified_date
assert_not_nil record_with_defaults.modified_date_function
assert_not_nil record_with_defaults.modified_time
assert_not_nil record_with_defaults.modified_time_without_precision
assert_not_nil record_with_defaults.modified_time_function
- end if current_adapter?(:PostgreSQLAdapter)
+ end if current_adapter?(:PostgreSQLAdapter) || current_adapter?(:SQLite3Adapter)
def test_update_many
topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } }
@@ -821,6 +821,14 @@ def test_destroy!
assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) }
end
+ def test_destroy_for_a_failed_to_destroy_cpk_record
+ book = cpk_books(:cpk_great_author_first_book)
+ book.fail_destroy = true
+ assert_raises(ActiveRecord::RecordNotDestroyed, match: /Failed to destroy Cpk::Book with \["author_id", "id"\]=/) do
+ book.destroy!
+ end
+ end
+
def test_find_raises_record_not_found_exception
assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) }
end
diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb
index feee7ad5d8602..6b6eb1c897c73 100644
--- a/activerecord/test/cases/reflection_test.rb
+++ b/activerecord/test/cases/reflection_test.rb
@@ -29,6 +29,7 @@
require "models/recipe"
require "models/user_with_invalid_relation"
require "models/hardback"
+require "models/sharded/comment"
class ReflectionTest < ActiveRecord::TestCase
include ActiveRecord::Reflection
@@ -569,13 +570,13 @@ def test_includes_accepts_strings
def test_reflect_on_association_accepts_symbols
assert_nothing_raised do
- assert_equal Hotel.reflect_on_association(:departments).name, :departments
+ assert_equal :departments, Hotel.reflect_on_association(:departments).name
end
end
def test_reflect_on_association_accepts_strings
assert_nothing_raised do
- assert_equal Hotel.reflect_on_association("departments").name, :departments
+ assert_equal :departments, Hotel.reflect_on_association("departments").name
end
end
@@ -614,6 +615,11 @@ def test_automatic_inverse_does_not_suppress_name_error_from_incidental_code
end
end
+ def test_association_primary_key_uses_explicit_primary_key_option_as_first_priority
+ actual = Sharded::Comment.reflect_on_association(:blog_post_by_id).association_primary_key
+ assert_equal "id", actual
+ end
+
private
def assert_reflection(klass, association, options)
assert reflection = klass.reflect_on_association(association)
diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb
index 751748d6db68b..eb34cff077a68 100644
--- a/activerecord/test/cases/relation/merging_test.rb
+++ b/activerecord/test/cases/relation/merging_test.rb
@@ -24,29 +24,41 @@ def test_merge_in_clause
assert_equal [mary], david_and_mary.merge(Author.where(id: mary))
assert_equal [mary], david_and_mary.merge(Author.rewhere(id: mary))
- assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ end
assert_equal [bob], david_and_mary.merge(Author.where(id: bob))
assert_equal [bob], david_and_mary.merge(Author.rewhere(id: bob))
- assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ end
assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]))
- assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ end
assert_equal [mary, bob], david_and_mary.merge(mary_and_bob)
- assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
assert_equal [david, mary], mary_and_bob.merge(david_and_mary)
- assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
david_and_bob = Author.where(id: david).or(Author.where(name: "Bob"))
assert_equal [david], david_and_mary.merge(david_and_bob)
- assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ end
assert_equal [david], david_and_mary.and(david_and_bob)
assert_equal authors, david_and_mary.or(david_and_bob)
end
@@ -62,29 +74,41 @@ def test_merge_between_clause
assert_equal [mary], david_and_mary.merge(Author.where(id: mary))
assert_equal [mary], david_and_mary.merge(Author.rewhere(id: mary))
- assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ end
assert_equal [bob], david_and_mary.merge(Author.where(id: bob))
assert_equal [bob], david_and_mary.merge(Author.rewhere(id: bob))
- assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ end
assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]))
- assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ end
assert_equal [mary, bob], david_and_mary.merge(mary_and_bob)
- assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
assert_equal [david, mary], mary_and_bob.merge(david_and_mary)
- assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
david_and_bob = Author.where(id: david).or(Author.where(name: "Bob"))
assert_equal [david], david_and_mary.merge(david_and_bob)
- assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ end
assert_equal [david], david_and_mary.and(david_and_bob)
assert_equal authors, david_and_mary.or(david_and_bob)
end
@@ -100,30 +124,42 @@ def test_merge_or_clause
assert_equal [mary], david_and_mary.merge(Author.where(id: mary))
assert_equal [mary], david_and_mary.merge(Author.rewhere(id: mary))
- assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary], david_and_mary.merge(Author.where(id: mary), rewhere: true)
+ end
assert_equal [bob], david_and_mary.merge(Author.where(id: bob))
assert_equal [bob], david_and_mary.merge(Author.rewhere(id: bob))
- assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [bob], david_and_mary.merge(Author.where(id: bob), rewhere: true)
+ end
assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]))
- assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, bob], mary_and_bob.merge(Author.where(id: [david, bob]), rewhere: true)
+ end
assert_equal [mary, bob], david_and_mary.merge(mary_and_bob)
- assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [mary, bob], david_and_mary.merge(mary_and_bob, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
assert_equal [david, mary], mary_and_bob.merge(david_and_mary)
- assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, mary], mary_and_bob.merge(david_and_mary, rewhere: true)
+ end
assert_equal [mary], david_and_mary.and(mary_and_bob)
assert_equal authors, david_and_mary.or(mary_and_bob)
david_and_bob = Author.where(id: david).or(Author.where(name: "Bob"))
assert_equal [david], david_and_mary.merge(david_and_bob)
- assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], david_and_mary.merge(david_and_bob, rewhere: true)
+ end
assert_equal [david], david_and_mary.and(david_and_bob)
assert_equal authors, david_and_mary.or(david_and_bob)
end
@@ -136,10 +172,14 @@ def test_merge_not_in_clause
assert_equal [david], non_mary_and_bob
assert_equal [david], Author.where(id: david).merge(non_mary_and_bob)
- assert_equal [david], Author.where(id: david).merge(non_mary_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], Author.where(id: david).merge(non_mary_and_bob, rewhere: true)
+ end
assert_equal [david], Author.where(id: mary).merge(non_mary_and_bob)
- assert_equal [david], Author.where(id: mary).merge(non_mary_and_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], Author.where(id: mary).merge(non_mary_and_bob, rewhere: true)
+ end
end
def test_merge_not_range_clause
@@ -150,10 +190,14 @@ def test_merge_not_range_clause
assert_equal [david, mary], less_than_bob
assert_equal [david, mary], Author.where(id: david).merge(less_than_bob)
- assert_equal [david, mary], Author.where(id: david).merge(less_than_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, mary], Author.where(id: david).merge(less_than_bob, rewhere: true)
+ end
assert_equal [david, mary], Author.where(id: mary).merge(less_than_bob)
- assert_equal [david, mary], Author.where(id: mary).merge(less_than_bob, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david, mary], Author.where(id: mary).merge(less_than_bob, rewhere: true)
+ end
end
def test_merge_doesnt_duplicate_same_clauses
@@ -174,7 +218,9 @@ def test_merge_doesnt_duplicate_same_clauses
end
assert_sql(/WHERE \(#{Regexp.escape(author_id)} IN \('1'\)\)\z/) do
- assert_equal [david], only_david.merge(only_david, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], only_david.merge(only_david, rewhere: true)
+ end
end
else
assert_sql(/WHERE \(#{Regexp.escape(author_id)} IN \(1\)\)\z/) do
@@ -182,7 +228,9 @@ def test_merge_doesnt_duplicate_same_clauses
end
assert_sql(/WHERE \(#{Regexp.escape(author_id)} IN \(1\)\)\z/) do
- assert_equal [david], only_david.merge(only_david, rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal [david], only_david.merge(only_david, rewhere: true)
+ end
end
end
end
@@ -207,7 +255,9 @@ def test_relation_merging_with_arel_equalities_keeps_last_equality
devs = Developer.where(salary_attr.eq(80000)).merge(Developer.where(salary_attr.eq(9000)))
assert_equal [developers(:poor_jamis)], devs.to_a
- devs = Developer.where(salary_attr.eq(80000)).merge(Developer.where(salary_attr.eq(9000)), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ devs = Developer.where(salary_attr.eq(80000)).merge(Developer.where(salary_attr.eq(9000)), rewhere: true)
+ end
assert_equal [developers(:poor_jamis)], devs.to_a
devs = Developer.where(salary_attr.eq(80000)).rewhere(salary_attr.eq(9000))
@@ -221,7 +271,9 @@ def test_relation_merging_with_arel_equalities_keeps_last_equality_with_non_attr
devs = Developer.where(abs_salary.eq(80000)).merge(Developer.where(abs_salary.eq(9000)))
assert_equal [developers(:poor_jamis)], devs.to_a
- devs = Developer.where(abs_salary.eq(80000)).merge(Developer.where(abs_salary.eq(9000)), rewhere: true)
+ assert_deprecated(ActiveRecord.deprecator) do
+ devs = Developer.where(abs_salary.eq(80000)).merge(Developer.where(abs_salary.eq(9000)), rewhere: true)
+ end
assert_equal [developers(:poor_jamis)], devs.to_a
devs = Developer.where(abs_salary.eq(80000)).rewhere(abs_salary.eq(9000))
@@ -262,7 +314,7 @@ def test_relation_merging_with_left_outer_joins
end
def test_relation_merging_with_skip_query_cache
- assert_equal Post.all.merge(Post.all.skip_query_cache!).skip_query_cache_value, true
+ assert_equal true, Post.all.merge(Post.all.skip_query_cache!).skip_query_cache_value
end
def test_relation_merging_with_association
@@ -347,6 +399,24 @@ def test_merging_duplicated_annotations
Post.annotate("bar").merge(Post.annotate("foo")).merge(posts).to_a
end
end
+
+ def test_rewhere_true_is_deprecated
+ message = <<-MSG.squish
+ Specifying `Relation#merge(rewhere: true)` is deprecated
+ MSG
+ assert_deprecated(message, ActiveRecord.deprecator) do
+ Author.where(id: 1).merge(Author.where(id: 2), rewhere: true)
+ end
+ end
+
+ def test_rewhere_false_is_deprecated
+ message = <<-MSG.squish
+ Relation#merge(rewhere: false)` is deprecated without replacement
+ MSG
+ assert_deprecated(message, ActiveRecord.deprecator) do
+ Author.where(id: 1).merge(Author.where(id: 2), rewhere: false)
+ end
+ end
end
class MergingDifferentRelationsTest < ActiveRecord::TestCase
diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb
index ae397ca79a942..a8a18e10d4615 100644
--- a/activerecord/test/cases/relations_test.rb
+++ b/activerecord/test/cases/relations_test.rb
@@ -1561,8 +1561,8 @@ def test_find_or_create_by_with_block
record.color = "blue"
end
assert_predicate bird, :persisted?
- assert_equal bird.name, "bob"
- assert_equal bird.color, "blue"
+ assert_equal "bob", bird.name
+ assert_equal "blue", bird.color
assert_equal bird, Bird.find_or_create_by(name: "bob", color: "blue")
end
@@ -1669,8 +1669,8 @@ def test_find_or_initialize_by_with_block
record.color = "blue"
end
assert_predicate bird, :new_record?
- assert_equal bird.name, "bob"
- assert_equal bird.color, "blue"
+ assert_equal "bob", bird.name
+ assert_equal "blue", bird.color
bird.save!
assert_equal bird, Bird.find_or_initialize_by(name: "bob", color: "blue")
@@ -2329,6 +2329,20 @@ def test_unscope_grouped_where
assert_equal Post.count, posts.unscope(where: :title).count
end
+ def test_unscope_with_double_dot_where
+ posts = Post.where(id: 1..2)
+
+ assert_equal 2, posts.count
+ assert_equal Post.count, posts.unscope(where: :id).count
+ end
+
+ def test_unscope_with_triple_dot_where
+ posts = Post.where(id: 1...3)
+
+ assert_equal 2, posts.count
+ assert_equal Post.count, posts.unscope(where: :id).count
+ end
+
def test_locked_should_not_build_arel
posts = Post.locked
assert_predicate posts, :locked?
diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb
index 5ec44b85488a5..64a9dea4e149d 100644
--- a/activerecord/test/cases/schema_dumper_test.rb
+++ b/activerecord/test/cases/schema_dumper_test.rb
@@ -241,19 +241,19 @@ def test_schema_dumps_exclusion_constraints
end
end
- if ActiveRecord::Base.connection.supports_unique_keys?
- def test_schema_dumps_unique_keys
- output = dump_table_schema("test_unique_keys")
- constraint_definitions = output.split(/\n/).grep(/t\.unique_key/)
+ if ActiveRecord::Base.connection.supports_unique_constraints?
+ def test_schema_dumps_unique_constraints
+ output = dump_table_schema("test_unique_constraints")
+ constraint_definitions = output.split(/\n/).grep(/t\.unique_constraint/)
assert_equal 3, constraint_definitions.size
- assert_match 't.unique_key ["position_1"], name: "test_unique_keys_position_deferrable_false"', output
- assert_match 't.unique_key ["position_2"], deferrable: :immediate, name: "test_unique_keys_position_deferrable_immediate"', output
- assert_match 't.unique_key ["position_3"], deferrable: :deferred, name: "test_unique_keys_position_deferrable_deferred"', output
+ assert_match 't.unique_constraint ["position_1"], name: "test_unique_constraints_position_deferrable_false"', output
+ assert_match 't.unique_constraint ["position_2"], deferrable: :immediate, name: "test_unique_constraints_position_deferrable_immediate"', output
+ assert_match 't.unique_constraint ["position_3"], deferrable: :deferred, name: "test_unique_constraints_position_deferrable_deferred"', output
end
- def test_schema_does_not_dumps_unique_key_indexes
- output = dump_table_schema("test_unique_keys")
+ def test_schema_does_not_dump_unique_constraints_as_indexes
+ output = dump_table_schema("test_unique_constraints")
unique_index_definitions = output.split(/\n/).grep(/t\.index.*unique: true/)
assert_equal 0, unique_index_definitions.size
@@ -308,6 +308,15 @@ def test_schema_dump_expression_indices
assert false
end
end
+
+ if current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
+ def test_schema_dump_expression_indices_escaping
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*full_name_index/).first.strip
+ index_definition.sub!(/, name: "full_name_index"\z/, "")
+
+ assert_match %r{concat_ws\(`firm_name`,`name`,_utf8mb4' '\)\)"\z}i, index_definition
+ end
+ end
end
if current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
diff --git a/activerecord/test/cases/secure_token_test.rb b/activerecord/test/cases/secure_token_test.rb
index 37a34a267418b..471fd480f36aa 100644
--- a/activerecord/test/cases/secure_token_test.rb
+++ b/activerecord/test/cases/secure_token_test.rb
@@ -16,6 +16,49 @@ def test_token_values_are_generated_for_specified_attributes_and_persisted_on_sa
assert_equal 36, @user.auth_token.size
end
+ def test_generating_token_on_initialize_does_not_affect_reading_from_the_column
+ model = Class.new(ActiveRecord::Base) do
+ self.table_name = "users"
+ has_secure_token on: :initialize
+ end
+
+ token = "abc123"
+
+ user = model.create!(token: token)
+
+ assert_equal token, user.token
+ assert_equal token, user.reload.token
+ assert_equal token, model.find(user.id).token
+ end
+
+ def test_generating_token_on_initialize_happens_only_once
+ model = Class.new(ActiveRecord::Base) do
+ self.table_name = "users"
+ has_secure_token on: :initialize
+ end
+
+ token = " "
+
+ user = model.new
+ user.update!(token: token)
+
+ assert_equal token, user.token
+ assert_equal token, user.reload.token
+ assert_equal token, model.find(user.id).token
+ end
+
+ def test_generating_token_on_initialize_is_skipped_if_column_was_not_selected
+ model = Class.new(ActiveRecord::Base) do
+ self.table_name = "users"
+ has_secure_token on: :initialize
+ end
+
+ model.create!
+ assert_nothing_raised do
+ model.select(:id).last
+ end
+ end
+
def test_regenerating_the_secure_token
@user.save
old_token = @user.token
@@ -46,14 +89,13 @@ def test_token_length_cannot_be_less_than_24_characters
end
def test_token_on_callback
- User.class_eval do
- undef regenerate_token
-
+ model = Class.new(ActiveRecord::Base) do
+ self.table_name = "users"
has_secure_token on: :initialize
end
- model = User.new
+ user = model.new
- assert_predicate model.token, :present?
+ assert_predicate user.token, :present?
end
end
diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb
index 518e13aeac6c8..a078fb48913e1 100644
--- a/activerecord/test/cases/test_case.rb
+++ b/activerecord/test/cases/test_case.rb
@@ -235,7 +235,10 @@ def clean_up_connection_handler
handler = ActiveRecord::Base.connection_handler
handler.instance_variable_get(:@connection_name_to_pool_manager).each do |owner, pool_manager|
pool_manager.role_names.each do |role_name|
- next if role_name == ActiveRecord::Base.default_role
+ next if role_name == ActiveRecord::Base.default_role &&
+ # TODO: Remove this helper when `remove_connection` for different shards is fixed.
+ # See https://github.com/rails/rails/pull/49382.
+ ["ActiveRecord::Base", "ARUnit2Model", "Contact", "ContactSti"].include?(owner)
pool_manager.remove_role(role_name)
end
end
diff --git a/activerecord/test/cases/transaction_instrumentation_test.rb b/activerecord/test/cases/transaction_instrumentation_test.rb
new file mode 100644
index 0000000000000..b27226d649653
--- /dev/null
+++ b/activerecord/test/cases/transaction_instrumentation_test.rb
@@ -0,0 +1,290 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class TransactionInstrumentationTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ fixtures :topics
+
+ def test_transaction_instrumentation_on_commit
+ topic = topics(:fifth)
+
+ notified = false
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ assert event.payload[:connection]
+ assert_equal :commit, event.payload[:outcome]
+ notified = true
+ end
+
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Ruby on Rails")
+ end
+
+ assert notified
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_on_rollback
+ topic = topics(:fifth)
+
+ notified = false
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ assert event.payload[:connection]
+ assert_equal :rollback, event.payload[:outcome]
+ notified = true
+ end
+
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Ruby on Rails")
+ raise ActiveRecord::Rollback
+ end
+
+ assert notified
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_with_savepoints
+ topic = topics(:fifth)
+
+ events = []
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ events << event
+ end
+
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Sinatra")
+ ActiveRecord::Base.transaction(requires_new: true) do
+ topic.update(title: "Ruby on Rails")
+ end
+ end
+
+ assert_equal 2, events.count
+ savepoint, real = events
+ assert_equal :commit, savepoint.payload[:outcome]
+ assert_equal :commit, real.payload[:outcome]
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_with_restart_parent_transaction_on_commit
+ topic = topics(:fifth)
+
+ events = []
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ events << event
+ end
+
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction(requires_new: true) do
+ topic.update(title: "Ruby on Rails")
+ end
+ end
+
+ assert_equal 1, events.count
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_with_restart_parent_transaction_on_rollback
+ topic = topics(:fifth)
+
+ events = []
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ events << event
+ end
+
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction(requires_new: true) do
+ topic.update(title: "Ruby on Rails")
+ raise ActiveRecord::Rollback
+ end
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal 2, events.count
+ restart, real = events
+ assert_equal :restart, restart.payload[:outcome]
+ assert_equal :rollback, real.payload[:outcome]
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_with_unmaterialized_restart_parent_transactions
+ events = []
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ events << event
+ end
+
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction(requires_new: true) do
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ assert_equal 0, events.count
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_with_restart_savepoint_parent_transactions
+ topic = topics(:fifth)
+
+ events = []
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ events << event
+ end
+
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Sinatry")
+ ActiveRecord::Base.transaction(requires_new: true) do
+ ActiveRecord::Base.transaction(requires_new: true) do
+ topic.update(title: "Ruby on Rails")
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ assert_equal 3, events.count
+ restart, savepoint, real = events
+ assert_equal :restart, restart.payload[:outcome]
+ assert_equal :commit, savepoint.payload[:outcome]
+ assert_equal :commit, real.payload[:outcome]
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_only_fires_if_materialized
+ notified = false
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ notified = true
+ end
+
+ ActiveRecord::Base.transaction do
+ end
+
+ assert_not notified
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_fires_before_after_commit_callbacks
+ notified = false
+ after_commit_triggered = false
+
+ topic_model = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+
+ after_commit do
+ after_commit_triggered = true
+ end
+ end
+
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ assert_not after_commit_triggered, "Transaction notification fired after the after_commit callback"
+ notified = true
+ end
+
+ topic_model.create!
+
+ assert notified
+ assert after_commit_triggered
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_fires_before_after_rollback_callbacks
+ notified = false
+ after_rollback_triggered = false
+
+ topic_model = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+
+ after_rollback do
+ after_rollback_triggered = true
+ end
+ end
+
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ assert_not after_rollback_triggered, "Transaction notification fired after the after_rollback callback"
+ notified = true
+ end
+
+ topic_model.transaction do
+ topic_model.create!
+ raise ActiveRecord::Rollback
+ end
+
+ assert notified
+ assert after_rollback_triggered
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_transaction_instrumentation_on_failed_commit
+ topic = topics(:fifth)
+
+ notified = false
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ notified = true
+ end
+
+ error = Class.new(StandardError)
+ assert_raises error do
+ ActiveRecord::Base.connection.stub(:commit_db_transaction, -> (*) { raise error }) do
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Ruby on Rails")
+ end
+ end
+ end
+
+ assert notified
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ unless in_memory_db?
+ def test_transaction_instrumentation_on_failed_rollback
+ topic = topics(:fifth)
+
+ notified = false
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ assert_equal :incomplete, event.payload[:outcome]
+ notified = true
+ end
+
+ error = Class.new(StandardError)
+ assert_raises error do
+ ActiveRecord::Base.connection.stub(:rollback_db_transaction, -> (*) { raise error }) do
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Ruby on Rails")
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ assert notified
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+ end
+
+ def test_transaction_instrumentation_on_broken_subscription
+ topic = topics(:fifth)
+
+ error = Class.new(StandardError)
+ subscriber = ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
+ raise error
+ end
+
+ assert_raises(error) do
+ ActiveRecord::Base.transaction do
+ topic.update(title: "Ruby on Rails")
+ end
+ end
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+end
diff --git a/activerecord/test/cases/type/type_map_test.rb b/activerecord/test/cases/type/type_map_test.rb
index 7541234395e6f..f5aed062d068c 100644
--- a/activerecord/test/cases/type/type_map_test.rb
+++ b/activerecord/test/cases/type/type_map_test.rb
@@ -103,8 +103,8 @@ def test_aliases_keep_metadata
mapping.register_type(/decimal/i) { |sql_type| sql_type }
mapping.alias_type(/number/i, "decimal")
- assert_equal mapping.lookup("number(20)"), "decimal(20)"
- assert_equal mapping.lookup("number"), "decimal"
+ assert_equal "decimal(20)", mapping.lookup("number(20)")
+ assert_equal "decimal", mapping.lookup("number")
end
def test_fuzzy_lookup
diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb
index fb6596ba36d17..993c201f039a2 100644
--- a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb
+++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb
@@ -48,7 +48,7 @@ def test_generate_message_taken_with_custom_message
topic = Topic.new
topic.errors.add(:title, :invalid)
topic.errors.add(:title, :blank)
- assert_equal "Validation failed: Title is invalid, Title can’t be blank", ActiveRecord::RecordInvalid.new(topic).message
+ assert_equal "Validation failed: Title is invalid, Title can't be blank", ActiveRecord::RecordInvalid.new(topic).message
end
test "RecordInvalid exception translation falls back to the :errors namespace" do
diff --git a/activerecord/test/cases/validations/numericality_validation_test.rb b/activerecord/test/cases/validations/numericality_validation_test.rb
index 42006f2a810e7..37a166b77a9ed 100644
--- a/activerecord/test/cases/validations/numericality_validation_test.rb
+++ b/activerecord/test/cases/validations/numericality_validation_test.rb
@@ -5,7 +5,6 @@
class NumericalityValidationTest < ActiveRecord::TestCase
def setup
- NumericData.generate_alias_attributes
@model_class = NumericData.dup
end
@@ -112,14 +111,9 @@ def test_virtual_attribute_with_precision_round_half_even
subject = model_class.new(virtual_decimal_number: 123.455)
- if 123.455.to_d(5) == BigDecimal("123.46")
- # BigDecimal's to_d behavior changed in BigDecimal 3.0.1, see https://github.com/ruby/bigdecimal/issues/70
- # TODO: replace this with a check against BigDecimal::VERSION, currently
- # we just check the behavior because both versions of BigDecimal report "3.0.0"
- assert_not_predicate subject, :valid?
- else
- assert_predicate subject, :valid?
- end
+ # BigDecimal's to_d behavior changed in BigDecimal 3.1.0, see https://github.com/ruby/bigdecimal/issues/70
+ # Since BigDecimal 3.1.4 or higher is installed as an Active Support dependency, we just check the behavior of BigDecimal 3.1.0+.
+ assert_not_predicate subject, :valid?
end
def test_virtual_attribute_with_precision_round_up
diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb
index f35c2206987e7..2ee77d0c723fd 100644
--- a/activerecord/test/cases/validations/uniqueness_validation_test.rb
+++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb
@@ -137,12 +137,12 @@ def test_validates_uniqueness_with_validates
def test_validate_uniqueness_when_integer_out_of_range
entry = BigIntTest.create(engines_count: INT_MAX_VALUE + 1)
- assert_equal entry.errors[:engines_count], ["is not included in the list"]
+ assert_equal ["is not included in the list"], entry.errors[:engines_count]
end
def test_validate_uniqueness_when_integer_out_of_range_show_order_does_not_matter
entry = BigIntReverseTest.create(engines_count: INT_MAX_VALUE + 1)
- assert_equal entry.errors[:engines_count], ["is not included in the list"]
+ assert_equal ["is not included in the list"], entry.errors[:engines_count]
end
def test_validates_uniqueness_with_newline_chars
diff --git a/activerecord/test/config.example.yml b/activerecord/test/config.example.yml
index 65f8df5f61409..be68b3108ffa8 100644
--- a/activerecord/test/config.example.yml
+++ b/activerecord/test/config.example.yml
@@ -108,11 +108,29 @@ connections:
postgresql:
arunit:
min_messages: warning
+ <% if ENV['PGHOST'] %>
+ host: <%= ENV['PGHOST'] %>
+ <% end %>
+ <% if ENV['PGPORT'] %>
+ port: <%= ENV['PGPORT'] %>
+ <% end %>
arunit_without_prepared_statements:
min_messages: warning
prepared_statements: false
+ <% if ENV['PGHOST'] %>
+ host: <%= ENV['PGHOST'] %>
+ <% end %>
+ <% if ENV['PGPORT'] %>
+ port: <%= ENV['PGPORT'] %>
+ <% end %>
arunit2:
min_messages: warning
+ <% if ENV['PGHOST'] %>
+ host: <%= ENV['PGHOST'] %>
+ <% end %>
+ <% if ENV['PGPORT'] %>
+ port: <%= ENV['PGPORT'] %>
+ <% end %>
sqlite3:
arunit:
diff --git a/activerecord/test/models/book_encrypted.rb b/activerecord/test/models/book_encrypted.rb
index ab7d4fa488828..0c4971379551e 100644
--- a/activerecord/test/models/book_encrypted.rb
+++ b/activerecord/test/models/book_encrypted.rb
@@ -10,6 +10,13 @@ class EncryptedBook < ActiveRecord::Base
encrypts :name, deterministic: true
end
+class EncryptedBookWithUniquenessValidation < ActiveRecord::Base
+ self.table_name = "encrypted_books"
+
+ validates :name, uniqueness: true
+ encrypts :name, deterministic: true
+end
+
class EncryptedBookWithDowncaseName < ActiveRecord::Base
self.table_name = "encrypted_books"
@@ -22,3 +29,17 @@ class EncryptedBookThatIgnoresCase < ActiveRecord::Base
encrypts :name, deterministic: true, ignore_case: true
end
+
+class EncryptedBookWithUnencryptedDataOptedOut < ActiveRecord::Base
+ self.table_name = "encrypted_books"
+
+ validates :name, uniqueness: true
+ encrypts :name, deterministic: true, support_unencrypted_data: false
+end
+
+class EncryptedBookWithUnencryptedDataOptedIn < ActiveRecord::Base
+ self.table_name = "encrypted_books"
+
+ validates :name, uniqueness: true
+ encrypts :name, deterministic: true, support_unencrypted_data: true
+end
diff --git a/activerecord/test/models/cpk/book.rb b/activerecord/test/models/cpk/book.rb
index 98c2040f71463..11e322aff6661 100644
--- a/activerecord/test/models/cpk/book.rb
+++ b/activerecord/test/models/cpk/book.rb
@@ -2,18 +2,27 @@
module Cpk
class Book < ActiveRecord::Base
+ attr_accessor :fail_destroy
+
self.table_name = :cpk_books
belongs_to :order, autosave: true, query_constraints: [:shop_id, :order_id]
belongs_to :author, class_name: "Cpk::Author"
has_many :chapters, query_constraints: [:author_id, :book_id]
+
+ before_destroy :prevent_destroy_if_set
+
+ private
+ def prevent_destroy_if_set
+ throw(:abort) if fail_destroy
+ end
end
class BestSeller < Book
end
class BrokenBook < Book
- belongs_to :order
+ belongs_to :order, class_name: "Cpk::OrderWithSpecialPrimaryKey"
end
class BrokenBookWithNonCpkOrder < Book
diff --git a/activerecord/test/models/cpk/order.rb b/activerecord/test/models/cpk/order.rb
index 9c9f1057dda7b..0e5085831be51 100644
--- a/activerecord/test/models/cpk/order.rb
+++ b/activerecord/test/models/cpk/order.rb
@@ -9,17 +9,30 @@ class Order < ActiveRecord::Base
alias_attribute :id_value, :id
- has_many :order_agreements, primary_key: :id
+ has_many :order_agreements
has_many :books, query_constraints: [:shop_id, :order_id]
has_one :book, query_constraints: [:shop_id, :order_id]
+ has_many :order_tags
+ has_many :tags, through: :order_tags
end
class BrokenOrder < Order
+ self.primary_key = [:shop_id, :status]
+
has_many :books
has_one :book
end
+ class OrderWithSpecialPrimaryKey < Order
+ self.primary_key = [:shop_id, :status]
+
+ has_many :books, query_constraints: [:shop_id, :status]
+ has_one :book, query_constraints: [:shop_id, :status]
+ end
+
class BrokenOrderWithNonCpkBooks < Order
+ self.primary_key = [:shop_id, :status]
+
has_many :books, class_name: "Cpk::NonCpkBook"
has_one :book, class_name: "Cpk::NonCpkBook"
end
@@ -29,7 +42,7 @@ class NonCpkOrder < Order
end
class OrderWithPrimaryKeyAssociatedBook < Order
- has_one :book, primary_key: :id, foreign_key: :order_id
+ has_one :book, foreign_key: :order_id
end
class OrderWithNullifiedBook < Order
diff --git a/activerecord/test/models/cpk/order_agreement.rb b/activerecord/test/models/cpk/order_agreement.rb
index e96594cf5c830..4e85d052d7aa5 100644
--- a/activerecord/test/models/cpk/order_agreement.rb
+++ b/activerecord/test/models/cpk/order_agreement.rb
@@ -5,6 +5,6 @@ module Cpk
class OrderAgreement < ActiveRecord::Base
self.table_name = :cpk_order_agreements
- belongs_to :order, primary_key: :id # foreign key is derived as `order_id`
+ belongs_to :order
end
end
diff --git a/activerecord/test/models/cpk/order_tag.rb b/activerecord/test/models/cpk/order_tag.rb
index 57f894aa35a45..d4a2ee111616d 100644
--- a/activerecord/test/models/cpk/order_tag.rb
+++ b/activerecord/test/models/cpk/order_tag.rb
@@ -5,6 +5,6 @@ class OrderTag < ActiveRecord::Base
self.table_name = :cpk_order_tags
belongs_to :tag
- belongs_to :order, primary_key: :id
+ belongs_to :order
end
end
diff --git a/activerecord/test/models/sharded/comment.rb b/activerecord/test/models/sharded/comment.rb
index cd8a339efc20c..1f2d7e6978c7f 100644
--- a/activerecord/test/models/sharded/comment.rb
+++ b/activerecord/test/models/sharded/comment.rb
@@ -6,7 +6,7 @@ class Comment < ActiveRecord::Base
query_constraints :blog_id, :id
belongs_to :blog_post
- belongs_to :blog_post_by_id, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id
+ belongs_to :blog_post_by_id, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id, primary_key: :id
belongs_to :blog
end
end
diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb
index 8a396d8391712..a1d071609cceb 100644
--- a/activerecord/test/schema/postgresql_specific_schema.rb
+++ b/activerecord/test/schema/postgresql_specific_schema.rb
@@ -25,8 +25,8 @@
end
create_table :defaults, force: true do |t|
- t.virtual :virtual_stored_number, type: :integer, as: "rand_number * 10", stored: true
- t.integer :rand_number, default: -> { "random() * 100" }
+ t.virtual :virtual_stored_number, type: :integer, as: "random_number * 10", stored: true
+ t.integer :random_number, default: -> { "random() * 100" }
t.string :ruby_on_rails, default: -> { "concat('Ruby ', 'on ', 'Rails')" }
t.date :modified_date, default: -> { "CURRENT_DATE" }
t.date :modified_date_function, default: -> { "now()" }
@@ -153,14 +153,14 @@
t.exclusion_constraint "daterange(transaction_from, transaction_to) WITH &&", using: :gist, where: "transaction_from IS NOT NULL AND transaction_to IS NOT NULL", name: "test_exclusion_constraints_transaction_overlap", deferrable: :deferred
end
- create_table :test_unique_keys, force: true do |t|
+ create_table :test_unique_constraints, force: true do |t|
t.integer :position_1
t.integer :position_2
t.integer :position_3
- t.unique_key :position_1, name: "test_unique_keys_position_deferrable_false"
- t.unique_key :position_2, name: "test_unique_keys_position_deferrable_immediate", deferrable: :immediate
- t.unique_key :position_3, name: "test_unique_keys_position_deferrable_deferred", deferrable: :deferred
+ t.unique_constraint :position_1, name: "test_unique_constraints_position_deferrable_false"
+ t.unique_constraint :position_2, name: "test_unique_constraints_position_deferrable_immediate", deferrable: :immediate
+ t.unique_constraint :position_3, name: "test_unique_constraints_position_deferrable_deferred", deferrable: :deferred
end
if supports_partitioned_indexes?
diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb
index 33f229dc4351f..b1e2d7dea37ff 100644
--- a/activerecord/test/schema/schema.rb
+++ b/activerecord/test/schema/schema.rb
@@ -275,9 +275,11 @@
t.string :status
end
- create_table :cpk_order_tags, force: true do |t|
+ create_table :cpk_order_tags, primary_key: [:order_id, :tag_id], force: true do |t|
t.integer :order_id
t.integer :tag_id
+ t.string :attached_by
+ t.string :attached_reason
end
create_table :cpk_tags, force: true do |t|
@@ -399,7 +401,12 @@
t.index [:firm_id, :type], name: "company_partial_index", where: "(rating > 10)"
t.index [:firm_id], name: "company_nulls_not_distinct", nulls_not_distinct: true
t.index :name, name: "company_name_index", using: :btree
- t.index "(CASE WHEN rating > 0 THEN lower(name) END) DESC", name: "company_expression_index" if supports_expression_index?
+ if supports_expression_index?
+ t.index "(CASE WHEN rating > 0 THEN lower(name) END) DESC", name: "company_expression_index"
+ if ActiveRecord::TestCase.current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
+ t.index "(CONCAT_WS(`firm_name`, `name`, _utf8mb4' '))", name: "full_name_index"
+ end
+ end
end
create_table :content, force: true do |t|
diff --git a/activerecord/test/schema/sqlite_specific_schema.rb b/activerecord/test/schema/sqlite_specific_schema.rb
index e0f3fbed2830e..7781efd3afb59 100644
--- a/activerecord/test/schema/sqlite_specific_schema.rb
+++ b/activerecord/test/schema/sqlite_specific_schema.rb
@@ -2,15 +2,21 @@
ActiveRecord::Schema.define do
create_table :defaults, force: true do |t|
- t.date :modified_date, default: -> { "CURRENT_DATE" }
- t.date :fixed_date, default: "2004-01-01"
- t.datetime :fixed_time, default: "2004-01-01 00:00:00"
- t.datetime :modified_time, default: -> { "CURRENT_TIMESTAMP" }
- t.datetime :modified_time_without_precision, precision: nil, default: -> { "CURRENT_TIMESTAMP" }
- t.datetime :modified_time_with_precision_0, precision: 0, default: -> { "CURRENT_TIMESTAMP" }
- t.integer :random_number, default: -> { "random()" }
- t.column :char1, "char(1)", default: "Y"
- t.string :char2, limit: 50, default: "a varchar field"
- t.text :char3, limit: 50, default: "a text field"
- end
+ t.integer :random_number, default: -> { "ABS(RANDOM())" }
+ t.string :ruby_on_rails, default: -> { "('Ruby ' || 'on ' || 'Rails')" }
+ t.date :modified_date, default: -> { "CURRENT_DATE" }
+ t.date :modified_date_function, default: -> { "DATE('now')" }
+ t.date :fixed_date, default: "2004-01-01"
+ t.datetime :modified_time, default: -> { "CURRENT_TIMESTAMP" }
+ t.datetime :modified_time_without_precision, precision: nil, default: -> { "CURRENT_TIMESTAMP" }
+ t.datetime :modified_time_with_precision_0, precision: 0, default: -> { "CURRENT_TIMESTAMP" }
+ t.datetime :modified_time_function, default: -> { "DATETIME('now')" }
+ t.datetime :fixed_time, default: "2004-01-01 00:00:00.000000-00"
+ t.column :char1, "char(1)", default: "Y"
+ t.string :char2, limit: 50, default: "a varchar field"
+ t.text :char3, default: "a text field"
+ t.text :multiline_default, default: "--- []
+
+"
+ end
end
diff --git a/activerecord/test/storage/test.sqlite3 b/activerecord/test/storage/test.sqlite3
deleted file mode 100644
index e69de29bb2d1d..0000000000000
diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md
index 64d1d7577d927..7e8be1f4c895d 100644
--- a/activestorage/CHANGELOG.md
+++ b/activestorage/CHANGELOG.md
@@ -1,219 +1,2 @@
-* Disables the session in `ActiveStorage::Blobs::ProxyController`
- and `ActiveStorage::Representations::ProxyController`
- in order to allow caching by default in some CDNs as CloudFlare
- Fixes #44136
-
- *Bruno Prieto*
-
-* Add `tags` to `ActiveStorage::Analyzer::AudioAnalyzer` output
-
- *Keaton Roux*
-
-* Add an option to preprocess variants
-
- ActiveStorage variants are processed on the fly when they are needed but
- sometimes we're sure that they are accessed and want to processed them
- upfront.
-
- `preprocessed` option is added when declaring variants.
-
- ```
- class User < ApplicationRecord
- has_one_attached :avatar do |attachable|
- attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
- end
- end
- ```
-
- *Shouichi Kamiya*
-
-* Fix variants not included when eager loading multiple records containing a single attachment
-
- When using the `with_attached_#{name}` scope for a `has_one_attached` relation,
- attachment variants were not eagerly loaded.
-
- *Russell Porter*
-
-* Allow an ActiveStorage attachment to be removed via a form post
-
- Attachments can already be removed by updating the attachment to be nil such as:
- ```ruby
- User.find(params[:id]).update!(avatar: nil)
- ```
-
- However, a form cannot post a nil param, it can only post an empty string. But, posting an
- empty string would result in an `ActiveSupport::MessageVerifier::InvalidSignature: mismatched digest`
- error being raised, because it's being treated as a signed blob id.
-
- Now, nil and an empty string are treated as a delete, which allows attachments to be removed via:
- ```ruby
- User.find(params[:id]).update!(params.require(:user).permit(:avatar))
-
- ```
-
- *Nate Matykiewicz*
-
-* Remove mini_mime usage in favour of marcel.
-
- We have two libraries that are have similar usage. This change removes
- dependency on mini_mime and makes use of similar methods from marcel.
-
- *Vipul A M*
-
-* Allow destroying active storage variants
-
- ```ruby
- User.first.avatar.variant(resize_to_limit: [100, 100]).destroy
- ```
-
- *Shouichi Kamiya*, *Yuichiro NAKAGAWA*, *Ryohei UEDA*
-
-* Add `sample_rate` to `ActiveStorage::Analyzer::AudioAnalyzer` output
-
- *Matija Čupić*
-
-* Remove deprecated `purge` and `purge_later` methods from the attachments association.
-
- *Rafael Mendonça França*
-
-* Remove deprecated behavior when assigning to a collection of attachments.
-
- Instead of appending to the collection, the collection is now replaced.
-
- *Rafael Mendonça França*
-
-* Remove deprecated `ActiveStorage::Current#host` and `ActiveStorage::Current#host=` methods.
-
- *Rafael Mendonça França*
-
-* Remove deprecated invalid default content types in Active Storage configurations.
-
- *Rafael Mendonça França*
-
-* Add missing preview event to `ActiveStorage::LogSubscriber`
-
- A `preview` event is being instrumented in `ActiveStorage::Previewer`.
- However it was not added inside ActiveStorage's LogSubscriber class.
-
- This will allow to have logs for when a preview happens
- in the same fashion as all other ActiveStorage events such as
- `upload` and `download` inside `Rails.logger`.
-
- *Chedli Bourguiba*
-
-* Fix retrieving rotation value from FFmpeg on version 5.0+.
-
- In FFmpeg version 5.0+ the rotation value has been removed from tags.
- Instead the value can be found in side_data_list. Along with
- this update it's possible to have values of -90, -270 to denote the video
- has been rotated.
-
- *Haroon Ahmed*
-
-* Touch all corresponding model records after ActiveStorage::Blob is analyzed
-
- This fixes a race condition where a record can be requested and have a cache entry built, before
- the initial `analyze_later` completes, which will not be invalidated until something else
- updates the record. This also invalidates cache entries when a blob is re-analyzed, which
- is helpful if a bug is fixed in an analyzer or a new analyzer is added.
-
- *Nate Matykiewicz*
-
-* Add ability to use pre-defined variants when calling `preview` or
- `representation` on an attachment.
-
- ```ruby
- class User < ActiveRecord::Base
- has_one_attached :file do |attachable|
- attachable.variant :thumb, resize_to_limit: [100, 100]
- end
- end
-
- <%= image_tag user.file.representation(:thumb) %>
- ```
-
- *Richard Böhme*
-
-* Method `attach` always returns the attachments except when the record
- is persisted, unchanged, and saving it fails, in which case it returns `nil`.
-
- *Santiago Bartesaghi*
-
-* Fixes multiple `attach` calls within transaction not uploading files correctly.
-
- In the following example, the code failed to upload all but the last file to the configured service.
- ```ruby
- ActiveRecord::Base.transaction do
- user.attachments.attach({
- content_type: "text/plain",
- filename: "dummy.txt",
- io: ::StringIO.new("dummy"),
- })
- user.attachments.attach({
- content_type: "text/plain",
- filename: "dummy2.txt",
- io: ::StringIO.new("dummy2"),
- })
- end
-
- assert_equal 2, user.attachments.count
- assert user.attachments.first.service.exist?(user.attachments.first.key) # Fails
- ```
-
- This was addressed by keeping track of the subchanges pending upload, and uploading them
- once the transaction is committed.
-
- Fixes #41661
-
- *Santiago Bartesaghi*, *Bruno Vezoli*, *Juan Roig*, *Abhay Nikam*
-
-* Raise an exception if `config.active_storage.service` is not set.
-
- If Active Storage is configured and `config.active_storage.service` is not
- set in the respective environment's configuration file, then an exception
- is raised with a meaningful message when attempting to use Active Storage.
-
- *Ghouse Mohamed*
-
-* Fixes proxy downloads of files over 5mb
-
- Previously, trying to view and/or download files larger than 5mb stored in
- services like S3 via proxy mode could return corrupted files at around
- 5.2mb or cause random halts in the download. Now,
- `ActiveStorage::Blobs::ProxyController` correctly handles streaming these
- larger files from the service to the client without any issues.
-
- Fixes #44679
-
- *Felipe Raul*
-
-* Saving attachment(s) to a record returns the blob/blobs object
-
- Previously, saving attachments did not return the blob/blobs that
- were attached. Now, saving attachments to a record with `#attach`
- method returns the blob or array of blobs that were attached to
- the record. If it fails to save the attachment(s), then it returns
- `false`.
-
- *Ghouse Mohamed*
-
-* Don't stream responses in redirect mode
-
- Previously, both redirect mode and proxy mode streamed their
- responses which caused a new thread to be created, and could end
- up leaking connections in the connection pool. But since redirect
- mode doesn't actually send any data, it doesn't need to be
- streamed.
-
- *Luke Lau*
-
-* Safe for direct upload on Libraries or Frameworks
-
- Enable the use of custom headers during direct uploads, which allows for
- the inclusion of Authorization bearer tokens or other forms of authorization
- tokens through headers.
-
- *Radamés Roriz*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activestorage/CHANGELOG.md) for previous changes.
diff --git a/activestorage/app/jobs/active_storage/analyze_job.rb b/activestorage/app/jobs/active_storage/analyze_job.rb
index 890781dd7e89e..44521f7931c82 100644
--- a/activestorage/app/jobs/active_storage/analyze_job.rb
+++ b/activestorage/app/jobs/active_storage/analyze_job.rb
@@ -5,7 +5,7 @@ class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:analysis] }
discard_on ActiveRecord::RecordNotFound
- retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
def perform(blob)
blob.analyze
diff --git a/activestorage/app/jobs/active_storage/mirror_job.rb b/activestorage/app/jobs/active_storage/mirror_job.rb
index e70629d6ecedd..525c43b07da8c 100644
--- a/activestorage/app/jobs/active_storage/mirror_job.rb
+++ b/activestorage/app/jobs/active_storage/mirror_job.rb
@@ -7,7 +7,7 @@ class ActiveStorage::MirrorJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:mirror] }
discard_on ActiveStorage::FileNotFoundError
- retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
def perform(key, checksum:)
ActiveStorage::Blob.service.try(:mirror, key, checksum: checksum)
diff --git a/activestorage/app/jobs/active_storage/purge_job.rb b/activestorage/app/jobs/active_storage/purge_job.rb
index 5ceb222005cd8..34df12b151e87 100644
--- a/activestorage/app/jobs/active_storage/purge_job.rb
+++ b/activestorage/app/jobs/active_storage/purge_job.rb
@@ -5,7 +5,7 @@ class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:purge] }
discard_on ActiveRecord::RecordNotFound
- retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer
+ retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :polynomially_longer
def perform(blob)
blob.purge
diff --git a/activestorage/app/jobs/active_storage/transform_job.rb b/activestorage/app/jobs/active_storage/transform_job.rb
index d2c8bffe1ff67..f757e8da70eba 100644
--- a/activestorage/app/jobs/active_storage/transform_job.rb
+++ b/activestorage/app/jobs/active_storage/transform_job.rb
@@ -4,7 +4,7 @@ class ActiveStorage::TransformJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:transform] }
discard_on ActiveRecord::RecordNotFound
- retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
+ retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :polynomially_longer
def perform(blob, transformations)
blob.variant(transformations).processed
diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb
index ab1b839a82575..3184ef99908e9 100644
--- a/activestorage/app/models/active_storage/blob.rb
+++ b/activestorage/app/models/active_storage/blob.rb
@@ -158,7 +158,7 @@ def compose(blobs, filename:, content_type: nil, metadata: nil)
end
# Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
- def signed_id(purpose: :blob_id, expires_in: nil)
+ def signed_id(purpose: :blob_id, expires_in: nil, expires_at: nil)
super
end
diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb
index f75c9beaceb48..cb5816fe4c7c3 100644
--- a/activestorage/app/models/active_storage/variant.rb
+++ b/activestorage/app/models/active_storage/variant.rb
@@ -91,12 +91,6 @@ def filename
ActiveStorage::Filename.new "#{blob.filename.base}.#{variation.format.downcase}"
end
- alias_method :content_type_for_serving, :content_type
-
- def forced_disposition_for_serving # :nodoc:
- nil
- end
-
# Returns the receiving variant. Allows ActiveStorage::Variant and ActiveStorage::Preview instances to be used interchangeably.
def image
self
diff --git a/activestorage/app/models/active_storage/variant_with_record.rb b/activestorage/app/models/active_storage/variant_with_record.rb
index a4fa02d410ccb..91ac2268a2da9 100644
--- a/activestorage/app/models/active_storage/variant_with_record.rb
+++ b/activestorage/app/models/active_storage/variant_with_record.rb
@@ -7,6 +7,7 @@
class ActiveStorage::VariantWithRecord
attr_reader :blob, :variation
delegate :service, to: :blob
+ delegate :content_type, to: :variation
def initialize(blob, variation)
@blob, @variation = blob, ActiveStorage::Variation.wrap(variation)
@@ -21,6 +22,10 @@ def image
record&.image
end
+ def filename
+ ActiveStorage::Filename.new "#{blob.filename.base}.#{variation.format.downcase}"
+ end
+
# Destroys record and deletes file from service.
def destroy
record&.destroy
diff --git a/activestorage/config/routes.rb b/activestorage/config/routes.rb
index 9b36b001164dd..d890a92efc6b6 100644
--- a/activestorage/config/routes.rb
+++ b/activestorage/config/routes.rb
@@ -32,16 +32,17 @@
direct :rails_storage_proxy do |model, options|
expires_in = options.delete(:expires_in) { ActiveStorage.urls_expire_in }
+ expires_at = options.delete(:expires_at)
if model.respond_to?(:signed_id)
route_for(
:rails_service_blob_proxy,
- model.signed_id(expires_in: expires_in),
+ model.signed_id(expires_in: expires_in, expires_at: expires_at),
model.filename,
options
)
else
- signed_blob_id = model.blob.signed_id(expires_in: expires_in)
+ signed_blob_id = model.blob.signed_id(expires_in: expires_in, expires_at: expires_at)
variation_key = model.variation.key
filename = model.blob.filename
@@ -57,16 +58,17 @@
direct :rails_storage_redirect do |model, options|
expires_in = options.delete(:expires_in) { ActiveStorage.urls_expire_in }
+ expires_at = options.delete(:expires_at)
if model.respond_to?(:signed_id)
route_for(
:rails_service_blob,
- model.signed_id(expires_in: expires_in),
+ model.signed_id(expires_in: expires_in, expires_at: expires_at),
model.filename,
options
)
else
- signed_blob_id = model.blob.signed_id(expires_in: expires_in)
+ signed_blob_id = model.blob.signed_id(expires_in: expires_in, expires_at: expires_at)
variation_key = model.variation.key
filename = model.blob.filename
diff --git a/activestorage/lib/active_storage/attached/changes/create_one.rb b/activestorage/lib/active_storage/attached/changes/create_one.rb
index 57e1e9660c5ae..d05098a348fb7 100644
--- a/activestorage/lib/active_storage/attached/changes/create_one.rb
+++ b/activestorage/lib/active_storage/attached/changes/create_one.rb
@@ -30,6 +30,18 @@ def upload
)
when Hash
blob.upload_without_unfurling(attachable.fetch(:io))
+ when File
+ blob.upload_without_unfurling(attachable)
+ when Pathname
+ blob.upload_without_unfurling(attachable.open)
+ when ActiveStorage::Blob
+ when String
+ else
+ raise(
+ ArgumentError,
+ "Could not upload: expected attachable, " \
+ "got #{attachable.inspect}"
+ )
end
end
@@ -82,8 +94,26 @@ def find_or_build_blob
)
when String
ActiveStorage::Blob.find_signed!(attachable, record: record)
+ when File
+ ActiveStorage::Blob.build_after_unfurling(
+ io: attachable,
+ filename: File.basename(attachable),
+ record: record,
+ service_name: attachment_service_name
+ )
+ when Pathname
+ ActiveStorage::Blob.build_after_unfurling(
+ io: attachable.open,
+ filename: File.basename(attachable),
+ record: record,
+ service_name: attachment_service_name
+ )
else
- raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
+ raise(
+ ArgumentError,
+ "Could not find or build blob: expected attachable, " \
+ "got #{attachable.inspect}"
+ )
end
end
diff --git a/activestorage/lib/active_storage/gem_version.rb b/activestorage/lib/active_storage/gem_version.rb
index 405a02a8c05fa..1728ca1086254 100644
--- a/activestorage/lib/active_storage/gem_version.rb
+++ b/activestorage/lib/active_storage/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/activestorage/package.json b/activestorage/package.json
index 59d8ab544c4be..f29b199ec3a28 100644
--- a/activestorage/package.json
+++ b/activestorage/package.json
@@ -1,6 +1,6 @@
{
"name": "@rails/activestorage",
- "version": "7.1.0-alpha",
+ "version": "7.2.0-alpha",
"description": "Attach cloud and local files in Rails applications",
"module": "app/assets/javascripts/activestorage.esm.js",
"main": "app/assets/javascripts/activestorage.js",
diff --git a/activestorage/test/controllers/blobs/proxy_controller_test.rb b/activestorage/test/controllers/blobs/proxy_controller_test.rb
index c45105ffe4063..6e817c69f6e91 100644
--- a/activestorage/test/controllers/blobs/proxy_controller_test.rb
+++ b/activestorage/test/controllers/blobs/proxy_controller_test.rb
@@ -32,18 +32,30 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes
assert_match(/^attachment; /, response.headers["Content-Disposition"])
end
- test "signed ID within expiration date" do
+ test "signed ID within expiration duration" do
get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg"), expires_in: 1.minute)
assert_response :success
end
- test "Expired signed ID" do
+ test "Expired signed ID within expiration duration" do
url = rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg"), expires_in: 1.minute)
travel 2.minutes
get url
assert_response :not_found
end
+ test "signed ID within expiration time" do
+ get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg"), expires_at: 1.minute.from_now)
+ assert_response :success
+ end
+
+ test "Expired signed ID within expiration time" do
+ url = rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg"), expires_at: 1.minute.from_now)
+ travel 2.minutes
+ get url
+ assert_response :not_found
+ end
+
test "single Byte Range" do
get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=5-9" }
assert_response :partial_content
diff --git a/activestorage/test/controllers/blobs/redirect_controller_test.rb b/activestorage/test/controllers/blobs/redirect_controller_test.rb
index 339da812b8f8f..e4543b9eb6bc3 100644
--- a/activestorage/test/controllers/blobs/redirect_controller_test.rb
+++ b/activestorage/test/controllers/blobs/redirect_controller_test.rb
@@ -19,17 +19,29 @@ class ActiveStorage::Blobs::RedirectControllerTest < ActionDispatch::Integration
assert_equal "max-age=300, private", response.headers["Cache-Control"]
end
- test "signed ID within expiration date" do
+ test "signed ID within expiration duration" do
get rails_storage_redirect_url(@blob, expires_in: 1.minute)
assert_redirected_to(/racecar\.jpg/)
end
- test "Expired signed ID" do
+ test "Expired signed ID within expiration duration" do
url = rails_storage_redirect_url(@blob, expires_in: 1.minute)
travel 2.minutes
get url
assert_response :not_found
end
+
+ test "signed ID within expiration time" do
+ get rails_storage_redirect_url(@blob, expires_at: 1.minute.from_now)
+ assert_redirected_to(/racecar\.jpg/)
+ end
+
+ test "Expired signed ID within expiration time" do
+ url = rails_storage_redirect_url(@blob, expires_at: 1.minute.from_now)
+ travel 2.minutes
+ get url
+ assert_response :not_found
+ end
end
class ActiveStorage::Blobs::ExpiringRedirectControllerTest < ActionDispatch::IntegrationTest
diff --git a/activestorage/test/fixture_set_test.rb b/activestorage/test/fixture_set_test.rb
index 7525edfac9503..fba2404d9d46e 100644
--- a/activestorage/test/fixture_set_test.rb
+++ b/activestorage/test/fixture_set_test.rb
@@ -11,9 +11,9 @@ def test_active_storage_blob
avatar = user.avatar
- assert_equal avatar.blob.content_type, "image/jpeg+override"
- assert_equal avatar.blob.filename.to_s, "racecar.jpg"
- assert_equal avatar.blob.service.name, :local
+ assert_equal "image/jpeg+override", avatar.blob.content_type
+ assert_equal "racecar.jpg", avatar.blob.filename.to_s
+ assert_equal :local, avatar.blob.service.name
avatar.blob.open { |file| assert FileUtils.identical?(file, file_fixture("racecar.jpg")) }
end
diff --git a/activestorage/test/models/attached/many_test.rb b/activestorage/test/models/attached/many_test.rb
index 989d96bcbdaed..a31c52d34a4be 100644
--- a/activestorage/test/models/attached/many_test.rb
+++ b/activestorage/test/models/attached/many_test.rb
@@ -20,7 +20,7 @@ class ActiveStorage::ManyAttachedTest < ActiveSupport::TestCase
assert_equal "town.jpg", @user.highlights.second.filename.to_s
assert_not_empty @user.highlights_attachments
- assert_equal @user.highlights_blobs.count, 2
+ assert_equal 2, @user.highlights_blobs.count
end
test "attaching existing blobs from signed IDs to an existing record" do
diff --git a/activestorage/test/models/attached/one_test.rb b/activestorage/test/models/attached/one_test.rb
index ffddd0301ffa8..3a1be9e295b4d 100644
--- a/activestorage/test/models/attached/one_test.rb
+++ b/activestorage/test/models/attached/one_test.rb
@@ -16,6 +16,32 @@ class ActiveStorage::OneAttachedTest < ActiveSupport::TestCase
ActiveStorage::Blob.all.each(&:delete)
end
+ test "creating a record with a File as attachable attribute" do
+ @user = User.create!(name: "Dorian", avatar: file_fixture("image.gif").open)
+
+ assert_equal "image.gif", @user.avatar.filename.to_s
+ assert_not_nil @user.avatar_attachment
+ assert_not_nil @user.avatar_blob
+ end
+
+ test "uploads the file when passing a File as attachable attribute" do
+ @user = User.create!(name: "Dorian", avatar: file_fixture("image.gif").open)
+ assert_nothing_raised { @user.avatar.download }
+ end
+
+ test "creating a record with a Pathname as attachable attribute" do
+ @user = User.create!(name: "Dorian", avatar: file_fixture("image.gif"))
+
+ assert_equal "image.gif", @user.avatar.filename.to_s
+ assert_not_nil @user.avatar_attachment
+ assert_not_nil @user.avatar_blob
+ end
+
+ test "uploads the file when passing a Pathname as attachable attribute" do
+ @user = User.create!(name: "Dorian", avatar: file_fixture("image.gif"))
+ assert_nothing_raised { @user.avatar.download }
+ end
+
test "attaching an existing blob to an existing record" do
@user.avatar.attach create_blob(filename: "funky.jpg")
assert_equal "funky.jpg", @user.avatar.filename.to_s
diff --git a/activestorage/test/models/attachment_test.rb b/activestorage/test/models/attachment_test.rb
index a4f828ae206db..2e98286b81084 100644
--- a/activestorage/test/models/attachment_test.rb
+++ b/activestorage/test/models/attachment_test.rb
@@ -108,7 +108,7 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase
assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id)
end
- test "fail to find blob within expiration date" do
+ test "fail to find blob within expiration duration" do
blob = create_blob
@user.avatar.attach(blob)
@@ -117,6 +117,23 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase
assert_nil ActiveStorage::Blob.find_signed(signed_id)
end
+ test "getting a signed blob ID from an attachment with a expires_at" do
+ blob = create_blob
+ @user.avatar.attach(blob)
+
+ signed_id = @user.avatar.signed_id(expires_at: 1.minute.from_now)
+ assert_equal blob, ActiveStorage::Blob.find_signed!(signed_id)
+ end
+
+ test "fail to find blob within expiration time" do
+ blob = create_blob
+ @user.avatar.attach(blob)
+
+ signed_id = @user.avatar.signed_id(expires_at: 1.minute.from_now)
+ travel 2.minutes
+ assert_nil ActiveStorage::Blob.find_signed(signed_id)
+ end
+
test "signed blob ID backwards compatibility" do
blob = create_blob
@user.avatar.attach(blob)
diff --git a/activestorage/test/models/variant_with_record_test.rb b/activestorage/test/models/variant_with_record_test.rb
index dccf2fdec1328..4b70a69edf385 100644
--- a/activestorage/test/models/variant_with_record_test.rb
+++ b/activestorage/test/models/variant_with_record_test.rb
@@ -23,6 +23,8 @@ class ActiveStorage::VariantWithRecordTest < ActiveSupport::TestCase
end
assert_match(/racecar\.jpg/, variant.url)
+ assert_equal "racecar.jpg", variant.filename.to_s
+ assert_equal "image/jpeg", variant.content_type
image = read_image(variant.image)
assert_equal 100, image.width
diff --git a/activestorage/test/service/gcs_service_test.rb b/activestorage/test/service/gcs_service_test.rb
index b49074101e2b5..93a2bb69a1dd2 100644
--- a/activestorage/test/service/gcs_service_test.rb
+++ b/activestorage/test/service/gcs_service_test.rb
@@ -70,7 +70,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase
request = Net::HTTP::Put.new uri.request_uri
request.body = data
headers = service.headers_for_direct_upload(key, checksum: checksum, filename: ActiveStorage::Filename.new("test.txt"), disposition: :attachment)
- assert_equal(headers["Cache-Control"], "public, max-age=1800")
+ assert_equal("public, max-age=1800", headers["Cache-Control"])
headers.each do |k, v|
request.add_field k, v
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md
index 2144c828d2502..4dcf958b9d89d 100644
--- a/activesupport/CHANGELOG.md
+++ b/activesupport/CHANGELOG.md
@@ -1,1006 +1,2 @@
-* Add `drb`, `mutex_m` and `base64` that are bundled gem candidates for Ruby 3.4
- *Yasuo Honda*
-
-* When using cache format version >= 7.1 or a custom serializer, expired and
- version-mismatched cache entries can now be detected without deserializing
- their values.
-
- *Jonathan Hefner*
-
-* Make all cache stores return a boolean for `#delete`
-
- Previously the `RedisCacheStore#delete` would return `1` if the entry
- exists and `0` otherwise. Now it returns true if the entry exists and false
- otherwise, just like the other stores.
-
- The `FileStore` would return `nil` if the entry doesn't exists and returns
- `false` now as well.
-
- *Petrik de Heus*
-
-* Active Support cache stores now support replacing the default compressor via
- a `:compressor` option. The specified compressor must respond to `deflate`
- and `inflate`. For example:
-
- ```ruby
- module MyCompressor
- def self.deflate(string)
- # compression logic...
- end
-
- def self.inflate(compressed)
- # decompression logic...
- end
- end
-
- config.cache_store = :redis_cache_store, { compressor: MyCompressor }
- ```
-
- *Jonathan Hefner*
-
-* Active Support cache stores now support a `:serializer` option. Similar to
- the `:coder` option, serializers must respond to `dump` and `load`. However,
- serializers are only responsible for serializing a cached value, whereas
- coders are responsible for serializing the entire `ActiveSupport::Cache::Entry`
- instance. Additionally, the output from serializers can be automatically
- compressed, whereas coders are responsible for their own compression.
-
- Specifying a serializer instead of a coder also enables performance
- optimizations, including the bare string optimization introduced by cache
- format version 7.1.
-
- The `:serializer` and `:coder` options are mutually exclusive. Specifying
- both will raise an `ArgumentError`.
-
- *Jonathan Hefner*
-
-* Fix `ActiveSupport::Inflector.humanize(nil)` raising ``NoMethodError: undefined method `end_with?' for nil:NilClass``.
-
- *James Robinson*
-
-* Don't show secrets for `ActiveSupport::KeyGenerator#inspect`.
-
- Before:
-
- ```ruby
- ActiveSupport::KeyGenerator.new(secret).inspect
- "#"
- ```
-
- After:
-
- ```ruby
- ActiveSupport::KeyGenerator::Aes256Gcm(secret).inspect
- "#"
- ```
-
- *Petrik de Heus*
-
-* Improve error message when EventedFileUpdateChecker is used without a
- compatible version of the Listen gem
-
- *Hartley McGuire*
-
-* Add `:report` behavior for Deprecation
-
- Setting `config.active_support.deprecation = :report` uses the error
- reporter to report deprecation warnings to `ActiveSupport::ErrorReporter`.
-
- Deprecations are reported as handled errors, with a severity of `:warning`.
-
- Useful to report deprecations happening in production to your bug tracker.
-
- *Étienne Barrié*
-
-* Rename `Range#overlaps?` to `#overlap?` and add alias for backwards compatibility
-
- *Christian Schmidt*
-
-* Fix `EncryptedConfiguration` returning incorrect values for some `Hash`
- methods
-
- *Hartley McGuire*
-
-* Don't show secrets for `MessageEncryptor#inspect`.
-
- Before:
-
- ```ruby
- ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm").inspect
- "#"
- ```
-
- After:
-
- ```ruby
- ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm").inspect
- "#"
- ```
-
- *Petrik de Heus*
-
-* Don't show contents for `EncryptedConfiguration#inspect`.
-
- Before:
- ```ruby
- Rails.application.credentials.inspect
- "#\"something secret\"} ... @key_file_contents=\"915e4ea054e011022398dc242\" ...>"
- ```
-
- After:
- ```ruby
- Rails.application.credentials.inspect
- "#"
- ```
-
- *Petrik de Heus*
-
-* `ERB::Util.html_escape_once` always returns an `html_safe` string.
-
- This method previously maintained the `html_safe?` property of a string on the return
- value. Because this string has been escaped, however, not marking it as `html_safe` causes
- entities to be double-escaped.
-
- As an example, take this view snippet:
-
- ```html
-
<%= html_escape_once("this & that & the other") %>
- ```
-
- Before this change, that would be double-escaped and render as:
-
- ```html
-
this & that & the other
- ```
-
- After this change, it renders correctly as:
-
- ```html
-
this & that & the other
- ```
-
- Fixes #48256
-
- *Mike Dalessio*
-
-* Deprecate `SafeBuffer#clone_empty`.
-
- This method has not been used internally since Rails 4.2.0.
-
- *Mike Dalessio*
-
-* `MessageEncryptor`, `MessageVerifier`, and `config.active_support.message_serializer`
- now accept `:message_pack` and `:message_pack_allow_marshal` as serializers.
- These serializers require the [`msgpack` gem](https://rubygems.org/gems/msgpack)
- (>= 1.7.0).
-
- The Message Pack format can provide improved performance and smaller payload
- sizes. It also supports round-tripping some Ruby types that are not supported
- by JSON. For example:
-
- ```ruby
- verifier = ActiveSupport::MessageVerifier.new("secret")
- data = [{ a: 1 }, { b: 2 }.with_indifferent_access, 1.to_d, Time.at(0, 123)]
- message = verifier.generate(data)
-
- # BEFORE with config.active_support.message_serializer = :json
- verifier.verified(message)
- # => [{"a"=>1}, {"b"=>2}, "1.0", "1969-12-31T18:00:00.000-06:00"]
- verifier.verified(message).map(&:class)
- # => [Hash, Hash, String, String]
-
- # AFTER with config.active_support.message_serializer = :message_pack
- verifier.verified(message)
- # => [{:a=>1}, {"b"=>2}, 0.1e1, 1969-12-31 18:00:00.000123 -0600]
- verifier.verified(message).map(&:class)
- # => [Hash, ActiveSupport::HashWithIndifferentAccess, BigDecimal, Time]
- ```
-
- The `:message_pack` serializer can fall back to deserializing with
- `ActiveSupport::JSON` when necessary, and the `:message_pack_allow_marshal`
- serializer can fall back to deserializing with `Marshal` as well as
- `ActiveSupport::JSON`. Additionally, the `:marshal`, `:json`, and
- `:json_allow_marshal` serializers can now fall back to deserializing with
- `ActiveSupport::MessagePack` when necessary. These behaviors ensure old
- messages can still be read so that migration is easier.
-
- *Jonathan Hefner*
-
-* A new `7.1` cache format is available which includes an optimization for
- bare string values such as view fragments.
-
- The `7.1` cache format is used by default for new apps, and existing apps
- can enable the format by setting `config.load_defaults 7.1` or by setting
- `config.active_support.cache_format_version = 7.1` in `config/application.rb`
- or a `config/environments/*.rb` file.
-
- Cache entries written using the `6.1` or `7.0` cache formats can be read
- when using the `7.1` format. To perform a rolling deploy of a Rails 7.1
- upgrade, wherein servers that have not yet been upgraded must be able to
- read caches from upgraded servers, leave the cache format unchanged on the
- first deploy, then enable the `7.1` cache format on a subsequent deploy.
-
- *Jonathan Hefner*
-
-* Active Support cache stores can now use a preconfigured serializer based on
- `ActiveSupport::MessagePack` via the `:serializer` option:
-
- ```ruby
- config.cache_store = :redis_cache_store, { serializer: :message_pack }
- ```
-
- The `:message_pack` serializer can reduce cache entry sizes and improve
- performance, but requires the [`msgpack` gem](https://rubygems.org/gems/msgpack)
- (>= 1.7.0).
-
- The `:message_pack` serializer can read cache entries written by the default
- serializer, and the default serializer can now read entries written by the
- `:message_pack` serializer. These behaviors make it easy to migrate between
- serializer without invalidating the entire cache.
-
- *Jonathan Hefner*
-
-* `Object#deep_dup` no longer duplicate named classes and modules.
-
- Before:
-
- ```ruby
- hash = { class: Object, module: Kernel }
- hash.deep_dup # => {:class=>#, :module=>#}
- ```
-
- After:
-
- ```ruby
- hash = { class: Object, module: Kernel }
- hash.deep_dup # => {:class=>Object, :module=>Kernel}
- ```
-
- *Jean Boussier*
-
-* Consistently raise an `ArgumentError` if the `ActiveSupport::Cache` key is blank.
-
- *Joshua Young*
-
-* Deprecate usage of the singleton `ActiveSupport::Deprecation`.
-
- All usage of `ActiveSupport::Deprecation` as a singleton is deprecated, the most common one being
- `ActiveSupport::Deprecation.warn`. Gem authors should now create their own deprecator (`ActiveSupport::Deprecation`
- object), and use it to emit deprecation warnings.
-
- Calling any of the following without specifying a deprecator argument is also deprecated:
- * Module.deprecate
- * deprecate_constant
- * DeprecatedObjectProxy
- * DeprecatedInstanceVariableProxy
- * DeprecatedConstantProxy
- * deprecation-related test assertions
-
- Use of `ActiveSupport::Deprecation.silence` and configuration methods like `behavior=`, `disallowed_behavior=`,
- `disallowed_warnings=` should now be aimed at the [application's deprecators](https://api.rubyonrails.org/classes/Rails/Application.html#method-i-deprecators).
-
- ```ruby
- Rails.application.deprecators.silence do
- # code that emits deprecation warnings
- end
- ```
-
- If your gem has a Railtie or Engine, it's encouraged to add your deprecator to the application's deprecators, that
- way the deprecation related configuration options will apply to it as well, e.g.
- `config.active_support.report_deprecations` set to `false` in the production environment will also disable your
- deprecator.
-
- ```ruby
- initializer "my_gem.deprecator" do |app|
- app.deprecators[:my_gem] = MyGem.deprecator
- end
- ```
-
- *Étienne Barrié*
-
-* Add `Object#with` to set and restore public attributes around a block
-
- ```ruby
- client.timeout # => 5
- client.with(timeout: 1) do
- client.timeout # => 1
- end
- client.timeout # => 5
- ```
-
- *Jean Boussier*
-
-* Remove deprecated support to generate incorrect RFC 4122 UUIDs when providing a namespace ID that is not one of the
- constants defined on `Digest::UUID`.
-
- *Rafael Mendonça França*
-
-* Deprecate `config.active_support.use_rfc4122_namespaced_uuids`.
-
- *Rafael Mendonça França*
-
-* Remove implicit conversion of objects into `String` by `ActiveSupport::SafeBuffer`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated `active_support/core_ext/range/include_time_with_zone` file.
-
- *Rafael Mendonça França*
-
-* Deprecate `config.active_support.remove_deprecated_time_with_zone_name`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated override of `ActiveSupport::TimeWithZone.name`.
-
- *Rafael Mendonça França*
-
-* Deprecate `config.active_support.disable_to_s_conversion`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated option to passing a format to `#to_s` in `Array`, `Range`, `Date`, `DateTime`, `Time`,
- `BigDecimal`, `Float` and, `Integer`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated `ActiveSupport::PerThreadRegistry`.
-
- *Rafael Mendonça França*
-
-* Remove deprecated override of `Enumerable#sum`.
-
- *Rafael Mendonça França*
-
-* Deprecated initializing a `ActiveSupport::Cache::MemCacheStore` with an instance of `Dalli::Client`.
-
- Deprecate the undocumented option of providing an already-initialized instance of `Dalli::Client` to `ActiveSupport::Cache::MemCacheStore`. Such clients could be configured with unrecognized options, which could lead to unexpected behavior. Instead, provide addresses as documented.
-
- *aledustet*
-
-* Stub `Time.new()` in `TimeHelpers#travel_to`
-
- ```ruby
- travel_to Time.new(2004, 11, 24) do
- # Inside the `travel_to` block `Time.new` is stubbed
- assert_equal Time.new.year, 2004
- end
- ```
-
- *fatkodima*
-
-* Raise `ActiveSupport::MessageEncryptor::InvalidMessage` from
- `ActiveSupport::MessageEncryptor#decrypt_and_verify` regardless of cipher.
- Previously, when a `MessageEncryptor` was using a non-AEAD cipher such as
- AES-256-CBC, a corrupt or tampered message would raise
- `ActiveSupport::MessageVerifier::InvalidSignature`. Now, all ciphers raise
- the same error:
-
- ```ruby
- encryptor = ActiveSupport::MessageEncryptor.new("x" * 32, cipher: "aes-256-gcm")
- message = encryptor.encrypt_and_sign("message")
- encryptor.decrypt_and_verify(message.next)
- # => raises ActiveSupport::MessageEncryptor::InvalidMessage
-
- encryptor = ActiveSupport::MessageEncryptor.new("x" * 32, cipher: "aes-256-cbc")
- message = encryptor.encrypt_and_sign("message")
- encryptor.decrypt_and_verify(message.next)
- # BEFORE:
- # => raises ActiveSupport::MessageVerifier::InvalidSignature
- # AFTER:
- # => raises ActiveSupport::MessageEncryptor::InvalidMessage
- ```
-
- *Jonathan Hefner*
-
-* Support `nil` original values when using `ActiveSupport::MessageVerifier#verify`.
- Previously, `MessageVerifier#verify` did not work with `nil` original
- values, though both `MessageVerifier#verified` and
- `MessageEncryptor#decrypt_and_verify` do:
-
- ```ruby
- encryptor = ActiveSupport::MessageEncryptor.new(secret)
- message = encryptor.encrypt_and_sign(nil)
-
- encryptor.decrypt_and_verify(message)
- # => nil
-
- verifier = ActiveSupport::MessageVerifier.new(secret)
- message = verifier.generate(nil)
-
- verifier.verified(message)
- # => nil
-
- verifier.verify(message)
- # BEFORE:
- # => raises ActiveSupport::MessageVerifier::InvalidSignature
- # AFTER:
- # => nil
- ```
-
- *Jonathan Hefner*
-
-* Maintain `html_safe?` on html_safe strings when sliced with `slice`, `slice!`, or `chr` method.
-
- Previously, `html_safe?` was only maintained when the html_safe strings were sliced
- with `[]` method. Now, `slice`, `slice!`, and `chr` methods will maintain `html_safe?` like `[]` method.
-
- ```ruby
- string = "
test
".html_safe
- string.slice(0, 1).html_safe? # => true
- string.slice!(0, 1).html_safe? # => true
- # maintain html_safe? after the slice!
- string.html_safe? # => true
- string.chr # => true
- ```
-
- *Michael Go*
-
-* Add `Object#in?` support for open ranges.
-
- ```ruby
- assert Date.today.in?(..Date.tomorrow)
- assert_not Date.today.in?(Date.tomorrow..)
- ```
-
- *Ignacio Galindo*
-
-* `config.i18n.raise_on_missing_translations = true` now raises on any missing translation.
-
- Previously it would only raise when called in a view or controller. Now it will raise
- anytime `I18n.t` is provided an unrecognised key.
-
- If you do not want this behaviour, you can customise the i18n exception handler. See the
- upgrading guide or i18n guide for more information.
-
- *Alex Ghiculescu*
-
-* `ActiveSupport::CurrentAttributes` now raises if a restricted attribute name is used.
-
- Attributes such as `set` and `reset` cannot be used as they clash with the
- `CurrentAttributes` public API.
-
- *Alex Ghiculescu*
-
-* `HashWithIndifferentAccess#transform_keys` now takes a Hash argument, just
- as Ruby's `Hash#transform_keys` does.
-
- *Akira Matsuda*
-
-* `delegate` now defines method with proper arity when delegating to a Class.
- With this change, it defines faster method (3.5x faster with no argument).
- However, in order to gain this benefit, the delegation target method has to
- be defined before declaring the delegation.
-
- ```ruby
- # This defines 3.5 times faster method than before
- class C
- def self.x() end
- delegate :x, to: :class
- end
-
- class C
- # This works but silently falls back to old behavior because
- # `delegate` cannot find the definition of `x`
- delegate :x, to: :class
- def self.x() end
- end
- ```
-
- *Akira Matsuda*
-
-* `assert_difference` message now includes what changed.
-
- This makes it easier to debug non-obvious failures.
-
- Before:
-
- ```
- "User.count" didn't change by 32.
- Expected: 1611
- Actual: 1579
- ```
-
- After:
-
- ```
- "User.count" didn't change by 32, but by 0.
- Expected: 1611
- Actual: 1579
- ```
-
- *Alex Ghiculescu*
-
-* Add ability to match exception messages to `assert_raises` assertion
-
- Instead of this
- ```ruby
- error = assert_raises(ArgumentError) do
- perform_service(param: 'exception')
- end
- assert_match(/incorrect param/i, error.message)
- ```
-
- you can now write this
- ```ruby
- assert_raises(ArgumentError, match: /incorrect param/i) do
- perform_service(param: 'exception')
- end
- ```
-
- *fatkodima*
-
-* Add `Rails.env.local?` shorthand for `Rails.env.development? || Rails.env.test?`.
-
- *DHH*
-
-* `ActiveSupport::Testing::TimeHelpers` now accepts named `with_usec` argument
- to `freeze_time`, `travel`, and `travel_to` methods. Passing true prevents
- truncating the destination time with `change(usec: 0)`.
-
- *KevSlashNull*, and *serprex*
-
-* `ActiveSupport::CurrentAttributes.resets` now accepts a method name
-
- The block API is still the recommended approach, but now both APIs are supported:
-
- ```ruby
- class Current < ActiveSupport::CurrentAttributes
- resets { Time.zone = nil }
- resets :clear_time_zone
- end
- ```
-
- *Alex Ghiculescu*
-
-* Ensure `ActiveSupport::Testing::Isolation::Forking` closes pipes
-
- Previously, `Forking.run_in_isolation` opened two ends of a pipe. The fork
- process closed the read end, wrote to it, and then terminated (which
- presumably closed the file descriptors on its end). The parent process
- closed the write end, read from it, and returned, never closing the read
- end.
-
- This resulted in an accumulation of open file descriptors, which could
- cause errors if the limit is reached.
-
- *Sam Bostock*
-
-* Fix `Time#change` and `Time#advance` for times around the end of Daylight
- Saving Time.
-
- Previously, when `Time#change` or `Time#advance` constructed a time inside
- the final stretch of Daylight Saving Time (DST), the non-DST offset would
- always be chosen for local times:
-
- ```ruby
- # DST ended just before 2021-11-07 2:00:00 AM in US/Eastern.
- ENV["TZ"] = "US/Eastern"
-
- time = Time.local(2021, 11, 07, 00, 59, 59) + 1
- # => 2021-11-07 01:00:00 -0400
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0500
- time.advance(seconds: 0)
- # => 2021-11-07 01:00:00 -0500
-
- time = Time.local(2021, 11, 06, 01, 00, 00)
- # => 2021-11-06 01:00:00 -0400
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0500
- time.advance(days: 1)
- # => 2021-11-07 01:00:00 -0500
- ```
-
- And the DST offset would always be chosen for times with a `TimeZone`
- object:
-
- ```ruby
- Time.zone = "US/Eastern"
-
- time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
- # => 2021-11-07 01:00:00 -0500
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0400
- time.advance(seconds: 0)
- # => 2021-11-07 01:00:00 -0400
-
- time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
- # => 2021-11-08 01:00:00 -0500
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0400
- time.advance(days: -1)
- # => 2021-11-07 01:00:00 -0400
- ```
-
- Now, `Time#change` and `Time#advance` will choose the offset that matches
- the original time's offset when possible:
-
- ```ruby
- ENV["TZ"] = "US/Eastern"
-
- time = Time.local(2021, 11, 07, 00, 59, 59) + 1
- # => 2021-11-07 01:00:00 -0400
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0400
- time.advance(seconds: 0)
- # => 2021-11-07 01:00:00 -0400
-
- time = Time.local(2021, 11, 06, 01, 00, 00)
- # => 2021-11-06 01:00:00 -0400
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0400
- time.advance(days: 1)
- # => 2021-11-07 01:00:00 -0400
-
- Time.zone = "US/Eastern"
-
- time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
- # => 2021-11-07 01:00:00 -0500
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0500
- time.advance(seconds: 0)
- # => 2021-11-07 01:00:00 -0500
-
- time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
- # => 2021-11-08 01:00:00 -0500
- time.change(day: 07)
- # => 2021-11-07 01:00:00 -0500
- time.advance(days: -1)
- # => 2021-11-07 01:00:00 -0500
- ```
-
- *Kevin Hall*, *Takayoshi Nishida*, and *Jonathan Hefner*
-
-* Fix MemoryStore to preserve entries TTL when incrementing or decrementing
-
- This is to be more consistent with how MemCachedStore and RedisCacheStore behaves.
-
- *Jean Boussier*
-
-* `Rails.error.handle` and `Rails.error.record` filter now by multiple error classes.
-
- ```ruby
- Rails.error.handle(IOError, ArgumentError) do
- 1 + '1' # raises TypeError
- end
- 1 + 1 # TypeErrors are not IOErrors or ArgumentError, so this will *not* be handled
- ```
-
- *Martin Spickermann*
-
-* `Class#subclasses` and `Class#descendants` now automatically filter reloaded classes.
-
- Previously they could return old implementations of reloadable classes that have been
- dereferenced but not yet garbage collected.
-
- They now automatically filter such classes like `DescendantTracker#subclasses` and
- `DescendantTracker#descendants`.
-
- *Jean Boussier*
-
-* `Rails.error.report` now marks errors as reported to avoid reporting them twice.
-
- In some cases, users might want to report errors explicitly with some extra context
- before letting it bubble up.
-
- This also allows to safely catch and report errors outside of the execution context.
-
- *Jean Boussier*
-
-* Add `assert_error_reported` and `assert_no_error_reported`
-
- Allows to easily asserts an error happened but was handled
-
- ```ruby
- report = assert_error_reported(IOError) do
- # ...
- end
- assert_equal "Oops", report.error.message
- assert_equal "admin", report.context[:section]
- assert_equal :warning, report.severity
- assert_predicate report, :handled?
- ```
-
- *Jean Boussier*
-
-* `ActiveSupport::Deprecation` behavior callbacks can now receive the
- deprecator instance as an argument. This makes it easier for such callbacks
- to change their behavior based on the deprecator's state. For example,
- based on the deprecator's `debug` flag.
-
- 3-arity and splat-args callbacks such as the following will now be passed
- the deprecator instance as their third argument:
-
- * `->(message, callstack, deprecator) { ... }`
- * `->(*args) { ... }`
- * `->(message, *other_args) { ... }`
-
- 2-arity and 4-arity callbacks such as the following will continue to behave
- the same as before:
-
- * `->(message, callstack) { ... }`
- * `->(message, callstack, deprecation_horizon, gem_name) { ... }`
- * `->(message, callstack, *deprecation_details) { ... }`
-
- *Jonathan Hefner*
-
-* `ActiveSupport::Deprecation#disallowed_warnings` now affects the instance on
- which it is configured.
-
- This means that individual `ActiveSupport::Deprecation` instances can be
- configured with their own disallowed warnings, and the global
- `ActiveSupport::Deprecation.disallowed_warnings` now only affects the global
- `ActiveSupport::Deprecation.warn`.
-
- **Before**
-
- ```ruby
- ActiveSupport::Deprecation.disallowed_warnings = ["foo"]
- deprecator = ActiveSupport::Deprecation.new("2.0", "MyCoolGem")
- deprecator.disallowed_warnings = ["bar"]
-
- ActiveSupport::Deprecation.warn("foo") # => raise ActiveSupport::DeprecationException
- ActiveSupport::Deprecation.warn("bar") # => print "DEPRECATION WARNING: bar"
- deprecator.warn("foo") # => raise ActiveSupport::DeprecationException
- deprecator.warn("bar") # => print "DEPRECATION WARNING: bar"
- ```
-
- **After**
-
- ```ruby
- ActiveSupport::Deprecation.disallowed_warnings = ["foo"]
- deprecator = ActiveSupport::Deprecation.new("2.0", "MyCoolGem")
- deprecator.disallowed_warnings = ["bar"]
-
- ActiveSupport::Deprecation.warn("foo") # => raise ActiveSupport::DeprecationException
- ActiveSupport::Deprecation.warn("bar") # => print "DEPRECATION WARNING: bar"
- deprecator.warn("foo") # => print "DEPRECATION WARNING: foo"
- deprecator.warn("bar") # => raise ActiveSupport::DeprecationException
- ```
-
- Note that global `ActiveSupport::Deprecation` methods such as `ActiveSupport::Deprecation.warn`
- and `ActiveSupport::Deprecation.disallowed_warnings` have been deprecated.
-
- *Jonathan Hefner*
-
-* Add italic and underline support to `ActiveSupport::LogSubscriber#color`
-
- Previously, only bold text was supported via a positional argument.
- This allows for bold, italic, and underline options to be specified
- for colored logs.
-
- ```ruby
- info color("Hello world!", :red, bold: true, underline: true)
- ```
-
- *Gannon McGibbon*
-
-* Add `String#downcase_first` method.
-
- This method is the corollary of `String#upcase_first`.
-
- *Mark Schneider*
-
-* `thread_mattr_accessor` will call `.dup.freeze` on non-frozen default values.
-
- This provides a basic level of protection against different threads trying
- to mutate a shared default object.
-
- *Jonathan Hefner*
-
-* Add `raise_on_invalid_cache_expiration_time` config to `ActiveSupport::Cache::Store`
-
- Specifies if an `ArgumentError` should be raised if `Rails.cache` `fetch` or
- `write` are given an invalid `expires_at` or `expires_in` time.
-
- Options are `true`, and `false`. If `false`, the exception will be reported
- as `handled` and logged instead. Defaults to `true` if `config.load_defaults >= 7.1`.
-
- *Trevor Turk*
-
-* `ActiveSupport::Cache:Store#fetch` now passes an options accessor to the block.
-
- It makes possible to override cache options:
-
- Rails.cache.fetch("3rd-party-token") do |name, options|
- token = fetch_token_from_remote
- # set cache's TTL to match token's TTL
- options.expires_in = token.expires_in
- token
- end
-
- *Andrii Gladkyi*, *Jean Boussier*
-
-* `default` option of `thread_mattr_accessor` now applies through inheritance and
- also across new threads.
-
- Previously, the `default` value provided was set only at the moment of defining
- the attribute writer, which would cause the attribute to be uninitialized in
- descendants and in other threads.
-
- Fixes #43312.
-
- *Thierry Deo*
-
-* Redis cache store is now compatible with redis-rb 5.0.
-
- *Jean Boussier*
-
-* Add `skip_nil:` support to `ActiveSupport::Cache::Store#fetch_multi`.
-
- *Daniel Alfaro*
-
-* Add `quarter` method to date/time
-
- *Matt Swanson*
-
-* Fix `NoMethodError` on custom `ActiveSupport::Deprecation` behavior.
-
- `ActiveSupport::Deprecation.behavior=` was supposed to accept any object
- that responds to `call`, but in fact its internal implementation assumed that
- this object could respond to `arity`, so it was restricted to only `Proc` objects.
-
- This change removes this `arity` restriction of custom behaviors.
-
- *Ryo Nakamura*
-
-* Support `:url_safe` option for `MessageEncryptor`.
-
- The `MessageEncryptor` constructor now accepts a `:url_safe` option, similar
- to the `MessageVerifier` constructor. When enabled, this option ensures
- that messages use a URL-safe encoding.
-
- *Jonathan Hefner*
-
-* Add `url_safe` option to `ActiveSupport::MessageVerifier` initializer
-
- `ActiveSupport::MessageVerifier.new` now takes optional `url_safe` argument.
- It can generate URL-safe strings by passing `url_safe: true`.
-
- ```ruby
- verifier = ActiveSupport::MessageVerifier.new(url_safe: true)
- message = verifier.generate(data) # => URL-safe string
- ```
-
- This option is `false` by default to be backwards compatible.
-
- *Shouichi Kamiya*
-
-* Enable connection pooling by default for `MemCacheStore` and `RedisCacheStore`.
-
- If you want to disable connection pooling, set `:pool` option to `false` when configuring the cache store:
-
- ```ruby
- config.cache_store = :mem_cache_store, "cache.example.com", pool: false
- ```
-
- *fatkodima*
-
-* Add `force:` support to `ActiveSupport::Cache::Store#fetch_multi`.
-
- *fatkodima*
-
-* Deprecated `:pool_size` and `:pool_timeout` options for configuring connection pooling in cache stores.
-
- Use `pool: true` to enable pooling with default settings:
-
- ```ruby
- config.cache_store = :redis_cache_store, pool: true
- ```
-
- Or pass individual options via `:pool` option:
-
- ```ruby
- config.cache_store = :redis_cache_store, pool: { size: 10, timeout: 2 }
- ```
-
- *fatkodima*
-
-* Allow #increment and #decrement methods of `ActiveSupport::Cache::Store`
- subclasses to set new values.
-
- Previously incrementing or decrementing an unset key would fail and return
- nil. A default will now be assumed and the key will be created.
-
- *Andrej Blagojević*, *Eugene Kenny*
-
-* Add `skip_nil:` support to `RedisCacheStore`
-
- *Joey Paris*
-
-* `ActiveSupport::Cache::MemoryStore#write(name, val, unless_exist:true)` now
- correctly writes expired keys.
-
- *Alan Savage*
-
-* `ActiveSupport::ErrorReporter` now accepts and forward a `source:` parameter.
-
- This allow libraries to signal the origin of the errors, and reporters
- to easily ignore some sources.
-
- *Jean Boussier*
-
-* Fix and add protections for XSS in `ActionView::Helpers` and `ERB::Util`.
-
- Add the method `ERB::Util.xml_name_escape` to escape dangerous characters
- in names of tags and names of attributes, following the specification of XML.
-
- *Álvaro Martín Fraguas*
-
-* Respect `ActiveSupport::Logger.new`'s `:formatter` keyword argument
-
- The stdlib `Logger::new` allows passing a `:formatter` keyword argument to
- set the logger's formatter. Previously `ActiveSupport::Logger.new` ignored
- that argument by always setting the formatter to an instance of
- `ActiveSupport::Logger::SimpleFormatter`.
-
- *Steven Harman*
-
-* Deprecate preserving the pre-Ruby 2.4 behavior of `to_time`
-
- With Ruby 2.4+ the default for +to_time+ changed from converting to the
- local system time to preserving the offset of the receiver. At the time Rails
- supported older versions of Ruby so a compatibility layer was added to assist
- in the migration process. From Rails 5.0 new applications have defaulted to
- the Ruby 2.4+ behavior and since Rails 7.0 now only supports Ruby 2.7+
- this compatibility layer can be safely removed.
-
- To minimize any noise generated the deprecation warning only appears when the
- setting is configured to `false` as that is the only scenario where the
- removal of the compatibility layer has any effect.
-
- *Andrew White*
-
-* `Pathname.blank?` only returns true for `Pathname.new("")`
-
- Previously it would end up calling `Pathname#empty?` which returned true
- if the path existed and was an empty directory or file.
-
- That behavior was unlikely to be expected.
-
- *Jean Boussier*
-
-* Deprecate `Notification::Event`'s `#children` and `#parent_of?`
-
- *John Hawthorn*
-
-* Change the default serializer of `ActiveSupport::MessageVerifier` from
- `Marshal` to `ActiveSupport::JSON` when using `config.load_defaults 7.1`.
-
- Messages serialized with `Marshal` can still be read, but new messages will
- be serialized with `ActiveSupport::JSON`. For more information, see
- https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer.
-
- *Saba Kiaei*, *David Buckley*, and *Jonathan Hefner*
-
-* Change the default serializer of `ActiveSupport::MessageEncryptor` from
- `Marshal` to `ActiveSupport::JSON` when using `config.load_defaults 7.1`.
-
- Messages serialized with `Marshal` can still be read, but new messages will
- be serialized with `ActiveSupport::JSON`. For more information, see
- https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer.
-
- *Zack Deveau*, *Martin Gingras*, and *Jonathan Hefner*
-
-* Add `ActiveSupport::TestCase#stub_const` to stub a constant for the duration of a yield.
-
- *DHH*
-
-* Fix `ActiveSupport::EncryptedConfiguration` to be compatible with Psych 4
-
- *Stephen Sugden*
-
-* Improve `File.atomic_write` error handling
-
- *Daniel Pepper*
-
-* Fix `Class#descendants` and `DescendantsTracker#descendants` compatibility with Ruby 3.1.
-
- [The native `Class#descendants` was reverted prior to Ruby 3.1 release](https://bugs.ruby-lang.org/issues/14394#note-33),
- but `Class#subclasses` was kept, breaking the feature detection.
-
- *Jean Boussier*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activesupport/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activesupport/CHANGELOG.md) for previous changes.
diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb
index 203a234c2d01f..e17864fe81a3a 100644
--- a/activesupport/lib/active_support.rb
+++ b/activesupport/lib/active_support.rb
@@ -28,6 +28,7 @@
require "active_support/version"
require "active_support/deprecator"
require "active_support/logger"
+require "active_support/broadcast_logger"
require "active_support/lazy_load_hooks"
require "active_support/core_ext/date_and_time/compatibility"
diff --git a/activesupport/lib/active_support/broadcast_logger.rb b/activesupport/lib/active_support/broadcast_logger.rb
new file mode 100644
index 0000000000000..e5c9efbccc92d
--- /dev/null
+++ b/activesupport/lib/active_support/broadcast_logger.rb
@@ -0,0 +1,206 @@
+# frozen_string_literal: true
+
+module ActiveSupport
+ # = Active Support Broadcast Logger
+ #
+ # The Broadcast logger is a logger used to write messages to multiple IO. It is commonly used
+ # in development to display messages on STDOUT and also write them to a file (development.log).
+ # With the Broadcast logger, you can broadcast your logs to a unlimited number of sinks.
+ #
+ # The BroadcastLogger acts as a standard logger and all methods you are used to are available.
+ # However, all the methods on this logger will propagate and be delegated to the other loggers
+ # that are part of the broadcast.
+ #
+ # Broadcasting your logs.
+ #
+ # stdout_logger = Logger.new(STDOUT)
+ # file_logger = Logger.new("development.log")
+ # broadcast = BroadcastLogger.new(stdout_logger, file_logger)
+ #
+ # broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
+ #
+ # Add a logger to the broadcast.
+ #
+ # stdout_logger = Logger.new(STDOUT)
+ # broadcast = BroadcastLogger.new(stdout_logger)
+ # file_logger = Logger.new("development.log")
+ # broadcast.broadcast_to(file_logger)
+ #
+ # broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
+ #
+ # Modifying the log level for all broadcasted loggers.
+ #
+ # stdout_logger = Logger.new(STDOUT)
+ # file_logger = Logger.new("development.log")
+ # broadcast = BroadcastLogger.new(stdout_logger, file_logger)
+ #
+ # broadcast.level = Logger::FATAL # Modify the log level for the whole broadcast.
+ #
+ # Stop broadcasting log to a sink.
+ #
+ # stdout_logger = Logger.new(STDOUT)
+ # file_logger = Logger.new("development.log")
+ # broadcast = BroadcastLogger.new(stdout_logger, file_logger)
+ # broadcast.info("Hello world!") # Writes the log to STDOUT and the development.log file.
+ #
+ # broadcast.stop_broadcasting_to(file_logger)
+ # broadcast.info("Hello world!") # Writes the log *only* to STDOUT.
+ #
+ # At least one sink has to be part of the broadcast. Otherwise, your logs will not
+ # be written anywhere. For instance:
+ #
+ # broadcast = BroadcastLogger.new
+ # broadcast.info("Hello world") # The log message will appear nowhere.
+ class BroadcastLogger
+ include ActiveSupport::LoggerSilence
+
+ # Returns all the logger that are part of this broadcast.
+ attr_reader :broadcasts
+ attr_reader :formatter
+ attr_accessor :progname
+
+ def initialize(*loggers)
+ @broadcasts = []
+ @progname = "Broadcast"
+
+ broadcast_to(*loggers)
+ end
+
+ # Add logger(s) to the broadcast.
+ #
+ # broadcast_logger = ActiveSupport::BroadcastLogger.new
+ # broadcast_logger.broadcast_to(Logger.new(STDOUT), Logger.new(STDERR))
+ def broadcast_to(*loggers)
+ @broadcasts.concat(loggers)
+ end
+
+ # Remove a logger from the broadcast. When a logger is removed, messages sent to
+ # the broadcast will no longer be written to its sink.
+ #
+ # sink = Logger.new(STDOUT)
+ # broadcast_logger = ActiveSupport::BroadcastLogger.new
+ #
+ # broadcast_logger.stop_broadcasting_to(sink)
+ def stop_broadcasting_to(logger)
+ @broadcasts.delete(logger)
+ end
+
+ def level
+ @broadcasts.map(&:level).min
+ end
+
+ def <<(message)
+ dispatch { |logger| logger.<<(message) }
+ end
+
+ def add(*args, &block)
+ dispatch { |logger| logger.add(*args, &block) }
+ end
+ alias_method :log, :add
+
+ def debug(*args, &block)
+ dispatch { |logger| logger.debug(*args, &block) }
+ end
+
+ def info(*args, &block)
+ dispatch { |logger| logger.info(*args, &block) }
+ end
+
+ def warn(*args, &block)
+ dispatch { |logger| logger.warn(*args, &block) }
+ end
+
+ def error(*args, &block)
+ dispatch { |logger| logger.error(*args, &block) }
+ end
+
+ def fatal(*args, &block)
+ dispatch { |logger| logger.fatal(*args, &block) }
+ end
+
+ def unknown(*args, &block)
+ dispatch { |logger| logger.unknown(*args, &block) }
+ end
+
+ def formatter=(formatter)
+ dispatch { |logger| logger.formatter = formatter }
+
+ @formatter = formatter
+ end
+
+ def level=(level)
+ dispatch { |logger| logger.level = level }
+ end
+ alias_method :sev_threshold=, :level=
+
+ def local_level=(level)
+ dispatch do |logger|
+ logger.local_level = level if logger.respond_to?(:local_level=)
+ end
+ end
+
+ def close
+ dispatch { |logger| logger.close }
+ end
+
+ # +True+ if the log level allows entries with severity Logger::DEBUG to be written
+ # to at least one broadcast. +False+ otherwise.
+ def debug?
+ @broadcasts.any? { |logger| logger.debug? }
+ end
+
+ # Sets the log level to Logger::DEBUG for the whole broadcast.
+ def debug!
+ dispatch { |logger| logger.debug! }
+ end
+
+ # +True+ if the log level allows entries with severity Logger::INFO to be written
+ # to at least one broadcast. +False+ otherwise.
+ def info?
+ @broadcasts.any? { |logger| logger.info? }
+ end
+
+ # Sets the log level to Logger::INFO for the whole broadcast.
+ def info!
+ dispatch { |logger| logger.info! }
+ end
+
+ # +True+ if the log level allows entries with severity Logger::WARN to be written
+ # to at least one broadcast. +False+ otherwise.
+ def warn?
+ @broadcasts.any? { |logger| logger.warn? }
+ end
+
+ # Sets the log level to Logger::WARN for the whole broadcast.
+ def warn!
+ dispatch { |logger| logger.warn! }
+ end
+
+ # +True+ if the log level allows entries with severity Logger::ERROR to be written
+ # to at least one broadcast. +False+ otherwise.
+ def error?
+ @broadcasts.any? { |logger| logger.error? }
+ end
+
+ # Sets the log level to Logger::ERROR for the whole broadcast.
+ def error!
+ dispatch { |logger| logger.error! }
+ end
+
+ # +True+ if the log level allows entries with severity Logger::FATAL to be written
+ # to at least one broadcast. +False+ otherwise.
+ def fatal?
+ @broadcasts.any? { |logger| logger.fatal? }
+ end
+
+ # Sets the log level to Logger::FATAL for the whole broadcast.
+ def fatal!
+ dispatch { |logger| logger.fatal! }
+ end
+
+ private
+ def dispatch(&block)
+ @broadcasts.each { |logger| block.call(logger) }
+ end
+ end
+end
diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb
index d54b077da0bcd..aa7099601c15e 100644
--- a/activesupport/lib/active_support/callbacks.rb
+++ b/activesupport/lib/active_support/callbacks.rb
@@ -743,7 +743,7 @@ def __update_callbacks(name) # :nodoc:
#
# The callback can be specified as a symbol naming an instance method; as a
# proc, lambda, or block; or as an object that responds to a certain method
- # determined by the :scope argument to +define_callbacks+.
+ # determined by the :scope argument to #define_callbacks.
#
# If a proc, lambda, or block is given, its body is evaluated in the context
# of the current object. It can also optionally accept the current object as
@@ -787,10 +787,13 @@ def set_callback(name, *filter_list, &block)
end
end
- # Skip a previously set callback. Like +set_callback+, :if or
+ # Skip a previously set callback. Like #set_callback, :if or
# :unless options may be passed in order to control when the
# callback is skipped.
#
+ # Note: this example uses +PersonRecord+ and +#saving_message+, which you
+ # can see defined here[rdoc-ref:ActiveSupport::Callbacks]
+ #
# class Writer < PersonRecord
# attr_accessor :age
# skip_callback :save, :before, :saving_message, if: -> { age > 18 }
@@ -933,7 +936,7 @@ def reset_callbacks(name)
# !, ? or =.
#
# Calling +define_callbacks+ multiple times with the same +names+ will
- # overwrite previous callbacks registered with +set_callback+.
+ # overwrite previous callbacks registered with #set_callback.
def define_callbacks(*names)
options = names.extract_options!
diff --git a/activesupport/lib/active_support/core_ext/hash/deep_merge.rb b/activesupport/lib/active_support/core_ext/hash/deep_merge.rb
index 9bc50b7bc63bf..e5fde9a0de393 100644
--- a/activesupport/lib/active_support/core_ext/hash/deep_merge.rb
+++ b/activesupport/lib/active_support/core_ext/hash/deep_merge.rb
@@ -1,6 +1,14 @@
# frozen_string_literal: true
+require "active_support/deep_mergeable"
+
class Hash
+ include ActiveSupport::DeepMergeable
+
+ ##
+ # :method: deep_merge
+ # :call-seq: deep_merge(other_hash, &block)
+ #
# Returns a new hash with +self+ and +other_hash+ merged recursively.
#
# h1 = { a: true, b: { c: [1, 2, 3] } }
@@ -15,20 +23,20 @@ class Hash
# h2 = { b: 250, c: { c1: 200 } }
# h1.deep_merge(h2) { |key, this_val, other_val| this_val + other_val }
# # => { a: 100, b: 450, c: { c1: 300 } }
- def deep_merge(other_hash, &block)
- dup.deep_merge!(other_hash, &block)
- end
+ #
+ #--
+ # Implemented by ActiveSupport::DeepMergeable#deep_merge.
+
+ ##
+ # :method: deep_merge!
+ # :call-seq: deep_merge!(other_hash, &block)
+ #
+ # Same as #deep_merge, but modifies +self+.
+ #
+ #--
+ # Implemented by ActiveSupport::DeepMergeable#deep_merge!.
- # Same as +deep_merge+, but modifies +self+.
- def deep_merge!(other_hash, &block)
- merge!(other_hash) do |key, this_val, other_val|
- if this_val.is_a?(Hash) && other_val.is_a?(Hash)
- this_val.deep_merge(other_val, &block)
- elsif block_given?
- block.call(key, this_val, other_val)
- else
- other_val
- end
- end
+ def deep_merge?(other) # :nodoc:
+ other.is_a?(Hash)
end
end
diff --git a/activesupport/lib/active_support/core_ext/range/overlap.rb b/activesupport/lib/active_support/core_ext/range/overlap.rb
index fb8f6514db2fe..07215776a707b 100644
--- a/activesupport/lib/active_support/core_ext/range/overlap.rb
+++ b/activesupport/lib/active_support/core_ext/range/overlap.rb
@@ -4,8 +4,36 @@ class Range
# Compare two ranges and see if they overlap each other
# (1..5).overlap?(4..6) # => true
# (1..5).overlap?(7..9) # => false
- def overlap?(other)
- other.begin == self.begin || cover?(other.begin) || other.cover?(self.begin)
+ unless Range.method_defined?(:overlap?)
+ def overlap?(other)
+ raise TypeError unless other.is_a? Range
+
+ self_begin = self.begin
+ other_end = other.end
+ other_excl = other.exclude_end?
+
+ return false if _empty_range?(self_begin, other_end, other_excl)
+
+ other_begin = other.begin
+ self_end = self.end
+ self_excl = self.exclude_end?
+
+ return false if _empty_range?(other_begin, self_end, self_excl)
+ return true if self_begin == other_begin
+
+ return false if _empty_range?(self_begin, self_end, self_excl)
+ return false if _empty_range?(other_begin, other_end, other_excl)
+
+ true
+ end
+
+ private
+ def _empty_range?(b, e, excl)
+ return false if b.nil? || e.nil?
+
+ comp = b <=> e
+ comp.nil? || comp > 0 || (comp == 0 && excl)
+ end
end
alias :overlaps? :overlap?
diff --git a/activesupport/lib/active_support/core_ext/securerandom.rb b/activesupport/lib/active_support/core_ext/securerandom.rb
index fa6b68b101918..6c1f2be919bea 100644
--- a/activesupport/lib/active_support/core_ext/securerandom.rb
+++ b/activesupport/lib/active_support/core_ext/securerandom.rb
@@ -16,12 +16,18 @@ module SecureRandom
#
# p SecureRandom.base58 # => "4kUgL2pdQMSCQtjE"
# p SecureRandom.base58(24) # => "77TMHrHJFvFDwodq8w7Ev2m7"
- def self.base58(n = 16)
- SecureRandom.random_bytes(n).unpack("C*").map do |byte|
- idx = byte % 64
- idx = SecureRandom.random_number(58) if idx >= 58
- BASE58_ALPHABET[idx]
- end.join
+ if RUBY_VERSION >= "3.3"
+ def self.base58(n = 16)
+ SecureRandom.alphanumeric(n, chars: BASE58_ALPHABET)
+ end
+ else
+ def self.base58(n = 16)
+ SecureRandom.random_bytes(n).unpack("C*").map do |byte|
+ idx = byte % 64
+ idx = SecureRandom.random_number(58) if idx >= 58
+ BASE58_ALPHABET[idx]
+ end.join
+ end
end
# SecureRandom.base36 generates a random base36 string in lowercase.
@@ -35,11 +41,17 @@ def self.base58(n = 16)
#
# p SecureRandom.base36 # => "4kugl2pdqmscqtje"
# p SecureRandom.base36(24) # => "77tmhrhjfvfdwodq8w7ev2m7"
- def self.base36(n = 16)
- SecureRandom.random_bytes(n).unpack("C*").map do |byte|
- idx = byte % 64
- idx = SecureRandom.random_number(36) if idx >= 36
- BASE36_ALPHABET[idx]
- end.join
+ if RUBY_VERSION >= "3.3"
+ def self.base36(n = 16)
+ SecureRandom.alphanumeric(n, chars: BASE36_ALPHABET)
+ end
+ else
+ def self.base36(n = 16)
+ SecureRandom.random_bytes(n).unpack("C*").map do |byte|
+ idx = byte % 64
+ idx = SecureRandom.random_number(36) if idx >= 36
+ BASE36_ALPHABET[idx]
+ end.join
+ end
end
end
diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb
index 83464f1a9c308..a117573bbe68f 100644
--- a/activesupport/lib/active_support/core_ext/time/calculations.rb
+++ b/activesupport/lib/active_support/core_ext/time/calculations.rb
@@ -159,7 +159,7 @@ def change(options)
::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, new_offset)
elsif utc?
::Time.utc(new_year, new_month, new_day, new_hour, new_min, new_sec)
- elsif zone&.respond_to?(:utc_to_local)
+ elsif zone.respond_to?(:utc_to_local)
new_time = ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, zone)
# When there are two occurrences of a nominal time due to DST ending,
diff --git a/activesupport/lib/active_support/deep_mergeable.rb b/activesupport/lib/active_support/deep_mergeable.rb
new file mode 100644
index 0000000000000..c00a240bbd73c
--- /dev/null
+++ b/activesupport/lib/active_support/deep_mergeable.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module ActiveSupport
+ # Provides +deep_merge+ and +deep_merge!+ methods. Expects the including class
+ # to provide a merge!(other, &block) method.
+ module DeepMergeable # :nodoc:
+ # Returns a new instance with the values from +other+ merged recursively.
+ #
+ # class Hash
+ # include ActiveSupport::DeepMergeable
+ # end
+ #
+ # hash_1 = { a: true, b: { c: [1, 2, 3] } }
+ # hash_2 = { a: false, b: { x: [3, 4, 5] } }
+ #
+ # hash_1.deep_merge(hash_2)
+ # # => { a: false, b: { c: [1, 2, 3], x: [3, 4, 5] } }
+ #
+ # A block can be provided to merge non-DeepMergeable values:
+ #
+ # hash_1 = { a: 100, b: 200, c: { c1: 100 } }
+ # hash_2 = { b: 250, c: { c1: 200 } }
+ #
+ # hash_1.deep_merge(hash_2) do |key, this_val, other_val|
+ # this_val + other_val
+ # end
+ # # => { a: 100, b: 450, c: { c1: 300 } }
+ #
+ def deep_merge(other, &block)
+ dup.deep_merge!(other, &block)
+ end
+
+ # Same as #deep_merge, but modifies +self+.
+ def deep_merge!(other, &block)
+ merge!(other) do |key, this_val, other_val|
+ if this_val.is_a?(DeepMergeable) && this_val.deep_merge?(other_val)
+ this_val.deep_merge(other_val, &block)
+ elsif block_given?
+ block.call(key, this_val, other_val)
+ else
+ other_val
+ end
+ end
+ end
+
+ # Returns true if +other+ can be deep merged into +self+. Classes may
+ # override this method to restrict or expand the domain of deep mergeable
+ # values. Defaults to checking that +other+ is of type +self.class+.
+ def deep_merge?(other)
+ other.is_a?(self.class)
+ end
+ end
+end
diff --git a/activesupport/lib/active_support/deprecation.rb b/activesupport/lib/active_support/deprecation.rb
index 1feb4af2f7c1b..ad96ecb946f53 100644
--- a/activesupport/lib/active_support/deprecation.rb
+++ b/activesupport/lib/active_support/deprecation.rb
@@ -65,7 +65,7 @@ class Deprecation
# and the second is a library name.
#
# ActiveSupport::Deprecation.new('2.0', 'MyLibrary')
- def initialize(deprecation_horizon = "7.2", gem_name = "Rails")
+ def initialize(deprecation_horizon = "7.3", gem_name = "Rails")
self.gem_name = gem_name
self.deprecation_horizon = deprecation_horizon
# By default, warnings are not silenced and debugging is off.
diff --git a/activesupport/lib/active_support/descendants_tracker.rb b/activesupport/lib/active_support/descendants_tracker.rb
index 3b1e7bae142a9..d88bc46073f57 100644
--- a/activesupport/lib/active_support/descendants_tracker.rb
+++ b/activesupport/lib/active_support/descendants_tracker.rb
@@ -66,8 +66,6 @@ def descendants
end
end
- include ReloadedClassesFiltering
-
class << self
def disable_clear! # :nodoc:
unless @clear_disabled
@@ -109,7 +107,7 @@ def descendants(klass)
end
def descendants
- subclasses = self.subclasses
+ subclasses = DescendantsTracker.reject!(self.subclasses)
subclasses.concat(subclasses.flat_map(&:descendants))
end
else
diff --git a/activesupport/lib/active_support/gem_version.rb b/activesupport/lib/active_support/gem_version.rb
index 66ad8abc28c27..eada318028a55 100644
--- a/activesupport/lib/active_support/gem_version.rb
+++ b/activesupport/lib/active_support/gem_version.rb
@@ -8,7 +8,7 @@ def self.gem_version
module VERSION
MAJOR = 7
- MINOR = 1
+ MINOR = 2
TINY = 0
PRE = "alpha"
diff --git a/activesupport/lib/active_support/i18n_railtie.rb b/activesupport/lib/active_support/i18n_railtie.rb
index df16a1c3586a1..2b4075a39eea0 100644
--- a/activesupport/lib/active_support/i18n_railtie.rb
+++ b/activesupport/lib/active_support/i18n_railtie.rb
@@ -49,7 +49,7 @@ def self.initialize_i18n(app)
when :load_path
I18n.load_path += value
when :raise_on_missing_translations
- forward_raise_on_missing_translations_config(app)
+ setup_raise_on_missing_translations_config(app)
else
I18n.public_send("#{setting}=", value)
end
@@ -77,15 +77,11 @@ def self.initialize_i18n(app)
@i18n_inited = true
end
- def self.forward_raise_on_missing_translations_config(app)
+ def self.setup_raise_on_missing_translations_config(app)
ActiveSupport.on_load(:action_view) do
ActionView::Helpers::TranslationHelper.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
end
- ActiveSupport.on_load(:action_controller) do
- AbstractController::Translation.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
- end
-
if app.config.i18n.raise_on_missing_translations &&
I18n.exception_handler.is_a?(I18n::ExceptionHandler) # Only override the i18n gem's default exception handler.
diff --git a/activesupport/lib/active_support/logger.rb b/activesupport/lib/active_support/logger.rb
index 1f62e61d86562..09a295f287090 100644
--- a/activesupport/lib/active_support/logger.rb
+++ b/activesupport/lib/active_support/logger.rb
@@ -19,64 +19,6 @@ def self.logger_outputs_to?(logger, *sources)
sources.any? { |source| source == logger_source }
end
- # Broadcasts logs to multiple loggers.
- def self.broadcast(logger) # :nodoc:
- Module.new do
- define_method(:add) do |*args, &block|
- logger.add(*args, &block)
- super(*args, &block)
- end
-
- define_method(:<<) do |x|
- logger << x
- super(x)
- end
-
- define_method(:close) do
- logger.close
- super()
- end
-
- define_method(:progname=) do |name|
- logger.progname = name
- super(name)
- end
-
- define_method(:formatter=) do |formatter|
- logger.formatter = formatter
- super(formatter)
- end
-
- define_method(:level=) do |level|
- logger.level = level
- super(level)
- end
-
- define_method(:local_level=) do |level|
- logger.local_level = level if logger.respond_to?(:local_level=)
- super(level) if respond_to?(:local_level=)
- end
-
- define_method(:silence) do |level = Logger::ERROR, &block|
- if logger.respond_to?(:silence)
- logger.silence(level) do
- if defined?(super)
- super(level, &block)
- else
- block.call(self)
- end
- end
- else
- if defined?(super)
- super(level, &block)
- else
- block.call(self)
- end
- end
- end
- end
- end
-
def initialize(*args, **kwargs)
super
@formatter ||= SimpleFormatter.new
diff --git a/activesupport/lib/active_support/logger_thread_safe_level.rb b/activesupport/lib/active_support/logger_thread_safe_level.rb
index 38142bfe70502..cc2e46b0da728 100644
--- a/activesupport/lib/active_support/logger_thread_safe_level.rb
+++ b/activesupport/lib/active_support/logger_thread_safe_level.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
require "active_support/concern"
-require "active_support/core_ext/module/attribute_accessors"
-require "concurrent"
-require "fiber"
+require "logger"
module ActiveSupport
module LoggerThreadSafeLevel # :nodoc:
diff --git a/activesupport/lib/active_support/messages/metadata.rb b/activesupport/lib/active_support/messages/metadata.rb
index d8e6a2c61ca16..a10729a41dff7 100644
--- a/activesupport/lib/active_support/messages/metadata.rb
+++ b/activesupport/lib/active_support/messages/metadata.rb
@@ -32,7 +32,7 @@ def serialize_with_metadata(data, **metadata)
if has_metadata && !use_message_serializer_for_metadata?
data_string = serialize_to_json_safe_string(data)
- envelope = wrap_in_metadata_envelope({ "message" => data_string }, **metadata)
+ envelope = wrap_in_metadata_legacy_envelope({ "message" => data_string }, **metadata)
serialize_to_json(envelope)
else
data = wrap_in_metadata_envelope({ "data" => data }, **metadata) if has_metadata
@@ -68,6 +68,13 @@ def wrap_in_metadata_envelope(hash, expires_at: nil, expires_in: nil, purpose: n
{ "_rails" => hash }
end
+ def wrap_in_metadata_legacy_envelope(hash, expires_at: nil, expires_in: nil, purpose: nil)
+ expiry = pick_expiry(expires_at, expires_in)
+ hash["exp"] = expiry
+ hash["pur"] = purpose
+ { "_rails" => hash }
+ end
+
def extract_from_metadata_envelope(envelope, purpose: nil)
hash = envelope["_rails"]
diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb
index b95ae54823a63..c91fa59882aae 100644
--- a/activesupport/lib/active_support/railtie.rb
+++ b/activesupport/lib/active_support/railtie.rb
@@ -88,7 +88,7 @@ class Railtie < Rails::Railtie # :nodoc:
initializer "active_support.initialize_time_zone" do |app|
begin
TZInfo::DataSource.get
- rescue TZInfo::DataSourceNotFound => e
+ rescue TZInfo::DataSourceNotFound, TZInfo::ZoneinfoDirectoryNotFound => e
raise e.exception "tzinfo-data is not present. Please add gem 'tzinfo-data' to your Gemfile and run bundle install"
end
require "active_support/core_ext/time/zones"
diff --git a/activesupport/lib/active_support/testing/error_reporter_assertions.rb b/activesupport/lib/active_support/testing/error_reporter_assertions.rb
index d6e7119a0261e..3b7ad7cd9bcdb 100644
--- a/activesupport/lib/active_support/testing/error_reporter_assertions.rb
+++ b/activesupport/lib/active_support/testing/error_reporter_assertions.rb
@@ -3,7 +3,7 @@
module ActiveSupport
module Testing
module ErrorReporterAssertions
- module ErrorCollector
+ module ErrorCollector # :nodoc:
@subscribed = false
@mutex = Mutex.new
@@ -51,58 +51,57 @@ def subscribe
end
end
- private
- # Assertion that the block should not cause an exception to be reported
- # to +Rails.error+.
- #
- # Passes if evaluated code in the yielded block reports no exception.
- #
- # assert_no_error_reported do
- # perform_service(param: 'no_exception')
- # end
- def assert_no_error_reported(&block)
- reports = ErrorCollector.record do
- _assert_nothing_raised_or_warn("assert_no_error_reported", &block)
- end
- assert_predicate(reports, :empty?)
+ # Assertion that the block should not cause an exception to be reported
+ # to +Rails.error+.
+ #
+ # Passes if evaluated code in the yielded block reports no exception.
+ #
+ # assert_no_error_reported do
+ # perform_service(param: 'no_exception')
+ # end
+ def assert_no_error_reported(&block)
+ reports = ErrorCollector.record do
+ _assert_nothing_raised_or_warn("assert_no_error_reported", &block)
end
+ assert_predicate(reports, :empty?)
+ end
- # Assertion that the block should cause at least one exception to be reported
- # to +Rails.error+.
- #
- # Passes if the evaluated code in the yielded block reports a matching exception.
- #
- # assert_error_reported(IOError) do
- # Rails.error.report(IOError.new("Oops"))
- # end
- #
- # To test further details about the reported exception, you can use the return
- # value.
- #
- # report = assert_error_reported(IOError) do
- # # ...
- # end
- # assert_equal "Oops", report.error.message
- # assert_equal "admin", report.context[:section]
- # assert_equal :warning, report.severity
- # assert_predicate report, :handled?
- def assert_error_reported(error_class = StandardError, &block)
- reports = ErrorCollector.record do
- _assert_nothing_raised_or_warn("assert_error_reported", &block)
- end
+ # Assertion that the block should cause at least one exception to be reported
+ # to +Rails.error+.
+ #
+ # Passes if the evaluated code in the yielded block reports a matching exception.
+ #
+ # assert_error_reported(IOError) do
+ # Rails.error.report(IOError.new("Oops"))
+ # end
+ #
+ # To test further details about the reported exception, you can use the return
+ # value.
+ #
+ # report = assert_error_reported(IOError) do
+ # # ...
+ # end
+ # assert_equal "Oops", report.error.message
+ # assert_equal "admin", report.context[:section]
+ # assert_equal :warning, report.severity
+ # assert_predicate report, :handled?
+ def assert_error_reported(error_class = StandardError, &block)
+ reports = ErrorCollector.record do
+ _assert_nothing_raised_or_warn("assert_error_reported", &block)
+ end
- if reports.empty?
- assert(false, "Expected a #{error_class.name} to be reported, but there were no errors reported.")
- elsif (report = reports.find { |r| error_class === r.error })
- self.assertions += 1
- report
- else
- message = "Expected a #{error_class.name} to be reported, but none of the " \
- "#{reports.size} reported errors matched: \n" \
- "#{reports.map { |r| r.error.class.name }.join("\n ")}"
- assert(false, message)
- end
+ if reports.empty?
+ assert(false, "Expected a #{error_class.name} to be reported, but there were no errors reported.")
+ elsif (report = reports.find { |r| error_class === r.error })
+ self.assertions += 1
+ report
+ else
+ message = "Expected a #{error_class.name} to be reported, but none of the " \
+ "#{reports.size} reported errors matched: \n" \
+ "#{reports.map { |r| r.error.class.name }.join("\n ")}"
+ assert(false, message)
end
+ end
end
end
end
diff --git a/activesupport/test/broadcast_logger_test.rb b/activesupport/test/broadcast_logger_test.rb
index 2080af76a96fd..d326f13ed1324 100644
--- a/activesupport/test/broadcast_logger_test.rb
+++ b/activesupport/test/broadcast_logger_test.rb
@@ -9,8 +9,7 @@ class BroadcastLoggerTest < TestCase
setup do
@log1 = FakeLogger.new
@log2 = FakeLogger.new
- @log1.extend Logger.broadcast @log2
- @logger = @log1
+ @logger = BroadcastLogger.new(@log1, @log2)
end
Logger::Severity.constants.each do |level_name|
@@ -40,23 +39,40 @@ class BroadcastLoggerTest < TestCase
end
test "#level= assigns the level to all loggers" do
- assert_equal ::Logger::DEBUG, logger.level
+ assert_equal ::Logger::DEBUG, log1.level
logger.level = ::Logger::FATAL
assert_equal ::Logger::FATAL, log1.level
assert_equal ::Logger::FATAL, log2.level
end
- test "#progname= assigns to all the loggers" do
- assert_nil logger.progname
- logger.progname = ::Logger::FATAL
+ test "#level returns the level of the logger with the lowest level" do
+ log1.level = Logger::DEBUG
- assert_equal ::Logger::FATAL, log1.progname
- assert_equal ::Logger::FATAL, log2.progname
+ assert_equal(Logger::DEBUG, logger.level)
+
+ log1.level = Logger::FATAL
+ log2.level = Logger::INFO
+
+ assert_equal(Logger::INFO, logger.level)
+ end
+
+ test "#progname returns Broadcast literally when the user didn't change the progname" do
+ assert_equal("Broadcast", logger.progname)
+ end
+
+ test "#progname= sets the progname on the Broadcast logger but doesn't modify the inner loggers" do
+ assert_nil(log1.progname)
+ assert_nil(log2.progname)
+
+ logger.progname = "Foo"
+
+ assert_equal("Foo", logger.progname)
+ assert_nil(log1.progname)
+ assert_nil(log2.progname)
end
test "#formatter= assigns to all the loggers" do
- assert_nil logger.formatter
logger.formatter = ::Logger::FATAL
assert_equal ::Logger::FATAL, log1.formatter
@@ -64,24 +80,42 @@ class BroadcastLoggerTest < TestCase
end
test "#local_level= assigns the local_level to all loggers" do
- assert_equal ::Logger::DEBUG, logger.local_level
+ assert_equal ::Logger::DEBUG, log1.local_level
logger.local_level = ::Logger::FATAL
assert_equal ::Logger::FATAL, log1.local_level
assert_equal ::Logger::FATAL, log2.local_level
end
+ test "severity methods get called on all loggers" do
+ my_logger = Class.new(::Logger) do
+ attr_reader :info_called
+
+ def info(msg, &block)
+ @info_called = true
+ end
+ end.new(StringIO.new)
+
+ @logger.broadcast_to(my_logger)
+
+ assert_changes(-> { my_logger.info_called }, from: nil, to: true) do
+ @logger.info("message")
+ end
+ ensure
+ @logger.stop_broadcasting_to(my_logger)
+ end
+
test "#silence does not break custom loggers" do
new_logger = FakeLogger.new
custom_logger = CustomLogger.new
assert_respond_to new_logger, :silence
assert_not_respond_to custom_logger, :silence
- custom_logger.extend(Logger.broadcast(new_logger))
+ logger = BroadcastLogger.new(custom_logger, new_logger)
- custom_logger.silence do
- custom_logger.error "from error"
- custom_logger.unknown "from unknown"
+ logger.silence do
+ logger.error "from error"
+ logger.unknown "from unknown"
end
assert_equal [[::Logger::ERROR, "from error", nil], [::Logger::UNKNOWN, "from unknown", nil]], custom_logger.adds
@@ -117,6 +151,99 @@ class BroadcastLoggerTest < TestCase
assert_equal [[::Logger::FATAL, "seen", nil]], log2.adds
end
+ test "stop broadcasting to a logger" do
+ @logger.stop_broadcasting_to(@log2)
+
+ @logger.info("Hello")
+
+ assert_equal([[1, "Hello", nil]], @log1.adds)
+ assert_empty(@log2.adds)
+ end
+
+ test "#broadcast on another broadcasted logger" do
+ @log3 = FakeLogger.new
+ @log4 = FakeLogger.new
+ @broadcast2 = ActiveSupport::BroadcastLogger.new(@log3, @log4)
+
+ @logger.broadcast_to(@broadcast2)
+ @logger.info("Hello")
+
+ assert_equal([[1, "Hello", nil]], @log1.adds)
+ assert_equal([[1, "Hello", nil]], @log2.adds)
+ assert_equal([[1, "Hello", nil]], @log3.adds)
+ assert_equal([[1, "Hello", nil]], @log4.adds)
+ end
+
+ test "#debug? is true when at least one logger's level is at or above DEBUG level" do
+ @log1.level = Logger::DEBUG
+ @log2.level = Logger::FATAL
+
+ assert_predicate(@logger, :debug?)
+ end
+
+ test "#debug? is false when all loggers are below DEBUG level" do
+ @log1.level = Logger::ERROR
+ @log2.level = Logger::FATAL
+
+ assert_not_predicate(@logger, :debug?)
+ end
+
+ test "#info? is true when at least one logger's level is at or above INFO level" do
+ @log1.level = Logger::DEBUG
+ @log2.level = Logger::FATAL
+
+ assert_predicate(@logger, :info?)
+ end
+
+ test "#info? is false when all loggers are below INFO" do
+ @log1.level = Logger::ERROR
+ @log2.level = Logger::FATAL
+
+ assert_not_predicate(@logger, :info?)
+ end
+
+ test "#warn? is true when at least one logger's level is at or above WARN level" do
+ @log1.level = Logger::DEBUG
+ @log2.level = Logger::FATAL
+
+ assert_predicate(@logger, :warn?)
+ end
+
+ test "#warn? is false when all loggers are below WARN" do
+ @log1.level = Logger::ERROR
+ @log2.level = Logger::FATAL
+
+ assert_not_predicate(@logger, :warn?)
+ end
+
+ test "#error? is true when at least one logger's level is at or above ERROR level" do
+ @log1.level = Logger::DEBUG
+ @log2.level = Logger::FATAL
+
+ assert_predicate(@logger, :error?)
+ end
+
+ test "#error? is false when all loggers are below ERROR" do
+ @log1.level = Logger::FATAL
+ @log2.level = Logger::FATAL
+
+ assert_not_predicate(@logger, :error?)
+ end
+
+ test "#fatal? is true when at least one logger's level is at or above FATAL level" do
+ @log1.level = Logger::DEBUG
+ @log2.level = Logger::FATAL
+
+ assert_predicate(@logger, :fatal?)
+ end
+
+ test "#fatal? is false when all loggers are below FATAL" do
+ @log1.level = Logger::UNKNOWN
+ @log2.level = Logger::UNKNOWN
+
+ assert_not_predicate(@logger, :fatal?)
+ end
+
class CustomLogger
attr_reader :adds, :closed, :chevrons
attr_accessor :level, :progname, :formatter, :local_level
diff --git a/activesupport/test/concern_test.rb b/activesupport/test/concern_test.rb
index e7661a048977c..46c6171e13fde 100644
--- a/activesupport/test/concern_test.rb
+++ b/activesupport/test/concern_test.rb
@@ -193,7 +193,7 @@ def test_prepended_and_included_methods
@klass.include included
@klass.prepend prepended
- assert_equal @klass.new.foo, [:included, :class, :prepended]
+ assert_equal [:included, :class, :prepended], @klass.new.foo
end
def test_prepended_and_included_class_methods
@@ -208,6 +208,6 @@ def test_prepended_and_included_class_methods
@klass.include included
@klass.prepend prepended
- assert_equal @klass.foo, [:included, :class, :prepended]
+ assert_equal [:included, :class, :prepended], @klass.foo
end
end
diff --git a/activesupport/test/core_ext/range_ext_test.rb b/activesupport/test/core_ext/range_ext_test.rb
index d55014e67c2fd..171ed86ffc16d 100644
--- a/activesupport/test/core_ext/range_ext_test.rb
+++ b/activesupport/test/core_ext/range_ext_test.rb
@@ -70,6 +70,67 @@ def test_overlaps_alias
assert_not (1...5).overlaps?(6..10)
end
+ def test_overlap_behaves_like_ruby
+ assert_not_operator(0..2, :overlap?, -2..-1)
+ assert_not_operator(0..2, :overlap?, -2...0)
+ assert_operator(0..2, :overlap?, -1..0)
+ assert_operator(0..2, :overlap?, 1..2)
+ assert_operator(0..2, :overlap?, 2..3)
+ assert_not_operator(0..2, :overlap?, 3..4)
+ assert_not_operator(0...2, :overlap?, 2..3)
+ assert_operator(..0, :overlap?, -1..0)
+ assert_operator(...0, :overlap?, -1..0)
+ assert_operator(..0, :overlap?, 0..1)
+ assert_operator(..0, :overlap?, ..1)
+ assert_not_operator(..0, :overlap?, 1..2)
+ assert_not_operator(...0, :overlap?, 0..1)
+ assert_not_operator(0.., :overlap?, -2..-1)
+ assert_not_operator(0.., :overlap?, ...0)
+ assert_operator(0.., :overlap?, -1..0)
+ assert_operator(0.., :overlap?, ..0)
+ assert_operator(0.., :overlap?, 0..1)
+ assert_operator(0.., :overlap?, 1..2)
+ assert_operator(0.., :overlap?, 1..)
+
+ assert_not_operator((1..3), :overlap?, ("a".."d"))
+
+ assert_raise(TypeError) { (0..).overlap?(1) }
+ assert_raise(TypeError) { (0..).overlap?(nil) }
+
+ assert_operator((1..3), :overlap?, (2..4))
+ assert_operator((1...3), :overlap?, (2..3))
+ assert_operator((2..3), :overlap?, (1..2))
+ assert_operator((..3), :overlap?, (3..))
+ assert_operator((nil..nil), :overlap?, (3..))
+ assert_operator((nil...nil), :overlap?, (nil..))
+
+ assert_raise(TypeError) { (1..3).overlap?(1) }
+
+ assert_not_operator((1..2), :overlap?, (2...2))
+ assert_not_operator((2...2), :overlap?, (1..2))
+
+ assert_not_operator((4..1), :overlap?, (2..3))
+ assert_not_operator((4..1), :overlap?, (..3))
+ assert_not_operator((4..1), :overlap?, (2..))
+
+ assert_not_operator((1..4), :overlap?, (3..2))
+ assert_not_operator((..4), :overlap?, (3..2))
+ assert_not_operator((1..), :overlap?, (3..2))
+
+ assert_not_operator((4..5), :overlap?, (2..3))
+ assert_not_operator((4..5), :overlap?, (2...4))
+
+ assert_not_operator((1..2), :overlap?, (3..4))
+ assert_not_operator((1...3), :overlap?, (3..4))
+
+ assert_not_operator((4..5), :overlap?, (2..3))
+ assert_not_operator((4..5), :overlap?, (2...4))
+
+ assert_not_operator((1..2), :overlap?, (3..4))
+ assert_not_operator((1...3), :overlap?, (3..4))
+ assert_not_operator((...3), :overlap?, (3..))
+ end
+
def test_should_include_identical_inclusive
assert((1..10).include?(1..10))
end
diff --git a/activesupport/test/core_ext/secure_random_test.rb b/activesupport/test/core_ext/secure_random_test.rb
index 5b9f26adc82b1..f2e1c2ba5cebb 100644
--- a/activesupport/test/core_ext/secure_random_test.rb
+++ b/activesupport/test/core_ext/secure_random_test.rb
@@ -28,6 +28,18 @@ def test_base58_with_length
assert_match(/^[^0OIl]+$/, s2)
end
+ def test_base58_with_nil
+ s1 = SecureRandom.base58(nil)
+ s2 = SecureRandom.base58(nil)
+
+ assert_not_equal s1, s2
+ assert_equal 16, s1.length
+ assert_match(/^[a-zA-Z0-9]+$/, s1)
+ assert_match(/^[a-zA-Z0-9]+$/, s2)
+ assert_match(/^[^0OIl]+$/, s1)
+ assert_match(/^[^0OIl]+$/, s2)
+ end
+
def test_base36
s1 = SecureRandom.base36
s2 = SecureRandom.base36
@@ -47,4 +59,14 @@ def test_base36_with_length
assert_match(/^[a-z0-9]+$/, s1)
assert_match(/^[a-z0-9]+$/, s2)
end
+
+ def test_base36_with_nil
+ s1 = SecureRandom.base36(nil)
+ s2 = SecureRandom.base36(nil)
+
+ assert_not_equal s1, s2
+ assert_equal 16, s1.length
+ assert_match(/^[a-z0-9]+$/, s1)
+ assert_match(/^[a-z0-9]+$/, s2)
+ end
end
diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb
index c6dc2aceaa7a8..ff4170dd82875 100644
--- a/activesupport/test/core_ext/time_with_zone_test.rb
+++ b/activesupport/test/core_ext/time_with_zone_test.rb
@@ -101,7 +101,7 @@ def test_nsec
with_zone = ActiveSupport::TimeWithZone.new(nil, ActiveSupport::TimeZone["Hawaii"], local)
assert_equal local.nsec, with_zone.nsec
- assert_equal with_zone.nsec, 999999999
+ assert_equal 999999999, with_zone.nsec
end
def test_strftime
diff --git a/activesupport/test/deep_mergeable_test.rb b/activesupport/test/deep_mergeable_test.rb
new file mode 100644
index 0000000000000..521ce61e6c472
--- /dev/null
+++ b/activesupport/test/deep_mergeable_test.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require_relative "abstract_unit"
+
+class DeepMergeableTest < ActiveSupport::TestCase
+ Wrapper = Struct.new(:underlying) do
+ include ActiveSupport::DeepMergeable
+
+ class << self
+ remove_method :[]
+
+ def [](value)
+ if value.is_a?(Hash)
+ self.new(value.transform_values { |value| self[value] })
+ else
+ value
+ end
+ end
+ end
+
+ delegate :[], to: :underlying
+
+ def merge!(other, &block)
+ self.underlying = underlying.merge(other.underlying, &block)
+ self
+ end
+ end
+
+ SubWrapper = Class.new(Wrapper)
+
+ OtherWrapper = Wrapper.dup
+
+ OmniWrapper = Class.new(Wrapper) do
+ def deep_merge?(other)
+ super || other.is_a?(OtherWrapper)
+ end
+ end
+
+ setup do
+ @hash_1 = { a: 1, b: 1, c: { d1: 1, d2: 1, d3: { e1: 1, e3: 1 } } }
+ @hash_2 = { a: 2, c: { d2: 2, d3: { e2: 2, e3: 2 } } }
+ @merged = { a: 2, b: 1, c: { d1: 1, d2: 2, d3: { e1: 1, e2: 2, e3: 2 } } }
+ @summed = { a: 3, b: 1, c: { d1: 1, d2: 3, d3: { e1: 1, e2: 2, e3: 3 } } }
+
+ @nested_value_key = :c
+ @sum_values = -> (key, value_1, value_2) { value_1 + value_2 }
+ end
+
+ test "deep_merge works" do
+ assert_equal Wrapper[@merged], Wrapper[@hash_1].deep_merge(Wrapper[@hash_2])
+ end
+
+ test "deep_merge! works" do
+ assert_equal Wrapper[@merged], Wrapper[@hash_1].deep_merge!(Wrapper[@hash_2])
+ end
+
+ test "deep_merge supports a merge block" do
+ assert_equal Wrapper[@summed], Wrapper[@hash_1].deep_merge(Wrapper[@hash_2], &@sum_values)
+ end
+
+ test "deep_merge! supports a merge block" do
+ assert_equal Wrapper[@summed], Wrapper[@hash_1].deep_merge!(Wrapper[@hash_2], &@sum_values)
+ end
+
+ test "deep_merge does not mutate the instance" do
+ instance = Wrapper[@hash_1.dup]
+ instance.deep_merge(Wrapper[@hash_2])
+ assert_equal Wrapper[@hash_1], instance
+ end
+
+ test "deep_merge! mutates the instance" do
+ instance = Wrapper[@hash_1]
+ instance.deep_merge!(Wrapper[@hash_2])
+ assert_equal Wrapper[@merged], instance
+ end
+
+ test "deep_merge! does not mutate the underlying values" do
+ instance = Wrapper[@hash_1.dup]
+ underlying = instance.underlying
+ instance.deep_merge!(Wrapper[@hash_2])
+ assert_equal Wrapper[@hash_1].underlying, underlying
+ end
+
+ test "deep_merge deep merges subclass values by default" do
+ nested_value = Wrapper[@hash_1].deep_merge(SubWrapper[@hash_2])[@nested_value_key]
+ assert_equal Wrapper[@merged][@nested_value_key], nested_value
+ end
+
+ test "deep_merge does not deep merge non-subclass values by default" do
+ nested_value = Wrapper[@hash_1].deep_merge(OtherWrapper[@hash_2])[@nested_value_key]
+ assert_equal OtherWrapper[@hash_2][@nested_value_key], nested_value
+ end
+
+ test "deep_merge? can be overridden to allow deep merging of non-subclass values" do
+ nested_value = OmniWrapper[@hash_1].deep_merge(OtherWrapper[@hash_2])[@nested_value_key]
+ assert_equal OmniWrapper[@merged][@nested_value_key], nested_value
+ end
+end
diff --git a/activesupport/test/deprecation_test.rb b/activesupport/test/deprecation_test.rb
index a08509b895ee2..755e1371adbf2 100644
--- a/activesupport/test/deprecation_test.rb
+++ b/activesupport/test/deprecation_test.rb
@@ -581,7 +581,7 @@ def method
end
test "disallowed_warnings is empty by default" do
- assert_equal @deprecator.disallowed_warnings, []
+ assert_equal [], @deprecator.disallowed_warnings
end
test "disallowed_warnings can be configured" do
diff --git a/activesupport/test/encrypted_configuration_test.rb b/activesupport/test/encrypted_configuration_test.rb
index b6ccd94213b02..f0843496ed114 100644
--- a/activesupport/test/encrypted_configuration_test.rb
+++ b/activesupport/test/encrypted_configuration_test.rb
@@ -53,7 +53,7 @@ class EncryptedConfigurationTest < ActiveSupport::TestCase
test "reading comment-only configuration" do
@credentials.write("# comment")
- assert_equal @credentials.config, {}
+ assert_equal({}, @credentials.config)
end
test "writing with element assignment and reading with element reference" do
diff --git a/activesupport/test/logger_test.rb b/activesupport/test/logger_test.rb
index c0f831ddee8fd..f9849aae3db34 100644
--- a/activesupport/test/logger_test.rb
+++ b/activesupport/test/logger_test.rb
@@ -176,13 +176,13 @@ def test_logger_silencing_works_for_broadcast
another_output = StringIO.new
another_logger = ActiveSupport::Logger.new(another_output)
- @logger.extend ActiveSupport::Logger.broadcast(another_logger)
+ logger = ActiveSupport::BroadcastLogger.new(@logger, another_logger)
- @logger.debug "CORRECT DEBUG"
- @logger.silence do |logger|
- assert_kind_of ActiveSupport::Logger, logger
- @logger.debug "FAILURE"
- @logger.error "CORRECT ERROR"
+ logger.debug "CORRECT DEBUG"
+ logger.silence do |logger|
+ assert_kind_of ActiveSupport::BroadcastLogger, logger
+ logger.debug "FAILURE"
+ logger.error "CORRECT ERROR"
end
assert_includes @output.string, "CORRECT DEBUG"
@@ -198,13 +198,13 @@ def test_broadcast_silencing_does_not_break_plain_ruby_logger
another_output = StringIO.new
another_logger = ::Logger.new(another_output)
- @logger.extend ActiveSupport::Logger.broadcast(another_logger)
+ logger = ActiveSupport::BroadcastLogger.new(@logger, another_logger)
- @logger.debug "CORRECT DEBUG"
- @logger.silence do |logger|
- assert_kind_of ActiveSupport::Logger, logger
- @logger.debug "FAILURE"
- @logger.error "CORRECT ERROR"
+ logger.debug "CORRECT DEBUG"
+ logger.silence do |logger|
+ assert_kind_of ActiveSupport::BroadcastLogger, logger
+ logger.debug "FAILURE"
+ logger.error "CORRECT ERROR"
end
assert_includes @output.string, "CORRECT DEBUG"
diff --git a/activesupport/test/messages/message_verifier_metadata_test.rb b/activesupport/test/messages/message_verifier_metadata_test.rb
index d807948bcbc9c..80b62d3e012a3 100644
--- a/activesupport/test/messages/message_verifier_metadata_test.rb
+++ b/activesupport/test/messages/message_verifier_metadata_test.rb
@@ -86,6 +86,20 @@ class MessageVerifierMetadataTest < ActiveSupport::TestCase
end
end
+ test "messages keep the old format when use_message_serializer_for_metadata is false" do
+ # Message generated by Rails 7.0 using:
+ #
+ # verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON)
+ # legacy_message = verifier.generate("legacy", purpose: "test")
+ legacy_message = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IklteGxaMkZqZVNJPSIsImV4cCI6bnVsbCwicHVyIjoidGVzdCJ9fQ==--53b1fc02f5b89b2da8c6ce94efa95f5cb656d975"
+
+ verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON)
+
+ using_message_serializer_for_metadata(false) do
+ assert_equal legacy_message, verifier.generate("legacy", purpose: "test")
+ end
+ end
+
private
def make_codec(**options)
ActiveSupport::MessageVerifier.new("secret", **options)
diff --git a/activesupport/test/tagged_logging_test.rb b/activesupport/test/tagged_logging_test.rb
index 64d84fdb2c2da..1f5dcd64c9b80 100644
--- a/activesupport/test/tagged_logging_test.rb
+++ b/activesupport/test/tagged_logging_test.rb
@@ -222,10 +222,10 @@ class TaggedLoggingWithoutBlockTest < ActiveSupport::TestCase
test "keeps broadcasting functionality" do
broadcast_output = StringIO.new
- broadcast_logger = ActiveSupport::TaggedLogging.new(Logger.new(broadcast_output))
- @logger.extend(ActiveSupport::Logger.broadcast(broadcast_logger))
+ broadcast_logger = ActiveSupport::BroadcastLogger.new(Logger.new(broadcast_output), @logger)
+ logger_with_tags = ActiveSupport::TaggedLogging.new(broadcast_logger)
- tagged_logger = @logger.tagged("OMG")
+ tagged_logger = logger_with_tags.tagged("OMG")
tagged_logger.info "Broadcasting..."
assert_equal "[OMG] Broadcasting...\n", @output.string
diff --git a/activesupport/test/test_case_test.rb b/activesupport/test/test_case_test.rb
index a7b17c0aa71d9..5a30e9d887360 100644
--- a/activesupport/test/test_case_test.rb
+++ b/activesupport/test/test_case_test.rb
@@ -93,7 +93,7 @@ def test_assert_difference_retval
@object.increment
end
- assert_equal incremented, 1
+ assert_equal 1, incremented
end
def test_assert_difference_with_implicit_difference
diff --git a/guides/CHANGELOG.md b/guides/CHANGELOG.md
index 217ce0d6edd7a..f85e179b8b97f 100644
--- a/guides/CHANGELOG.md
+++ b/guides/CHANGELOG.md
@@ -1,5 +1,2 @@
-* Add "back to top" button to guides
- *Alysson Rosa*
-
-Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/guides/CHANGELOG.md) for previous changes.
+Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/guides/CHANGELOG.md) for previous changes.
diff --git a/guides/bug_report_templates/active_record_migrations_main.rb b/guides/bug_report_templates/active_record_migrations_main.rb
index 5836db3b7e215..49f7272447b3b 100644
--- a/guides/bug_report_templates/active_record_migrations_main.rb
+++ b/guides/bug_report_templates/active_record_migrations_main.rb
@@ -28,7 +28,7 @@
class Payment < ActiveRecord::Base
end
-class ChangeAmountToAddScale < ActiveRecord::Migration[7.1]
+class ChangeAmountToAddScale < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
diff --git a/guides/source/7_0_release_notes.md b/guides/source/7_0_release_notes.md
index 24519027aef75..32eaf303c333f 100644
--- a/guides/source/7_0_release_notes.md
+++ b/guides/source/7_0_release_notes.md
@@ -96,6 +96,7 @@ Please refer to the [Changelog][action-view] for detailed changes.
#=>
# After
#=>
+ ```
Action Mailer
-------------
diff --git a/guides/source/7_1_release_notes.md b/guides/source/7_1_release_notes.md
index 343cd992a26ee..0c940c4bd9653 100644
--- a/guides/source/7_1_release_notes.md
+++ b/guides/source/7_1_release_notes.md
@@ -33,24 +33,24 @@ meant for development purposes.
Here's a quick example of how to build and run your Rails app using these Docker files:
```bash
-docker build -t app .
-docker volume create app-storage
-docker run --rm -it -v app-storage:/rails/storage -p 3000:3000 --env RAILS_MASTER_KEY= app
+$ docker build -t app .
+$ docker volume create app-storage
+$ docker run --rm -it -v app-storage:/rails/storage -p 3000:3000 --env RAILS_MASTER_KEY= app
```
You can also start a console or runner from this Docker image:
```bash
-docker run --rm -it -v app-storage:/rails/storage --env RAILS_MASTER_KEY= app console
+$ docker run --rm -it -v app-storage:/rails/storage --env RAILS_MASTER_KEY= app console
```
For those looking to create a multi-platform image (e.g., Apple Silicon for AMD or Intel deployment),
and push it to Docker Hub, follow these steps:
```bash
-docker login -u
-docker buildx create --use
-docker buildx build --push --platform=linux/amd64,linux/arm64 -t .
+$ docker login -u
+$ docker buildx create --use
+$ docker buildx build --push --platform=linux/amd64,linux/arm64 -t .
```
This enhancement simplifies the deployment process, providing a convenient starting point for
@@ -58,10 +58,11 @@ getting your Rails application up and running in a production environment.
### Add `ActiveRecord::Base.normalizes`
-Normalizations can be declared for attribute values. The normalization
-takes place when the attribute is assigned or updated, and will be persisted to the database.
-Normalization is also applied to corresponding keyword arguments in finder methods,
-allowing records to be queried using unnormalized values.
+[`ActiveRecord::Base.normalizes`][] declares an attribute normalization. The
+normalization is applied when the attribute is assigned or updated, and the
+normalized value will be persisted to the database. The normalization is also
+applied to the corresponding keyword argument of query methods, allowing records
+to be queried using unnormalized values.
For example:
@@ -78,19 +79,53 @@ user = User.find_by(email: "\tCRUISE-CONTROL@EXAMPLE.COM ")
user.email # => "cruise-control@example.com"
user.email_before_type_cast # => "cruise-control@example.com"
+User.where(email: "\tCRUISE-CONTROL@EXAMPLE.COM ").count # => 1
+User.where(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]).count # => 0
+
User.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
-User.normalize(:phone, "+1 (555) 867-5309") # => "5558675309"
+User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"
```
+[`ActiveRecord::Base.normalizes`]: https://api.rubyonrails.org/v7.1/classes/ActiveRecord/Normalization/ClassMethods.html#method-i-normalizes
+
### Add `ActiveRecord::Base.generates_token_for`
-TODO: Add description https://github.com/rails/rails/pull/44189
+[`ActiveRecord::Base.generates_token_for`][] defines the generation of tokens
+for a specific purpose. Generated tokens can expire and can also embed record
+data. When using a token to fetch a record, the data from the token and the
+current data from the record will be compared. If the two do not match, the
+token will be treated as invalid, the same as if it had expired.
+
+Here is an example implementing a single-use password reset token:
+
+```ruby
+class User < ActiveRecord::Base
+ has_secure_password
+
+ generates_token_for :password_reset, expires_in: 15.minutes do
+ # `password_salt` (defined by `has_secure_password`) returns the salt for
+ # the password. The salt changes when the password is changed, so the token
+ # will expire when the password is changed.
+ password_salt&.last(10)
+ end
+end
+
+user = User.first
+token = user.generate_token_for(:password_reset)
+
+User.find_by_token_for(:password_reset, token) # => user
+
+user.update!(password: "new password")
+User.find_by_token_for(:password_reset, token) # => nil
+```
+
+[`ActiveRecord::Base.generates_token_for`]: https://api.rubyonrails.org/v7.1/classes/ActiveRecord/TokenFor/ClassMethods.html#method-i-generates_token_for
### Add `perform_all_later` to enqueue multiple jobs at once
-The [`perform_all_later` method in ActiveJob](https://github.com/rails/rails/pull/46603),
+The [`perform_all_later` method in Active Job](https://github.com/rails/rails/pull/46603),
designed to streamline the process of enqueuing multiple jobs simultaneously. This powerful
addition allows you to efficiently enqueue jobs without triggering callbacks. This is
particularly useful when you need to enqueue a batch of jobs at once, reducing the overhead
@@ -118,7 +153,28 @@ and reporting of the bulk enqueuing process.
### Composite primary keys
-TODO: Add description
+Composite primary keys are now supported at both the database and application level. Rails is able to derive these keys directly from the schema. This feature is particularly beneficial for many-to-many relationships and other complex data models where a single column is insufficient to uniquely identify a record.
+
+The SQL generated by query methods in Active Record (e.g. `#reload`, `#update`, `#delete`) will contain all parts of the composite primary key. Methods like `#first` and `#last` will use the full composite primary key in the `ORDER BY` statements.
+
+The `query_constraints` macro can be used as a "virtual primary key" to achieve the same behavior without modifying the database schema.
+Example:
+
+```ruby
+class TravelRoute < ActiveRecord::Base
+ query_constraints :origin, :destination
+end
+```
+
+Similarly, associations accept a `query_constraints:` option. This option serves as a composite foreign key, configuring the list of columns used for accessing the associated record.
+
+Example:
+
+```ruby
+class TravelRouteReview < ActiveRecord::Base
+ belongs_to :travel_route, query_constraints: [:travel_route_origin, :travel_route_destination]
+end
+```
### Introduce adapter for `Trilogy`
@@ -142,7 +198,43 @@ ENV['DATABASE_URL'] # => "trilogy://localhost/blog_development?pool=5"
### Add `ActiveSupport::MessagePack`
-TODO: Add description https://github.com/rails/rails/pull/47770
+[`ActiveSupport::MessagePack`][] is a serializer that integrates with the
+[`msgpack` gem][]. `ActiveSupport::MessagePack` can serialize the basic Ruby
+types supported by `msgpack`, as well as several additional types such as `Time`,
+`ActiveSupport::TimeWithZone`, and `ActiveSupport::HashWithIndifferentAccess`.
+Compared to `JSON` and `Marshal`, `ActiveSupport::MessagePack` can reduce
+payload size and improve performance.
+
+`ActiveSupport::MessagePack` can be used as a [message serializer](
+https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer):
+
+```ruby
+config.active_support.message_serializer = :message_pack
+
+# Or individually:
+ActiveSupport::MessageEncryptor.new(secret, serializer: :message_pack)
+ActiveSupport::MessageVerifier.new(secret, serializer: :message_pack)
+```
+
+As the [cookies serializer](
+https://guides.rubyonrails.org/v7.1/configuring.html#config-action-dispatch-cookies-serializer):
+
+```ruby
+config.action_dispatch.cookies_serializer = :message_pack
+```
+
+And as a [cache serializer](
+https://guides.rubyonrails.org/v7.1/caching_with_rails.html#configuration):
+
+```ruby
+config.cache_store = :file_store, "tmp/cache", { serializer: :message_pack }
+
+# Or individually:
+ActiveSupport::Cache.lookup_store(:file_store, "tmp/cache", serializer: :message_pack)
+```
+
+[`ActiveSupport::MessagePack`]: https://api.rubyonrails.org/v7.1/classes/ActiveSupport/MessagePack.html
+[`msgpack` gem]: https://github.com/msgpack/msgpack-ruby
### Introducing `config.autoload_lib` and `config.autoload_lib_once` for Enhanced Autoloading
@@ -170,11 +262,67 @@ Please, see more details in the [autoloading guide](autoloading_and_reloading_co
### Active Record API for general async queries
-TODO: Add description https://github.com/rails/rails/pull/44446
+A significant enhancement has been introduced to the Active Record API, expanding its
+[support for asynchronous queries](https://github.com/rails/rails/pull/44446). This enhancement
+addresses the need for more efficient handling of not-so-fast queries, particularly focusing on
+aggregates (such as `count`, `sum`, etc.) and all methods returning a single record or anything
+other than a `Relation`.
+
+The new API includes the following asynchronous methods:
+
+- `async_count`
+- `async_sum`
+- `async_minimum`
+- `async_maximum`
+- `async_average`
+- `async_pluck`
+- `async_pick`
+- `async_ids`
+- `async_find_by_sql`
+- `async_count_by_sql`
+
+Here's a brief example of how to use one of these methods, `async_count`, to count the number of published
+posts in an asynchronous manner:
+
+```ruby
+# Synchronous count
+published_count = Post.where(published: true).count # => 10
+
+# Asynchronous count
+promise = Post.where(published: true).async_count # => #
+promise.value # => 10
+```
+
+These methods allow for the execution of these operations in an asynchronous manner, which can significantly
+improve performance for certain types of database queries.
### Allow templates to set strict `locals`.
-TODO: https://github.com/rails/rails/pull/45602
+Introduce a new feature that [allows templates to set explicit `locals`](https://github.com/rails/rails/pull/45602).
+This enhancement provides greater control and clarity when passing variables to your templates.
+
+By default, templates will accept any `locals` as keyword arguments. However, now you can define what `locals` a
+template should accept by adding a `locals` magic comment at the beginning of your template file.
+
+Here's how it works:
+
+```erb
+<%# locals: (message:) -%>
+<%= message %>
+```
+
+You can also set default values for these locals:
+
+```erb
+<%# locals: (message: "Hello, world!") -%>
+<%= message %>
+```
+
+If you want to disable the use of locals entirely, you can do so like this:
+
+```erb
+<%# locals: () %>
+```
### Add `Rails.application.deprecators`
@@ -192,11 +340,11 @@ The collection's configuration settings affect all deprecators in the collection
```ruby
Rails.application.deprecators.debug = true
-puts Rails.application.deprecators[:my_gem].debug
-# true
+Rails.application.deprecators[:my_gem].debug
+# => true
-puts Rails.application.deprecators[:other_gem].debug
-# true
+Rails.application.deprecators[:other_gem].debug
+# => true
```
There are scenarios where you might want to mute all deprecator warnings for a specific block of code.
@@ -275,6 +423,62 @@ assert_pattern { html.at("main") => { children: [{ name: "h1", content: /content
[nokogiri-pattern-matching]: https://nokogiri.org/rdoc/Nokogiri/XML/Attr.html#method-i-deconstruct_keys
[minitest-pattern-matching]: https://docs.seattlerb.org/minitest/Minitest/Assertions.html#method-i-assert_pattern
+### Introduce `ActionView::TestCase.register_parser`
+
+[Extend `ActionView::TestCase`][#49194] to support parsing content rendered by
+view partials into known structures. By default, define `rendered_html` to parse
+HTML into a `Nokogiri::XML::Node` and `rendered_json` to parse JSON into an
+`ActiveSupport::HashWithIndifferentAccess`:
+
+```ruby
+test "renders HTML" do
+ article = Article.create!(title: "Hello, world")
+
+ render partial: "articles/article", locals: { article: article }
+
+ assert_pattern { rendered_html.at("main h1") => { content: "Hello, world" } }
+end
+
+test "renders JSON" do
+ article = Article.create!(title: "Hello, world")
+
+ render formats: :json, partial: "articles/article", locals: { article: article }
+
+ assert_pattern { rendered_json => { title: "Hello, world" } }
+end
+```
+
+To parse the rendered content into RSS, register a call to `RSS::Parser.parse`:
+
+```ruby
+register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }
+
+test "renders RSS" do
+ article = Article.create!(title: "Hello, world")
+
+ render formats: :rss, partial: article, locals: { article: article }
+
+ assert_equal "Hello, world", rendered_rss.items.last.title
+end
+```
+
+To parse the rendered content into a Capybara::Simple::Node, re-register an
+`:html` parser with a call to `Capybara.string`:
+
+```ruby
+register_parser :html, -> rendered { Capybara.string(rendered) }
+
+test "renders HTML" do
+ article = Article.create!(title: "Hello, world")
+
+ render partial: article
+
+ rendered_html.assert_css "main h1", text: "Hello, world"
+end
+```
+
+[#49194]: https://github.com/rails/rails/pull/49194
+
Railties
--------
@@ -288,11 +492,11 @@ Please refer to the [Changelog][railties] for detailed changes.
### Deprecations
-* Deprecated usage of `Rails.application.secrets`.
+* Deprecate usage of `Rails.application.secrets`.
-* Deprecated `secrets:show` and `secrets:edit` commands in favor of `credentials`.
+* Deprecate `secrets:show` and `secrets:edit` commands in favor of `credentials`.
-* Deprecated `Rails::Generators::Testing::Behaviour` in favor of `Rails::Generators::Testing::Behavior`.
+* Deprecate `Rails::Generators::Testing::Behaviour` in favor of `Rails::Generators::Testing::Behavior`.
### Notable changes
@@ -303,6 +507,14 @@ Please refer to the [Changelog][railties] for detailed changes.
* Add `DATABASE` option that enables the specification of the target database when executing the
`rails railties:install:migrations` command to copy migrations.
+* Add support for Bun in `rails new --javascript` generator.
+
+ ```bash
+ $ rails new my_new_app --javascript=bun
+ ```
+
+* Add ability to show slow tests to the test runner.
+
Action Cable
------------
@@ -337,12 +549,14 @@ Please refer to the [Changelog][action-pack] for detailed changes.
* Deprecate `config.action_dispatch.return_only_request_media_type_on_content_type`.
-* Deprecate `AbstractController::Helpers::MissingHelperError`
+* Deprecate `AbstractController::Helpers::MissingHelperError`.
* Deprecate `ActionDispatch::IllegalStateError`.
* Deprecate `speaker`, `vibrate`, and `vr` permissions policy directives.
+* Deprecate `true` and `false` values for `config.action_dispatch.show_exceptions` in favor of `:all`, `:rescuable`, or `:none`.
+
### Notable changes
* Add `exclude?` method to `ActionController::Parameters`. It is the inverse of `include?` method.
@@ -376,8 +590,8 @@ Please refer to the [Changelog][action-view] for detailed changes.
options for the sanitize process.
```ruby
- simple_format("Continue", {}, { sanitize_options: { attributes: %w[target href] } })
- # => "
"
```
Action Mailer
@@ -389,9 +603,9 @@ Please refer to the [Changelog][action-mailer] for detailed changes.
### Deprecations
-* Deprecated `config.action_mailer.preview_path`.
+* Deprecate `config.action_mailer.preview_path`.
-* Deprecated passing params to `assert_enqueued_email_with` via the `:args` kwarg.
+* Deprecate passing params to `assert_enqueued_email_with` via the `:args` kwarg.
Now supports a `:params` kwarg, so use that to pass params.
### Notable changes
@@ -402,7 +616,6 @@ Please refer to the [Changelog][action-mailer] for detailed changes.
* Add `deliver_enqueued_emails` to `ActionMailer::TestHelper` to deliver all enqueued email jobs.
-
Active Record
-------------
@@ -443,10 +656,47 @@ Please refer to the [Changelog][active-record] for detailed changes.
* Deprecate `read_attribute(:id)` returning the primary key if the primary key is not `:id`.
+* Deprecate `rewhere` argument on `#merge`.
+
### Notable changes
* Add `TestFixtures#fixture_paths` to support multiple fixture paths.
+* Add `authenticate_by` when using `has_secure_password`.
+
+* Add `update_attribute!` to `ActiveRecord::Persistence`, which is similar to `update_attribute`
+ but raises `ActiveRecord::RecordNotSaved` when a `before_*` callback throws `:abort`.
+
+* Allow using aliased attributes with `insert_all`/`upsert_all`.
+
+* Add `:include` option to `add_index`.
+
+* Add `#regroup` query method as a short-hand for `.unscope(:group).group(fields)`.
+
+* Add support for generated columns, deferred foreign keys, auto-populated columns,
+ and custom primary keys to the `SQLite3` adapter.
+
+* Add modern, performant defaults for `SQLite3` database connections.
+
+* Allow specifying where clauses with column-tuple syntax.
+
+ ```ruby
+ Topic.where([:title, :author_name] => [["The Alchemist", "Paulo Coelho"], ["Harry Potter", "J.K Rowling"]])
+ ```
+
+* Auto generated index names are now limited to 62 bytes, which fits within the default
+ index name length limits for MySQL, Postgres and SQLite.
+
+* Introduce adapter for Trilogy database client.
+
+* Add `ActiveRecord.disconnect_all!` method to immediately close all connections from all pools.
+
+* Add PostgreSQL migration commands for enum rename, add value, and rename value.
+
+* Add `ActiveRecord::Base#id_value` alias to access the raw value of a record's id column.
+
+* Add validation option for `enum`.
+
Active Storage
--------------
@@ -491,6 +741,35 @@ Please refer to the [Changelog][active-model] for detailed changes.
### Notable changes
+* Add support for infinite ranges to `LengthValidator`s `:in`/`:within` options.
+
+ ```ruby
+ validates_length_of :first_name, in: ..30
+ ```
+
+* Add support for beginless ranges to `inclusivity/exclusivity` validators.
+
+ ```ruby
+ validates_inclusion_of :birth_date, in: -> { (..Date.today) }
+ ```
+
+ ```ruby
+ validates_exclusion_of :birth_date, in: -> { (..Date.today) }
+ ```
+
+* Add support for password challenges to `has_secure_password`. When set, validate that the password
+ challenge matches the persisted `password_digest`.
+
+* Allow validators to accept lambdas without record argument.
+
+ ```ruby
+ # Before
+ validates_comparison_of :birth_date, less_than_or_equal_to: ->(_record) { Date.today }
+
+ # After
+ validates_comparison_of :birth_date, less_than_or_equal_to: -> { Date.today }
+ ```
+
Active Support
--------------
@@ -524,6 +803,14 @@ Please refer to the [Changelog][active-support] for detailed changes.
* Deprecate `config.active_support.use_rfc4122_namespaced_uuids`.
+* Deprecate `SafeBuffer#clone_empty`.
+
+* Deprecate usage of the singleton `ActiveSupport::Deprecation`.
+
+* Deprecate initializing a `ActiveSupport::Cache::MemCacheStore` with an instance of `Dalli::Client`.
+
+* Deprecate `Notification::Event`'s `#children` and `#parent_of?` methods.
+
### Notable changes
Active Job
diff --git a/guides/source/7_2_release_notes.md b/guides/source/7_2_release_notes.md
new file mode 100644
index 0000000000000..98d3a44464fe5
--- /dev/null
+++ b/guides/source/7_2_release_notes.md
@@ -0,0 +1,88 @@
+**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON https://guides.rubyonrails.org.**
+
+Ruby on Rails 7.2 Release Notes
+===============================
+
+Highlights in Rails 7.2:
+
+--------------------------------------------------------------------------------
+
+Upgrading to Rails 7.1
+----------------------
+
+If you're upgrading an existing application, it's a great idea to have good test
+coverage before going in. You should also first upgrade to Rails 7.1 in case you
+haven't and make sure your application still runs as expected before attempting
+an update to Rails 7.2. A list of things to watch out for when upgrading is
+available in the
+[Upgrading Ruby on Rails](upgrading_ruby_on_rails.html#upgrading-from-rails-7-1-to-rails-7-2)
+guide.
+
+Major Features
+--------------
+
+
+Railties
+--------
+
+Action Cable
+------------
+
+Action Pack
+-----------
+
+Action View
+-----------
+
+Action Mailer
+-------------
+
+Active Record
+-------------
+
+Active Storage
+--------------
+
+Active Model
+------------
+
+Active Support
+--------------
+
+Active Job
+----------
+
+Action Text
+----------
+
+Action Mailbox
+----------
+
+Ruby on Rails Guides
+--------------------
+
+Please refer to the [Changelog][guides] for detailed changes.
+
+### Notable changes
+
+Credits
+-------
+
+See the
+[full list of contributors to Rails](https://contributors.rubyonrails.org/)
+for the many people who spent many hours making Rails, the stable and robust
+framework it is. Kudos to all of them.
+
+[railties]: https://github.com/rails/rails/blob/main/railties/CHANGELOG.md
+[action-pack]: https://github.com/rails/rails/blob/main/actionpack/CHANGELOG.md
+[action-view]: https://github.com/rails/rails/blob/main/actionview/CHANGELOG.md
+[action-mailer]: https://github.com/rails/rails/blob/main/actionmailer/CHANGELOG.md
+[action-cable]: https://github.com/rails/rails/blob/main/actioncable/CHANGELOG.md
+[active-record]: https://github.com/rails/rails/blob/main/activerecord/CHANGELOG.md
+[active-storage]: https://github.com/rails/rails/blob/main/activestorage/CHANGELOG.md
+[active-model]: https://github.com/rails/rails/blob/main/activemodel/CHANGELOG.md
+[active-support]: https://github.com/rails/rails/blob/main/activesupport/CHANGELOG.md
+[active-job]: https://github.com/rails/rails/blob/main/activejob/CHANGELOG.md
+[action-text]: https://github.com/rails/rails/blob/main/actiontext/CHANGELOG.md
+[action-mailbox]: https://github.com/rails/rails/blob/main/actionmailbox/CHANGELOG.md
+[guides]: https://github.com/rails/rails/blob/main/guides/CHANGELOG.md
diff --git a/guides/source/_welcome.html.erb b/guides/source/_welcome.html.erb
index 8325b4cb60ac6..2a9d5490e7390 100644
--- a/guides/source/_welcome.html.erb
+++ b/guides/source/_welcome.html.erb
@@ -10,12 +10,13 @@
<% else %>
- These are the new guides for Rails 7.1 based on <%= @version %>.
+ These are the new guides for Rails 7.2 based on <%= @version %>.
These guides are designed to make you immediately productive with Rails, and to help you understand how all of the pieces fit together.
<% end %>
The guides for earlier releases:
+Rails 7.1,
Rails 7.0,
Rails 6.1,
Rails 6.0,
diff --git a/guides/source/action_controller_overview.md b/guides/source/action_controller_overview.md
index c31110fc2dbb7..a7b9ee4244584 100644
--- a/guides/source/action_controller_overview.md
+++ b/guides/source/action_controller_overview.md
@@ -186,6 +186,34 @@ In this case, when a user opens the URL `/clients/active`, `params[:status]` wil
[`controller_name`]: https://api.rubyonrails.org/classes/ActionController/Metal.html#method-i-controller_name
[`action_name`]: https://api.rubyonrails.org/classes/AbstractController/Base.html#method-i-action_name
+### Composite Key Parameters
+
+Composite key parameters contain multiple values in one parameter. For this reason, we need to be able to extract each value and pass them to Active Record. We can leverage the `extract_value` method for this use-case.
+
+Given the following controller:
+
+```ruby
+class BooksController < ApplicationController
+ def show
+ # Extract the composite ID value from URL parameters.
+ id = params.extract_value(:id)
+ # Find the book using the composite ID.
+ @book = Book.find(id)
+ # use the default rendering behaviour to render the show view.
+ end
+end
+```
+
+And the following route:
+
+```ruby
+get '/books/:id', to: 'books#show'
+```
+
+When a user opens the URL `/books/4_2`, the controller will extract the composite
+key value `["4", "2"]` and pass it to `Book.find` to render the right record in the view.
+The `extract_value` method may be used to extract arrays out of any delimited parameters.
+
### `default_url_options`
You can set global default parameters for URL generation by defining a method called `default_url_options` in your controller. Such a method must return a hash with the desired defaults, whose keys must be symbols:
diff --git a/guides/source/action_mailer_basics.md b/guides/source/action_mailer_basics.md
index 796eff175fdc3..dca63d10e5771 100644
--- a/guides/source/action_mailer_basics.md
+++ b/guides/source/action_mailer_basics.md
@@ -777,7 +777,38 @@ class UserMailer < ApplicationMailer
end
```
-* You could use an `after_delivery` to record the delivery of the message.
+* You could use an `after_deliver` to record the delivery of the message. It
+ also allows observer/interceptor-like behaviors, but with access to the full
+ mailer context.
+
+```ruby
+class UserMailer < ApplicationMailer
+ after_deliver :mark_delivered
+ before_deliver :sandbox_staging
+ after_deliver :observe_delivery
+
+ def feedback_message
+ @feedback = params[:feedback]
+ end
+
+ private
+ def mark_delivered
+ params[:feedback].touch(:delivered_at)
+ end
+
+ # An Interceptor alternative.
+ def sandbox_staging
+ message.to = ['sandbox@example.com'] if Rails.env.staging?
+ end
+
+ # A callback has more context than the comparable Observer example.
+ def observe_delivery
+ EmailDelivery.log(message, self.class, action_name, params)
+ end
+end
+```
+
+
* Mailer callbacks abort further processing if body is set to a non-nil value. `before_deliver` can abort with `throw :abort`.
diff --git a/guides/source/action_view_helpers.md b/guides/source/action_view_helpers.md
index 41666cbdb04d0..3bd236a584858 100644
--- a/guides/source/action_view_helpers.md
+++ b/guides/source/action_view_helpers.md
@@ -192,7 +192,7 @@ A method for caching fragments of a view rather than an entire action or page. T
```erb
<% cache do %>
- <%= render "shared/footer" %>
+ <%= render "application/footer" %>
<% end %>
```
@@ -468,6 +468,9 @@ url_for @profile
url_for [ @hotel, @booking, page: 2, line: 3 ]
# => /hotels/1/bookings/1?line=3&page=2
+
+url_for @post # given a composite primary key [:blog_id, :id]
+# => /posts/1_2
```
#### link_to
@@ -481,6 +484,9 @@ when passing models to `link_to`.
```ruby
link_to "Profile", @profile
# => Profile
+
+link_to "Book", @book # given a composite primary key [:author_id, :id]
+# => Book
```
You can use a block as well if your link target can't fit in the name parameter. ERB example:
diff --git a/guides/source/action_view_overview.md b/guides/source/action_view_overview.md
index fc2086894ad84..9da2576a22174 100644
--- a/guides/source/action_view_overview.md
+++ b/guides/source/action_view_overview.md
@@ -197,17 +197,17 @@ To render a partial as part of a view, you use the `render` method within the vi
This will render a file named `_menu.html.erb` at that point within the view that is being rendered. Note the leading underscore character: partials are named with a leading underscore to distinguish them from regular views, even though they are referred to without the underscore. This holds true even when you're pulling in a partial from another folder:
```erb
-<%= render "shared/menu" %>
+<%= render "application/menu" %>
```
-That code will pull in the partial from `app/views/shared/_menu.html.erb`.
+That code will pull in the partial from `app/views/application/_menu.html.erb`.
### Using Partials to Simplify Views
One way to use partials is to treat them as the equivalent of subroutines; a way to move details out of a view so that you can grasp what's going on more easily. For example, you might have a view that looks like this:
```html+erb
-<%= render "shared/ad_banner" %>
+<%= render "application/ad_banner" %>
Products
@@ -216,11 +216,138 @@ One way to use partials is to treat them as the equivalent of subroutines; a way
<%= render partial: "product", locals: { product: product } %>
<% end %>
-<%= render "shared/footer" %>
+<%= render "application/footer" %>
```
Here, the `_ad_banner.html.erb` and `_footer.html.erb` partials could contain content that is shared among many pages in your application. You don't need to see the details of these sections when you're concentrating on a particular page.
+TIP: View partials rely on the same [Template
+Inheritance](/layouts_and_rendering.html#template-inheritance) as templates and
+layouts, so templates rendered by controllers that inherit from
+`ApplicationController` can render view partials declared in
+`app/views/application`.
+
+In addition to resolving partials with the inheritance chain, controllers can
+also override default partials with the inheritance chain. For example, a
+`ProductsController` that inherits from `ApplicationController` will resolve a
+call to `<%= render "ad_banner" %>` by first searching for
+`app/views/products/_ad_banner.html.erb` before falling back to
+`app/views/application/_ad_banner.html.erb`.
+
+### `render` with `locals` Option
+
+When rendering a partial, each key in the `locals:` option is available as a
+partial-local variable:
+
+```html+erb
+<%# app/views/products/show.html.erb %>
+
+<%= render partial: "products/product", locals: { product: @product } %>
+
+<%# app/views/products/_product.html.erb %>
+
+<%= tag.div id: dom_id(product) do %>
+
<%= product.name %>
+<% end %>
+```
+
+If a template refers to a variable that isn't passed into the view as part of
+the `locals:` option, the template will raise an `ActionView::Template::Error`:
+
+```html+erb
+<%# app/views/products/_product.html.erb %>
+
+<%= tag.div id: dom_id(product) do %>
+
<%= product.name %>
+
+ <%# => raises ActionView::Template::Error %>
+ <% related_products.each do |related_product| %>
+ <%# ... %>
+ <% end %>
+<% end %>
+```
+
+### Using `local_assigns`
+
+Each key in the `locals:` option is available as a partial-local variable through the [local_assigns][] helper method:
+
+```html+erb
+<%# app/views/products/show.html.erb %>
+
+<%= render partial: "products/product", locals: { product: @product } %>
+
+<%# app/views/products/_product.html.erb %>
+
+<% local_assigns[:product] # => "#" %>
+<% local_assigns[:options] # => nil %>
+```
+
+Since `local_assigns` is a `Hash`, it's compatible with [Ruby 3.1's pattern matching assignment operator](https://docs.ruby-lang.org/en/master/syntax/pattern_matching_rdoc.html):
+
+```ruby
+local_assigns => { product:, **options }
+product # => "#"
+options # => {}
+```
+
+When keys other than `:product` are assigned into a partial-local `Hash`
+variable, they can be splatted into helper method calls:
+
+```html+erb
+<%# app/views/products/_product.html.erb %>
+
+<% local_assigns => { product:, **options } %>
+
+<%= tag.div id: dom_id(product), **options do %>
+
+%>
+```
+
+Pattern matching assignment also supports variable renaming:
+
+```ruby
+local_assigns => { product: record }
+product # => "#"
+record # => "#"
+product == record # => true
+```
+
+Since `local_assigns` returns a `Hash` instance, you can conditionally read a variable, then fall back to a default value when the key isn't part of the `locals:` options:
+
+```html+erb
+<%# app/views/products/_product.html.erb %>
+
+<% local_assigns.fetch(:related_products, []).each do |related_product| %>
+ <%# ... %>
+<% end %>
+```
+
+Combining Ruby 3.1's pattern matching assignment with calls to [Hash#with_defaults](https://api.rubyonrails.org/classes/Hash.html#method-i-with_defaults) enables compact partial-local default variable assignments:
+
+```html+erb
+<%# app/views/products/_product.html.erb %>
+
+<% local_assigns.with_defaults(related_products: []) => { product:, related_products: } %>
+
+<%= tag.div id: dom_id(product) do %>
+
<%= product.name %>
+
+ <% related_products.each do |related_product| %>
+ <%# ... %>
+ <% end %>
+<% end %>
+```
+
+[local_assigns]: https://api.rubyonrails.org/classes/ActionView/Template.html#method-i-local_assigns
+
### `render` without `partial` and `locals` Options
In the above example, `render` takes 2 options: `partial` and `locals`. But if
diff --git a/guides/source/active_record_basics.md b/guides/source/active_record_basics.md
index 3c233ba8f3161..11c7d3b3377c0 100644
--- a/guides/source/active_record_basics.md
+++ b/guides/source/active_record_basics.md
@@ -212,7 +212,11 @@ class Product < ApplicationRecord
end
```
-NOTE: **Active Record does not support using non-primary key columns named `id`.**
+NOTE: **Active Record does not recommend using non-primary key columns named `id`.**
+Using a column named `id` which is not a single-column primary key complicates the access to the column value.
+The application will have to use the [`id_value`][] alias attribute to access the value of the non-PK `id` column.
+
+[`id_value`]: https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema.html#method-i-id_value
NOTE: If you try to create a column named `id` which is not the primary key,
Rails will throw an error during migrations such as:
@@ -363,7 +367,7 @@ irb> user = User.new
irb> user.save
=> false
irb> user.save!
-ActiveRecord::RecordInvalid: Validation failed: Name can’t be blank
+ActiveRecord::RecordInvalid: Validation failed: Name can't be blank
```
You can learn more about validations in the [Active Record Validations
@@ -406,7 +410,7 @@ in files which are executed against any database that Active Record supports.
Here's a migration that creates a new table called `publications`:
```ruby
-class CreatePublications < ActiveRecord::Migration[7.1]
+class CreatePublications < ActiveRecord::Migration[7.2]
def change
create_table :publications do |t|
t.string :title
diff --git a/guides/source/active_record_composite_primary_keys.md b/guides/source/active_record_composite_primary_keys.md
new file mode 100644
index 0000000000000..d4e681034b132
--- /dev/null
+++ b/guides/source/active_record_composite_primary_keys.md
@@ -0,0 +1,302 @@
+**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON https://guides.rubyonrails.org.**
+
+Composite Primary Keys
+======================
+
+This guide is an introduction to composite primary keys for database tables.
+
+After reading this guide you will be able to:
+
+* Create a table with a composite primary key
+* Query a model with a composite primary key
+* Enable your model to use a composite primary key for queries and associations
+* Create forms for models that use composite primary keys
+* Extract composite primary keys from controller parameters
+* Use database fixtures for tables with composite primary keys
+
+--------------------------------------------------------------------------------
+
+What are Composite Primary Keys?
+--------------------------------
+
+Sometimes a single column's value isn't enough to uniquely identify every row
+of a table, and a combination of two or more columns is required.
+This can be the case when using a legacy database schema without a single `id`
+column as a primary key, or when altering schemas for sharding or multitenancy.
+
+Composite primary keys increase complexity and can be slower than a single
+primary key column. Ensure your use-case requires a composite primary key
+before using one.
+
+
+Composite Primary Key Migrations
+--------------------------------
+
+You can create a table with a composite primary key by passing the
+`:primary_key` option to `create_table` with an array value:
+
+```ruby
+class CreateProducts < ActiveRecord::Migration[7.2]
+ def change
+ create_table :products, primary_key: [:store_id, :sku] do |t|
+ t.integer :store_id
+ t.string :sku
+ t.text :description
+ end
+ end
+end
+```
+
+Querying Models
+---------------
+
+### Using `#find`
+
+If your table uses a composite primary key, you'll need to pass an array
+when using `#find` to locate a record:
+
+```irb
+# Find the product with store_id 3 and sku "XYZ12345"
+irb> product = Product.find([3, "XYZ12345"])
+=> #
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM products WHERE store_id = 3 AND sku = "XYZ12345"
+```
+
+To find multiple records with composite IDs, pass an array of arrays to `#find`:
+
+```irb
+# Find the products with primary keys [1, "ABC98765"] and [7, "ZZZ11111"]
+irb> products = Product.find([[1, "ABC98765"], [7, "ZZZ11111"]])
+=> [
+ #,
+ #
+]
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM products WHERE (store_id = 1 AND sku = 'ABC98765' OR store_id = 7 AND sku = 'ZZZ11111')
+```
+
+Models with composite primary keys will also use the full composite primary key
+when ordering:
+
+
+```irb
+irb> product = Product.first
+=> #
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM products ORDER BY products.store_id ASC, products.sku ASC LIMIT 1
+```
+
+### Using `#where`
+
+Hash conditions for `#where` may be specified in a tuple-like syntax.
+This can be useful for querying composite primary key relations:
+
+```ruby
+Product.where(Product.primary_key => [[1, "ABC98765"], [7, "ZZZ11111"]])
+```
+
+#### Conditions with `:id`
+
+When specifying conditions on methods like `find_by` and `where`, the use
+of `id` will match against an `:id` attribute on the model. This is different
+from `find`, where the ID passed in should be a primary key value.
+
+Take caution when using `find_by(id:)` on models where `:id` is not the primary
+key, such as composite primary key models. See the [Active Record Querying][]
+guide to learn more.
+
+[Active Record Querying]: active_record_querying.html#using-id-as-a-condition
+
+Associations between Models with Composite Primary Keys
+-------------------------------------------------------
+
+Rails is often able to infer the primary key - foreign key information between
+associated models with composite primary keys without needing extra information.
+Take the following example:
+
+```ruby
+class Order < ApplicationRecord
+ self.primary_key = [:shop_id, :id]
+ has_many :books
+end
+
+class Book < ApplicationRecord
+ belongs_to :order
+end
+```
+
+Here, Rails assumes that the `:id` column should be used as the primary key for
+the association between an order and its books, just as with a regular
+`has_many` / `belongs_to` association. It will infer that the foreign key column
+on the `books` table is `:order_id`. Accessing a book's order:
+
+```ruby
+order = Order.create!(id: [1, 2], status: "pending")
+book = order.books.create!(title: "A Cool Book")
+
+book.reload.order
+```
+
+will generate the following SQL to access the order:
+
+```sql
+SELECT * FROM orders WHERE id = 2
+```
+
+This only works if the model's composite primary key contains the `:id` column,
+_and_ the column is unique for all records. In order to use the full composite
+primary key in associations, set the `query_constraints` option on the
+association. This option specifies a composite foreign key on the association,
+meaning that all columns in the foreign key will be used to query the
+associated record(s). For example:
+
+```ruby
+class Author < ApplicationRecord
+ self.primary_key = [:first_name, :last_name]
+ has_many :books, query_constraints: [:first_name, :last_name]
+end
+
+class Book < ApplicationRecord
+ belongs_to :author, query_constraints: [:author_first_name, :author_last_name]
+end
+```
+
+Accessing a book's author:
+
+```ruby
+author = Author.create!(first_name: "Jane", last_name: "Doe")
+book = author.books.create!(title: "A Cool Book")
+
+book.reload.author
+```
+
+will use `:first_name` _and_ `:last_name` in the SQL query:
+
+```sql
+SELECT * FROM authors WHERE first_name = 'Jane' AND last_name = 'Doe'
+```
+
+Forms for Composite Primary Key Models
+---------------------------
+
+Forms may also be built for composite primary key models.
+See the [Form Helpers][] guide for more information on the form builder syntax.
+
+[Form Helpers]: form_helpers.html
+
+Given a `@book` model object with a composite key `[:author_id, :id]`:
+
+```ruby
+@book = Book.find([2, 25])
+# => #
+```
+
+The following form:
+
+```erb
+<%= form_with model: @book do |form| %>
+ <%= form.text_field :title %>
+ <%= form.submit %>
+<% end %>
+```
+
+Outputs:
+
+```html
+
+```
+
+Note the generated URL contains the `author_id` and `id` delimited by an
+underscore. Once submitted, the controller can extract primary key values from
+the parameters and update the record. See the next section for more details.
+
+Composite Key Parameters
+------------------------
+
+Composite key parameters contain multiple values in one parameter.
+For this reason, we need to be able to extract each value and pass them to
+Active Record. We can leverage the `extract_value` method for this use-case.
+
+Given the following controller:
+
+```ruby
+class BooksController < ApplicationController
+ def show
+ # Extract the composite ID value from URL parameters.
+ id = params.extract_value(:id)
+ # Find the book using the composite ID.
+ @book = Book.find(id)
+ # use the default rendering behaviour to render the show view.
+ end
+end
+```
+
+And the following route:
+
+```ruby
+get '/books/:id', to: 'books#show'
+```
+
+When a user opens the URL `/books/4_2`, the controller will extract the
+composite key value `["4", "2"]` and pass it to `Book.find` to render the right
+record in the view. The `extract_value` method may be used to extract arrays
+out of any delimited parameters.
+
+Composite Primary Key Fixtures
+------------------------------
+
+Fixtures for composite primary key tables are fairly similar to normal tables.
+When using an id column, the column may be omitted as usual:
+
+```ruby
+class Book < ApplicationRecord
+ self.primary_key = [:author_id, :id]
+ belongs_to :author
+end
+```
+
+```yml
+# books.yml
+alices_adventure_in_wonderland:
+ author_id: <%= ActiveRecord::FixtureSet.identify(:lewis_carroll) %>
+ title: "Alice's Adventures in Wonderland"
+```
+
+However, in order to support composite primary key relationships,
+you must use the `composite_identify` method:
+
+```ruby
+class BookOrder < ApplicationRecord
+ self.primary_key = [:shop_id, :id]
+ belongs_to :order, query_constraints: [:shop_id, :order_id]
+ belongs_to :book, query_constraints: [:author_id, :book_id]
+end
+```
+
+```yml
+# book_orders.yml
+alices_adventure_in_wonderland_in_books:
+ author: lewis_carroll
+ book_id: <%= ActiveRecord::FixtureSet.composite_identify(
+ :alices_adventure_in_wonderland, Book.primary_key)[:id] %>
+ shop: book_store
+ order_id: <%= ActiveRecord::FixtureSet.composite_identify(
+ :books, Order.primary_key)[:id] %>
+```
diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md
index b8915170db20c..eedcf085d7d40 100644
--- a/guides/source/active_record_encryption.md
+++ b/guides/source/active_record_encryption.md
@@ -29,7 +29,7 @@ But more importantly, by using Active Record Encryption, you define what constit
### Setup
-First, you need to add some keys to your [Rails credentials](/security.html#custom-credentials). Run `bin/rails db:encryption:init` to generate a random key set:
+Run `bin/rails db:encryption:init` to generate a random key set:
```bash
$ bin/rails db:encryption:init
@@ -41,6 +41,14 @@ active_record_encryption:
key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz
```
+These values can be stored by copying and pasting the generated values into your existing [Rails credentials](/security.html#custom-credentials). Alternatively, these values can be configured from other sources, such as environment variables:
+
+```ruby
+config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
+config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
+config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
+```
+
NOTE: These generated values are 32 bytes in length. If you generate these yourself, the minimum lengths you should use are 12 bytes for the primary key (this will be used to derive the AES 32 bytes key) and 20 bytes for the salt.
### Declaration of Encrypted Attributes
diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md
index 4018c598e5c88..ad1d353d98c1a 100644
--- a/guides/source/active_record_migrations.md
+++ b/guides/source/active_record_migrations.md
@@ -34,7 +34,7 @@ history to the latest version. Active Record will also update your
Here's an example of a migration:
```ruby
-class CreateProducts < ActiveRecord::Migration[7.1]
+class CreateProducts < ActiveRecord::Migration[7.2]
def change
create_table :products do |t|
t.string :name
@@ -73,7 +73,7 @@ If you wish for a migration to do something that Active Record doesn't know how
to reverse, you can use `reversible`:
```ruby
-class ChangeProductsPrice < ActiveRecord::Migration[7.1]
+class ChangeProductsPrice < ActiveRecord::Migration[7.2]
def change
reversible do |direction|
change_table :products do |t|
@@ -92,7 +92,7 @@ passed to `direction.up` and `direction.down` respectively.
Alternatively, you can use `up` and `down` instead of `change`:
```ruby
-class ChangeProductsPrice < ActiveRecord::Migration[7.1]
+class ChangeProductsPrice < ActiveRecord::Migration[7.2]
def up
change_table :products do |t|
t.change :price, :string
@@ -136,7 +136,7 @@ $ bin/rails generate migration AddPartNumberToProducts
This will create an appropriately named empty migration:
```ruby
-class AddPartNumberToProducts < ActiveRecord::Migration[7.1]
+class AddPartNumberToProducts < ActiveRecord::Migration[7.2]
def change
end
end
@@ -160,7 +160,7 @@ $ bin/rails generate migration AddPartNumberToProducts part_number:string
This will generate the following migration:
```ruby
-class AddPartNumberToProducts < ActiveRecord::Migration[7.1]
+class AddPartNumberToProducts < ActiveRecord::Migration[7.2]
def change
add_column :products, :part_number, :string
end
@@ -176,7 +176,7 @@ $ bin/rails generate migration AddPartNumberToProducts part_number:string:index
This will generate the appropriate [`add_column`][] and [`add_index`][] statements:
```ruby
-class AddPartNumberToProducts < ActiveRecord::Migration[7.1]
+class AddPartNumberToProducts < ActiveRecord::Migration[7.2]
def change
add_column :products, :part_number, :string
add_index :products, :part_number
@@ -194,7 +194,7 @@ Will generate a schema migration which adds two additional
columns to the `products` table.
```ruby
-class AddDetailsToProducts < ActiveRecord::Migration[7.1]
+class AddDetailsToProducts < ActiveRecord::Migration[7.2]
def change
add_column :products, :part_number, :string
add_column :products, :price, :decimal
@@ -213,7 +213,7 @@ $ bin/rails generate migration RemovePartNumberFromProducts part_number:string
This generates the appropriate [`remove_column`][] statements:
```ruby
-class RemovePartNumberFromProducts < ActiveRecord::Migration[7.1]
+class RemovePartNumberFromProducts < ActiveRecord::Migration[7.2]
def change
remove_column :products, :part_number, :string
end
@@ -233,7 +233,7 @@ $ bin/rails generate migration CreateProducts name:string part_number:string
generates
```ruby
-class CreateProducts < ActiveRecord::Migration[7.1]
+class CreateProducts < ActiveRecord::Migration[7.2]
def change
create_table :products do |t|
t.string :name
@@ -261,7 +261,7 @@ $ bin/rails generate migration AddUserRefToProducts user:references
generates the following [`add_reference`][] call:
```ruby
-class AddUserRefToProducts < ActiveRecord::Migration[7.1]
+class AddUserRefToProducts < ActiveRecord::Migration[7.2]
def change
add_reference :products, :user, foreign_key: true
end
@@ -281,7 +281,7 @@ $ bin/rails generate migration CreateJoinTableCustomerProduct customer product
will produce the following migration:
```ruby
-class CreateJoinTableCustomerProduct < ActiveRecord::Migration[7.1]
+class CreateJoinTableCustomerProduct < ActiveRecord::Migration[7.2]
def change
create_join_table :customers, :products do |t|
# t.index [:customer_id, :product_id]
@@ -310,7 +310,7 @@ $ bin/rails generate model Product name:string description:text
This will create a migration that looks like this:
```ruby
-class CreateProducts < ActiveRecord::Migration[7.1]
+class CreateProducts < ActiveRecord::Migration[7.2]
def change
create_table :products do |t|
t.string :name
@@ -338,7 +338,7 @@ $ bin/rails generate migration AddDetailsToProducts 'price:decimal{5,2}' supplie
will produce a migration that looks like this
```ruby
-class AddDetailsToProducts < ActiveRecord::Migration[7.1]
+class AddDetailsToProducts < ActiveRecord::Migration[7.2]
def change
add_column :products, :price, :decimal, precision: 5, scale: 2
add_reference :products, :supplier, polymorphic: true
@@ -370,8 +370,9 @@ end
This method creates a `products` table with a column called `name`.
By default, `create_table` will implicitly create a primary key called `id` for
-you. You can change the name of the column with the `:primary_key` option or,
-if you don't want a primary key at all, you can pass the option `id: false`.
+you. You can change the name of the column with the `:primary_key` option, or
+pass an array to `:primary_key` for a composite primary key. If you don't want
+a primary key at all, you can pass the option `id: false`.
If you need to pass database specific options you can place an SQL fragment in
the `:options` option. For example:
@@ -622,6 +623,33 @@ NOTE: Active Record only supports single column foreign keys. `execute` and
`structure.sql` are required to use composite foreign keys. See
[Schema Dumping and You](#schema-dumping-and-you).
+### Composite Primary Keys
+
+Sometimes a single column's value isn't enough to uniquely identify every row
+of a table, but a combination of two or more columns *does* uniquely identify
+it. This can be the case when using a legacy database schema without a single
+`id` column as a primary key, or when altering schemas for sharding or
+multitenancy.
+
+You can create a table with a composite primary key by passing the
+`:primary_key` option to `create_table` with an array value:
+
+```ruby
+class CreateProducts < ActiveRecord::Migration[7.2]
+ def change
+ create_table :products, primary_key: [:customer_id, :product_sku] do |t|
+ t.integer :customer_id
+ t.string :product_sku
+ t.text :description
+ end
+ end
+end
+```
+
+INFO: Tables with composite primary keys require passing array values rather
+than integer IDs to many methods. See also the [Active Record Querying](
+active_record_querying.html) guide to learn more.
+
### When Helpers aren't Enough
If the helpers provided by Active Record aren't enough you can use the [`execute`][]
@@ -716,7 +744,7 @@ reverse. You can use [`reversible`][] to specify what to do when running a
migration and what else to do when reverting it. For example:
```ruby
-class ExampleMigration < ActiveRecord::Migration[7.1]
+class ExampleMigration < ActiveRecord::Migration[7.2]
def change
create_table :distributors do |t|
t.string :zipcode
@@ -767,7 +795,7 @@ reverse order they were made in the `up` method. The example in the `reversible`
section is equivalent to:
```ruby
-class ExampleMigration < ActiveRecord::Migration[7.1]
+class ExampleMigration < ActiveRecord::Migration[7.2]
def up
create_table :distributors do |t|
t.string :zipcode
@@ -815,7 +843,7 @@ You can use Active Record's ability to rollback migrations using the [`revert`][
```ruby
require_relative "20121212123456_example_migration"
-class FixupExampleMigration < ActiveRecord::Migration[7.1]
+class FixupExampleMigration < ActiveRecord::Migration[7.2]
def change
revert ExampleMigration
@@ -833,7 +861,7 @@ For example, let's imagine that `ExampleMigration` is committed and it is later
decided that a Distributors view is no longer needed.
```ruby
-class DontUseDistributorsViewMigration < ActiveRecord::Migration[7.1]
+class DontUseDistributorsViewMigration < ActiveRecord::Migration[7.2]
def change
revert do
# copy-pasted code from ExampleMigration
@@ -1011,7 +1039,7 @@ Several methods are provided in migrations that allow you to control all this:
For example, take the following migration:
```ruby
-class CreateProducts < ActiveRecord::Migration[7.1]
+class CreateProducts < ActiveRecord::Migration[7.2]
def change
suppress_messages do
create_table :products do |t|
@@ -1116,7 +1144,7 @@ When `:ruby` is selected, then the schema is stored in `db/schema.rb`. If you lo
at this file you'll find that it looks an awful lot like one very big migration:
```ruby
-ActiveRecord::Schema[7.1].define(version: 2008_09_06_171750) do
+ActiveRecord::Schema[7.2].define(version: 2008_09_06_171750) do
create_table "authors", force: true do |t|
t.string "name"
t.datetime "created_at"
@@ -1201,7 +1229,7 @@ modify data. This is useful in an existing database that can't be destroyed and
recreated, such as a production database.
```ruby
-class AddInitialProducts < ActiveRecord::Migration[7.1]
+class AddInitialProducts < ActiveRecord::Migration[7.2]
def up
5.times do |i|
Product.create(name: "Product ##{i}", description: "A product.")
diff --git a/guides/source/active_record_postgresql.md b/guides/source/active_record_postgresql.md
index 6305fb0c309bd..2c36f5a2c8d36 100644
--- a/guides/source/active_record_postgresql.md
+++ b/guides/source/active_record_postgresql.md
@@ -654,14 +654,14 @@ Unique Constraint
# db/migrate/20230422225213_create_items.rb
create_table :items do |t|
t.integer :position, null: false
- t.unique_key [:position], deferrable: :immediate
+ t.unique_constraint [:position], deferrable: :immediate
end
```
If you want to change an existing unique index to deferrable, you can use `:using_index` to create deferrable unique constraints.
```ruby
-add_unique_key :items, deferrable: :deferred, using_index: "index_items_on_position"
+add_unique_constraint :items, deferrable: :deferred, using_index: "index_items_on_position"
```
Like foreign keys, unique constraints can be deferred by setting `:deferrable` to either `:immediate` or `:deferred`. By default, `:deferrable` is `false` and the constraint is always checked immediately.
diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md
index a40293fd28f66..e8cee9de1f96b 100644
--- a/guides/source/active_record_querying.md
+++ b/guides/source/active_record_querying.md
@@ -199,6 +199,34 @@ SELECT * FROM customers WHERE (customers.id IN (1,10))
WARNING: The `find` method will raise an `ActiveRecord::RecordNotFound` exception unless a matching record is found for **all** of the supplied primary keys.
+If your table uses a composite primary key, you'll need to pass find an array to find a single item. For instance, if customers were defined with `[:store_id, :id]` as a primary key:
+
+```irb
+# Find the customer with store_id 3 and id 17
+irb> customers = Customer.find([3, 17])
+=> #
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM customers WHERE store_id = 3 AND id = 17
+```
+
+To find multiple customers with composite IDs, you would pass an array of arrays:
+
+```irb
+# Find the customers with primary keys [1, 8] and [7, 15].
+irb> customers = Customer.find([[1, 8], [7, 15]]) # OR Customer.find([1, 8], [7, 15])
+=> [#, #]
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM customers WHERE (store_id = 1 AND id = 8 OR store_id = 7 AND id = 15)
+```
+
#### `take`
The [`take`][] method retrieves a record without any implicit ordering. For example:
@@ -268,6 +296,20 @@ The SQL equivalent of the above is:
SELECT * FROM customers ORDER BY customers.id ASC LIMIT 3
```
+Models with composite primary keys will use the full composite primary key for ordering.
+For instance, if customers were defined with `[:store_id, :id]` as a primary key:
+
+```irb
+irb> customer = Customer.first
+=> #
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM customers ORDER BY customers.store_id ASC, customers.id ASC LIMIT 1
+```
+
On a collection that is ordered using `order`, `first` will return the first record ordered by the specified attribute for `order`.
```irb
@@ -303,6 +345,20 @@ SELECT * FROM customers ORDER BY customers.id DESC LIMIT 1
The `last` method returns `nil` if no matching record is found and no exception will be raised.
+Models with composite primary keys will use the full composite primary key for ordering.
+For instance, if customers were defined with `[:store_id, :id]` as a primary key:
+
+```irb
+irb> customer = Customer.last
+=> #
+```
+
+The SQL equivalent of the above is:
+
+```sql
+SELECT * FROM customers ORDER BY customers.store_id DESC, customers.id DESC LIMIT 1
+```
+
If your [default scope](active_record_querying.html#applying-a-default-scope) contains an order method, `last` will return the last record according to this ordering.
You can pass in a numerical argument to the `last` method to return up to that number of results. For example
@@ -378,6 +434,36 @@ Customer.where(first_name: 'does not exist').take!
[`find_by`]: https://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html#method-i-find_by
[`find_by!`]: https://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html#method-i-find_by-21
+##### Conditions with `:id`
+
+When specifying conditions on methods like [`find_by`][] and [`where`][], the use of `id` will match against
+an `:id` attribute on the model. This is different from [`find`][], where the ID passed in should be a primary key value.
+
+Take caution when using `find_by(id:)` on models where `:id` is not the primary key, such as composite primary key models.
+For example, if customers were defined with `[:store_id, :id]` as a primary key:
+
+```irb
+irb> customer = Customer.last
+=> #
+irb> Customer.find_by(id: customer.id) # Customer.find_by(id: [5, 10])
+=> #
+```
+
+Here, we might intend to search for a single record with the composite primary key `[5, 10]`, but Active Record will
+search for a record with an `:id` column of _either_ 5 or 10, and may return the wrong record.
+
+TIP: The [`id_value`][] method can be used to fetch the value of the `:id` column for a record, for use in finder
+methods such as `find_by` and `where`. See example below:
+
+```irb
+irb> customer = Customer.last
+=> #
+irb> Customer.find_by(id: customer.id_value) # Customer.find_by(id: 10)
+=> #
+```
+
+[`id_value`]: https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema.html#method-i-id_value
+
### Retrieving Multiple Objects in Batches
We often need to iterate over a large set of records, as when we send a newsletter to a large set of customers, or when we export data.
@@ -655,6 +741,23 @@ Book.where(author: author)
Author.joins(:books).where(books: { author: author })
```
+Hash conditions may also be specified in a tuple-like syntax, where the key is an array of columns and the value is
+an array of tuples:
+
+```ruby
+Book.where([:author_id, :id] => [[15, 1], [15, 2]])
+```
+
+This syntax can be useful for querying relations where the table uses a composite primary key:
+
+```ruby
+class Book < ApplicationRecord
+ self.primary_key = [:author_id, :id]
+end
+
+Book.where(Book.primary_key => [[2, 1], [3, 1]])
+```
+
#### Range Conditions
```ruby
@@ -1134,7 +1237,7 @@ the SQL executed would be:
SELECT * FROM books WHERE out_of_print = 1 AND out_of_print = 0
```
-[`regroup`]: https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-regroup
+[`rewhere`]: https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-rewhere
### `regroup`
@@ -1651,15 +1754,40 @@ some associations. To make sure no associations are lazy loaded you can enable
By enabling strict loading mode on a relation, an
`ActiveRecord::StrictLoadingViolationError` will be raised if the record tries
-to lazily load an association:
+to lazily load any association:
```ruby
user = User.strict_loading.first
+user.address.city # raises an ActiveRecord::StrictLoadingViolationError
user.comments.to_a # raises an ActiveRecord::StrictLoadingViolationError
```
[`strict_loading`]: https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-strict_loading
+### `strict_loading!`
+
+We can also enable strict loading on the record itself by calling [`strict_loading!`][]:
+
+```ruby
+user = User.first
+user.strict_loading!
+user.address.city # raises an ActiveRecord::StrictLoadingViolationError
+user.comments.to_a # raises an ActiveRecord::StrictLoadingViolationError
+```
+
+`strict_loading!` also takes a `:mode` argument. Setting it to `:n_plus_one_only`
+will only raise an error if an association that will lead to an N + 1 query is
+lazily loaded:
+
+```ruby
+user.strict_loading!(mode: :n_plus_one_only)
+user.address.city # => "Tatooine"
+user.comments.to_a # => [# Customer.find_or_create_by!(first_name: 'Andy')
-ActiveRecord::RecordInvalid: Validation failed: Orders count can’t be blank
+ActiveRecord::RecordInvalid: Validation failed: Orders count can't be blank
```
[`find_or_create_by!`]: https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-find_or_create_by-21
diff --git a/guides/source/active_record_validations.md b/guides/source/active_record_validations.md
index 2a3face2991ae..5a1ac12dcd7ad 100644
--- a/guides/source/active_record_validations.md
+++ b/guides/source/active_record_validations.md
@@ -205,21 +205,21 @@ irb> p.errors.size
irb> p.valid?
=> false
irb> p.errors.objects.first.full_message
-=> "Name can’t be blank"
+=> "Name can't be blank"
irb> p = Person.create
=> #
irb> p.errors.objects.first.full_message
-=> "Name can’t be blank"
+=> "Name can't be blank"
irb> p.save
=> false
irb> p.save!
-ActiveRecord::RecordInvalid: Validation failed: Name can’t be blank
+ActiveRecord::RecordInvalid: Validation failed: Name can't be blank
irb> Person.create!
-ActiveRecord::RecordInvalid: Validation failed: Name can’t be blank
+ActiveRecord::RecordInvalid: Validation failed: Name can't be blank
```
[`invalid?`][] is the inverse of `valid?`. It triggers your validations,
@@ -648,7 +648,7 @@ validates :boolean_field_name, exclusion: [nil]
By using one of these validations, you will ensure the value will NOT be `nil`
which would result in a `NULL` value in most cases.
-The default error message is _"can’t be blank"_.
+The default error message is _"can't be blank"_.
[`Object#blank?`]: https://api.rubyonrails.org/classes/Object.html#method-i-blank-3F
@@ -1087,7 +1087,7 @@ irb> book.valid?
irb> book.valid?(:ensure_title)
=> false
irb> book.errors.messages
-=> {:title=>["can’t be blank"]}
+=> {:title=>["can't be blank"]}
```
When triggered by an explicit context, validations are run for that context,
@@ -1106,7 +1106,7 @@ irb> person = Person.new
irb> person.valid?(:account_setup)
=> false
irb> person.errors.messages
-=> {:email=>["has already been taken"], :age=>["is not a number"], :name=>["can’t be blank"]}
+=> {:email=>["has already been taken"], :age=>["is not a number"], :name=>["can't be blank"]}
```
We will cover more use-cases for `on:` in the [callbacks guide](active_record_callbacks.html).
@@ -1125,7 +1125,7 @@ end
```irb
irb> Person.new.valid?
-ActiveModel::StrictValidationFailed: Name can’t be blank
+ActiveModel::StrictValidationFailed: Name can't be blank
```
There is also the ability to pass a custom exception to the `:strict` option.
@@ -1138,7 +1138,7 @@ end
```irb
irb> Person.new.valid?
-TokenGenerationException: Token can’t be blank
+TokenGenerationException: Token can't be blank
```
Conditional Validation
@@ -1395,7 +1395,7 @@ irb> person = Person.new
irb> person.valid?
=> false
irb> person.errors.full_messages
-=> ["Name can’t be blank", "Name is too short (minimum is 3 characters)"]
+=> ["Name can't be blank", "Name is too short (minimum is 3 characters)"]
irb> person = Person.new(name: "John Doe")
irb> person.valid?
@@ -1442,7 +1442,7 @@ irb> person = Person.new
irb> person.valid?
=> false
irb> person.errors[:name]
-=> ["can’t be blank", "is too short (minimum is 3 characters)"]
+=> ["can't be blank", "is too short (minimum is 3 characters)"]
```
### `errors.where` and Error Object
diff --git a/guides/source/active_support_core_extensions.md b/guides/source/active_support_core_extensions.md
index b3dc7b61e6d5a..e299461cb6852 100644
--- a/guides/source/active_support_core_extensions.md
+++ b/guides/source/active_support_core_extensions.md
@@ -2197,47 +2197,6 @@ BigDecimal(5.00, 6).to_s("e") # => "0.5E1"
Extensions to `Enumerable`
--------------------------
-### `sum`
-
-The method [`sum`][Enumerable#sum] adds the elements of an enumerable:
-
-```ruby
-[1, 2, 3].sum # => 6
-(1..100).sum # => 5050
-```
-
-Addition only assumes the elements respond to `+`:
-
-```ruby
-[[1, 2], [2, 3], [3, 4]].sum # => [1, 2, 2, 3, 3, 4]
-%w(foo bar baz).sum # => "foobarbaz"
-{ a: 1, b: 2, c: 3 }.sum # => [:a, 1, :b, 2, :c, 3]
-```
-
-The sum of an empty collection is zero by default, but this is customizable:
-
-```ruby
-[].sum # => 0
-[].sum(1) # => 1
-```
-
-If a block is given, `sum` becomes an iterator that yields the elements of the collection and sums the returned values:
-
-```ruby
-(1..5).sum { |n| n * 2 } # => 30
-[2, 4, 6, 8, 10].sum # => 30
-```
-
-The sum of an empty receiver can be customized in this form as well:
-
-```ruby
-[].sum(1) { |n| n**3 } # => 1
-```
-
-NOTE: Defined in `active_support/core_ext/enumerable.rb`.
-
-[Enumerable#sum]: https://api.rubyonrails.org/classes/Enumerable.html#method-i-sum
-
### `index_by`
The method [`index_by`][Enumerable#index_by] generates a hash with the elements of an enumerable indexed by some key.
diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md
index 53e93364a9a39..dac453c6c8b94 100644
--- a/guides/source/active_support_instrumentation.md
+++ b/guides/source/active_support_instrumentation.md
@@ -102,7 +102,7 @@ View Timings from Instrumentation in Your Browser
Rails implements the [Server Timing](https://www.w3.org/TR/server-timing/) standard to make timing information available in the web browser. To enable, edit your environment configuration (usually `development.rb` as this is most-used in development) to include the following:
```ruby
- config.server_timing = true
+config.server_timing = true
```
Once configured (including restarting your server), you can go to the Developer Tools pane of your browser, then select Network and reload your page. You can then select any request to your Rails server, and will see server timings in the timings tab. For an example of doing this, see the [Firefox Documentation](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_details/index.html#server-timing).
diff --git a/guides/source/asset_pipeline.md b/guides/source/asset_pipeline.md
index bd7e3cb5f813a..7c0e310a42e96 100644
--- a/guides/source/asset_pipeline.md
+++ b/guides/source/asset_pipeline.md
@@ -1050,15 +1050,15 @@ We are aware that there are no one-size-fits-it-all solutions for the various Ja
### jsbundling-rails
-[`jsbundling-rails`](https://github.com/rails/jsbundling-rails) is a Node.js dependent alternative to the `importmap-rails` way of bundling JavaScript with [esbuild](https://esbuild.github.io/), [rollup.js](https://rollupjs.org/), or [Webpack](https://webpack.js.org/).
+[`jsbundling-rails`](https://github.com/rails/jsbundling-rails) is a JavaScript run-time dependent alternative to the `importmap-rails` way of bundling JS with [Bun](https://bun.sh), [esbuild](https://esbuild.github.io/), [rollup.js](https://rollupjs.org/), or [Webpack](https://webpack.js.org/).
-The gem provides `yarn build --watch` process to automatically generate output in development. For production, it automatically hooks `javascript:build` task into `assets:precompile` task to ensure that all your package dependencies have been installed and JavaScript has been built for all entry points.
+The gem provides a build task in `package.json` to watch for changes and automatically generate output in development. For production, it automatically hooks `javascript:build` task into `assets:precompile` task to ensure that all your package dependencies have been installed and JavaScript has been built for all entry points.
**When to use instead of `importmap-rails`?** If your JavaScript code depends on transpiling so if you are using [Babel](https://babeljs.io/), [TypeScript](https://www.typescriptlang.org/) or React `JSX` format then `jsbundling-rails` is the correct way to go.
### Webpacker/Shakapacker
-[`Webpacker`](webpacker.html) was the default JavaScript pre-processor and bundler for Rails 5 and 6. It has now been retired. A successor called [`shakapacker`](https://github.com/shakacode/shakapacker) exists, but is not maintained by the Rails team or project.
+[`Webpacker`](https://github.com/rails/webpacker) was the default JavaScript pre-processor and bundler for Rails 5 and 6. It has now been retired. A successor called [`shakapacker`](https://github.com/shakacode/shakapacker) exists, but is not maintained by the Rails team or project.
Unlike other libraries in this list `webpacker`/`shakapacker` is completely independent of Sprockets and could process both JavaScript and CSS files.
diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md
index 8c1e2523be4a8..1075b94736d4f 100644
--- a/guides/source/association_basics.md
+++ b/guides/source/association_basics.md
@@ -112,7 +112,7 @@ NOTE: `belongs_to` associations _must_ use the singular term. If you used the pl
The corresponding migration might look like this:
```ruby
-class CreateBooks < ActiveRecord::Migration[7.1]
+class CreateBooks < ActiveRecord::Migration[7.2]
def change
create_table :authors do |t|
t.string :name
@@ -159,7 +159,7 @@ The main difference from `belongs_to` is that the link column `supplier_id` is l
The corresponding migration might look like this:
```ruby
-class CreateSuppliers < ActiveRecord::Migration[7.1]
+class CreateSuppliers < ActiveRecord::Migration[7.2]
def change
create_table :suppliers do |t|
t.string :name
@@ -205,7 +205,7 @@ NOTE: The name of the other model is pluralized when declaring a `has_many` asso
The corresponding migration might look like this:
```ruby
-class CreateAuthors < ActiveRecord::Migration[7.1]
+class CreateAuthors < ActiveRecord::Migration[7.2]
def change
create_table :authors do |t|
t.string :name
@@ -257,7 +257,7 @@ end
The corresponding migration might look like this:
```ruby
-class CreateAppointments < ActiveRecord::Migration[7.1]
+class CreateAppointments < ActiveRecord::Migration[7.2]
def change
create_table :physicians do |t|
t.string :name
@@ -343,7 +343,7 @@ end
The corresponding migration might look like this:
```ruby
-class CreateAccountHistories < ActiveRecord::Migration[7.1]
+class CreateAccountHistories < ActiveRecord::Migration[7.2]
def change
create_table :suppliers do |t|
t.string :name
@@ -386,7 +386,7 @@ end
The corresponding migration might look like this:
```ruby
-class CreateAssembliesAndParts < ActiveRecord::Migration[7.1]
+class CreateAssembliesAndParts < ActiveRecord::Migration[7.2]
def change
create_table :assemblies do |t|
t.string :name
@@ -425,7 +425,7 @@ end
The corresponding migration might look like this:
```ruby
-class CreateSuppliers < ActiveRecord::Migration[7.1]
+class CreateSuppliers < ActiveRecord::Migration[7.2]
def change
create_table :suppliers do |t|
t.string :name
@@ -482,6 +482,9 @@ The simplest rule of thumb is that you should set up a `has_many :through` relat
You should use `has_many :through` if you need validations, callbacks, or extra attributes on the join model.
+While `has_and_belongs_to_many` suggests creating a join table with no primary key via `id: false`, consider using a composite primary key for the join table in the `has_many :through` relationship.
+For example, it's recommended to use `create_table :manifests, primary_key: [:assembly_id, :part_id]` in the example above.
+
### Polymorphic Associations
A slightly more advanced twist on associations is the _polymorphic association_. With polymorphic associations, a model can belong to more than one other model, on a single association. For example, you might have a picture model that belongs to either an employee model or a product model. Here's how this could be declared:
@@ -507,7 +510,7 @@ Similarly, you can retrieve `@product.pictures`.
If you have an instance of the `Picture` model, you can get to its parent via `@picture.imageable`. To make this work, you need to declare both a foreign key column and a type column in the model that declares the polymorphic interface:
```ruby
-class CreatePictures < ActiveRecord::Migration[7.1]
+class CreatePictures < ActiveRecord::Migration[7.2]
def change
create_table :pictures do |t|
t.string :name
@@ -524,7 +527,7 @@ end
This migration can be simplified by using the `t.references` form:
```ruby
-class CreatePictures < ActiveRecord::Migration[7.1]
+class CreatePictures < ActiveRecord::Migration[7.2]
def change
create_table :pictures do |t|
t.string :name
@@ -537,6 +540,70 @@ end

+### Associations between Models with Composite Primary Keys
+
+Rails is often able to infer the primary key - foreign key information between associated models with composite
+primary keys without needing extra information. Take the following example:
+
+```ruby
+class Order < ApplicationRecord
+ self.primary_key = [:shop_id, :id]
+ has_many :books
+end
+
+class Book < ApplicationRecord
+ belongs_to :order
+end
+```
+
+Here, Rails assumes that the `:id` column should be used as the primary key for the association between an order
+and its books, just as with a regular `has_many` / `belongs_to` association. It will infer that the foreign key column
+on the `books` table is `:order_id`. Accessing a book's order:
+
+```ruby
+order = Order.create!(id: [1, 2], status: "pending")
+book = order.books.create!(title: "A Cool Book")
+
+book.reload.order
+```
+
+will generate the following SQL to access the order:
+
+```sql
+SELECT * FROM orders WHERE id = 2
+```
+
+This only works if the model's composite primary key contains the `:id` column, _and_ the column is unique for
+all records. In order to use the full composite primary key in associations, set the `query_constraints` option on
+the association. This option specifies a composite foreign key on the association: all columns in the foreign key will
+be used when querying the associated record(s). For example:
+
+```ruby
+class Author < ApplicationRecord
+ self.primary_key = [:first_name, :last_name]
+ has_many :books, query_constraints: [:first_name, :last_name]
+end
+
+class Book < ApplicationRecord
+ belongs_to :author, query_constraints: [:author_first_name, :author_last_name]
+end
+```
+
+Accessing a book's author:
+
+```ruby
+author = Author.create!(first_name: "Jane", last_name: "Doe")
+book = author.books.create!(title: "A Cool Book")
+
+book.reload.author
+```
+
+will use `:first_name` _and_ `:last_name` in the SQL query:
+
+```sql
+SELECT * FROM authors WHERE first_name = 'Jane' AND last_name = 'Doe'
+```
+
### Self Joins
In designing a data model, you will sometimes find a model that should have a relation to itself. For example, you may want to store all employees in a single database model, but be able to trace relationships such as between manager and subordinates. This situation can be modeled with self-joining associations:
@@ -555,7 +622,7 @@ With this setup, you can retrieve `@employee.subordinates` and `@employee.manage
In your migrations/schema, you will add a references column to the model itself.
```ruby
-class CreateEmployees < ActiveRecord::Migration[7.1]
+class CreateEmployees < ActiveRecord::Migration[7.2]
def change
create_table :employees do |t|
t.references :manager, foreign_key: { to_table: :employees }
@@ -629,7 +696,7 @@ end
This declaration needs to be backed up by a corresponding foreign key column in the books table. For a brand new table, the migration might look something like this:
```ruby
-class CreateBooks < ActiveRecord::Migration[7.1]
+class CreateBooks < ActiveRecord::Migration[7.2]
def change
create_table :books do |t|
t.datetime :published_at
@@ -643,7 +710,7 @@ end
Whereas for an existing table, it might look like this:
```ruby
-class AddAuthorToBooks < ActiveRecord::Migration[7.1]
+class AddAuthorToBooks < ActiveRecord::Migration[7.2]
def change
add_reference :books, :author
end
@@ -675,7 +742,7 @@ end
These need to be backed up by a migration to create the `assemblies_parts` table. This table should be created without a primary key:
```ruby
-class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[7.1]
+class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[7.2]
def change
create_table :assemblies_parts, id: false do |t|
t.bigint :assembly_id
@@ -693,7 +760,7 @@ We pass `id: false` to `create_table` because that table does not represent a mo
For simplicity, you can also use the method `create_join_table`:
```ruby
-class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[7.1]
+class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[7.2]
def change
create_join_table :assemblies, :parts do |t|
t.index :assembly_id
diff --git a/guides/source/autoloading_and_reloading_constants.md b/guides/source/autoloading_and_reloading_constants.md
index 8a846713df9d3..d435c0ea9984c 100644
--- a/guides/source/autoloading_and_reloading_constants.md
+++ b/guides/source/autoloading_and_reloading_constants.md
@@ -114,10 +114,12 @@ Normally, `lib` has subdirectories that should not be managed by the autoloaders
config.autoload_lib(ignore: %w(assets tasks))
```
-Why? While `assets` and `tasks` share the `lib` directory with regular code, their contents are not meant to be autoloaded or eager loaded. `Assets` and `Tasks` are not Ruby namespaces there. Same with generators if you have any:
+Why? While `assets` and `tasks` share the `lib` directory with regular Ruby code, their contents are not meant to be reloaded or eager loaded.
+
+The `ignore` list should have all `lib` subdirectories that do not contain files with `.rb` extension, or that should not be reloadaded or eager loaded. For example,
```ruby
-config.autoload_lib(ignore: %w(assets tasks generators))
+config.autoload_lib(ignore: %w(assets tasks templates generators middleware))
```
`config.autoload_lib` is not available before 7.1, but you can still emulate it as long as the application uses Zeitwerk:
diff --git a/guides/source/command_line.md b/guides/source/command_line.md
index ead3642ab9cdd..5e0493e6e9558 100644
--- a/guides/source/command_line.md
+++ b/guides/source/command_line.md
@@ -410,7 +410,7 @@ If you wish to test out some code without changing any data, you can do that by
```bash
$ bin/rails console --sandbox
-Loading development environment in sandbox (Rails 7.1.0)
+Loading development environment in sandbox (Rails 7.2.0)
Any modifications you make will be rolled back on exit
irb(main):001:0>
```
diff --git a/guides/source/configuring.md b/guides/source/configuring.md
index 265cb53a2014b..9903c78a6b20d 100644
--- a/guides/source/configuring.md
+++ b/guides/source/configuring.md
@@ -73,6 +73,7 @@ Below are the default values associated with each target version. In cases of co
- [`config.active_record.default_column_serializer`](#config-active-record-default-column-serializer): `nil`
- [`config.active_record.encryption.hash_digest_class`](#config-active-record-encryption-hash-digest-class): `OpenSSL::Digest::SHA256`
- [`config.active_record.encryption.support_sha1_for_non_deterministic_encryption`](#config-active-record-encryption-support-sha1-for-non-deterministic-encryption): `false`
+- [`config.active_record.generate_secure_token_on`](#config-active-record-generate-secure-token-on): `:initialize`
- [`config.active_record.marshalling_format_version`](#config-active-record-marshalling-format-version): `7.1`
- [`config.active_record.query_log_tags_format`](#config-active-record-query-log-tags-format): `:sqlcommenter`
- [`config.active_record.raise_on_assign_to_attr_readonly`](#config-active-record-raise-on-assign-to-attr-readonly): `true`
@@ -442,7 +443,8 @@ attacks.
#### `config.javascript_path`
-Sets the path where your app's JavaScript lives relative to the `app` directory. The default is `javascript`, used by [webpacker](https://github.com/rails/webpacker). An app's configured `javascript_path` will be excluded from `autoload_paths`.
+Sets the path where your app's JavaScript lives relative to the `app` directory and the default value is `javascript`.
+An app's configured `javascript_path` will be excluded from `autoload_paths`.
#### `config.log_file_size`
@@ -970,13 +972,13 @@ irb> person = Person.new.tap(&:valid?)
irb> person.errors.full_messages
=> [
- "Invalid Name (can’t be blank)",
+ "Invalid Name (can't be blank)",
"Please fill in your Age"
]
irb> person.errors.messages
=> {
- :name => ["can’t be blank"],
+ :name => ["can't be blank"],
:age => ["Please fill in your Age"]
}
```
@@ -1513,6 +1515,44 @@ Defaults to `true`. Determines whether to raise an exception or not when
the PostgreSQL adapter is provided an integer that is wider than signed
64bit representation.
+#### `config.active_record.generate_secure_token_on`
+
+Controls when to generate a value for `has_secure_token` declarations. By
+default, generate the value when the model is initialized:
+
+```ruby
+class User < ApplicationRecord
+ has_secure_token
+end
+
+record = User.new
+record.token # => "fwZcXX6SkJBJRogzMdciS7wf"
+```
+
+With `config.active_record.generate_secure_token_on = :create`, generate the
+value when the model is created:
+
+```ruby
+# config/application.rb
+
+config.active_record.generate_secure_token_on = :create
+
+# app/models/user.rb
+class User < ApplicationRecord
+ has_secure_token on: :create
+end
+
+record = User.new
+record.token # => nil
+record.save!
+record.token # => "fwZcXX6SkJBJRogzMdciS7wf"
+```
+
+| Starting with version | The default value is |
+| --------------------- | -------------------- |
+| (original) | `:create` |
+| 7.1 | `:initialize` |
+
#### `ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans` and `ActiveRecord::ConnectionAdapters::TrilogyAdapter.emulate_booleans`
Controls whether the Active Record MySQL adapter will consider all `tinyint(1)` columns as booleans. Defaults to `true`.
diff --git a/guides/source/contributing_to_ruby_on_rails.md b/guides/source/contributing_to_ruby_on_rails.md
index 13ef0c893ee2f..0eb985f7fa6cb 100644
--- a/guides/source/contributing_to_ruby_on_rails.md
+++ b/guides/source/contributing_to_ruby_on_rails.md
@@ -138,6 +138,7 @@ learn about Ruby on Rails, and the API, which serves as a reference.
You can help improve the Rails guides or the API reference by making them more coherent, consistent, or readable, adding missing information, correcting factual errors, fixing typos, or bringing them up to date with the latest edge Rails.
To do so, make changes to Rails guides source files (located [here](https://github.com/rails/rails/tree/main/guides/source) on GitHub) or RDoc comments in source code. Then open a pull request to apply your changes to the main branch.
+Use `[ci skip]` in your pull request title to avoid running the CI build for documentation changes.
When working with documentation, please take into account the [API Documentation Guidelines](api_documentation_guidelines.html) and the [Ruby on Rails Guides Guidelines](ruby_on_rails_guides_guidelines.html).
@@ -305,21 +306,7 @@ For `rails-ujs` CoffeeScript and JavaScript files, you can run `npm run lint` in
#### Spell Checking
-We are running [misspell](https://github.com/client9/misspell) which is mainly written in
-[Golang](https://golang.org/) to check spelling with [GitHub Actions](https://github.com/rails/rails/blob/main/.github/workflows/lint.yml). Correct
-commonly misspelled English words quickly with `misspell`. `misspell` is different from most other spell checkers
-because it doesn't use a custom dictionary. You can run `misspell` locally against all files with:
-
-```bash
-$ find . -type f | xargs ./misspell -i 'aircrafts,devels,invertions' -error
-```
-
-Notable `misspell` help options or flags are:
-
-- `-i` string: ignore the following corrections, comma separated
-- `-w`: Overwrite file with corrections (default is just to display)
-
-We also run [codespell](https://github.com/codespell-project/codespell) with GitHub Actions to check spelling and
+We run [codespell](https://github.com/codespell-project/codespell) with GitHub Actions to check spelling and
[codespell](https://pypi.org/project/codespell/) runs against a [small custom dictionary](https://github.com/rails/rails/blob/main/codespell.txt).
`codespell` is written in [Python](https://www.python.org/) and you can run it with:
@@ -627,7 +614,7 @@ To ease the upgrade it's required to add the new default to the
value:
```ruby
-# new_framework_defaults_7_1.rb.tt
+# new_framework_defaults_7_2.rb.tt
# Rails.application.config.active_job.existing_behavior = false
```
diff --git a/guides/source/debugging_rails_applications.md b/guides/source/debugging_rails_applications.md
index 3ded201023130..64a3489392850 100644
--- a/guides/source/debugging_rails_applications.md
+++ b/guides/source/debugging_rails_applications.md
@@ -378,7 +378,7 @@ Processing by PostsController#index as HTML
10| # GET /posts/1 or /posts/1.json
11| def show
=>#0 PostsController#index at ~/projects/rails-guide-example/app/controllers/posts_controller.rb:7
- #1 ActionController::BasicImplicitRender#send_action(method="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.1.0.alpha/lib/action_controller/metal/basic_implicit_render.rb:6
+ #1 ActionController::BasicImplicitRender#send_action(method="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.2.0.alpha/lib/action_controller/metal/basic_implicit_render.rb:6
# and 72 frames (use `bt' command for all frames)
(rdbg)
```
@@ -444,14 +444,14 @@ When used without any options, `backtrace` lists all the frames on the stack:
```rb
=>#0 PostsController#index at ~/projects/rails-guide-example/app/controllers/posts_controller.rb:7
- #1 ActionController::BasicImplicitRender#send_action(method="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.1.0.alpha/lib/action_controller/metal/basic_implicit_render.rb:6
- #2 AbstractController::Base#process_action(method_name="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.1.0.alpha/lib/abstract_controller/base.rb:214
- #3 ActionController::Rendering#process_action(#arg_rest=nil) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.1.0.alpha/lib/action_controller/metal/rendering.rb:53
- #4 block in process_action at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.1.0.alpha/lib/abstract_controller/callbacks.rb:221
- #5 block in run_callbacks at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activesupport-7.1.0.alpha/lib/active_support/callbacks.rb:118
- #6 ActionText::Rendering::ClassMethods#with_renderer(renderer=#) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actiontext-7.1.0.alpha/lib/action_text/rendering.rb:20
- #7 block {|controller=#, action=# (4 levels) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actiontext-7.1.0.alpha/lib/action_text/engine.rb:69
- #8 [C] BasicObject#instance_exec at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activesupport-7.1.0.alpha/lib/active_support/callbacks.rb:127
+ #1 ActionController::BasicImplicitRender#send_action(method="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-2.0.alpha/lib/action_controller/metal/basic_implicit_render.rb:6
+ #2 AbstractController::Base#process_action(method_name="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.2.0.alpha/lib/abstract_controller/base.rb:214
+ #3 ActionController::Rendering#process_action(#arg_rest=nil) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.2.0.alpha/lib/action_controller/metal/rendering.rb:53
+ #4 block in process_action at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.2.0.alpha/lib/abstract_controller/callbacks.rb:221
+ #5 block in run_callbacks at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activesupport-7.2.0.alpha/lib/active_support/callbacks.rb:118
+ #6 ActionText::Rendering::ClassMethods#with_renderer(renderer=#) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actiontext-7.2.0.alpha/lib/action_text/rendering.rb:20
+ #7 block {|controller=#, action=# (4 levels) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actiontext-7.2.0.alpha/lib/action_text/engine.rb:69
+ #8 [C] BasicObject#instance_exec at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activesupport-7.2.0.alpha/lib/active_support/callbacks.rb:127
..... and more
```
@@ -505,7 +505,7 @@ instance variables:
@_action_has_layout @_action_name @_config @_lookup_context @_request
@_response @_response_body @_routes @marked_for_same_origin_verification @posts
@rendered_format
-class variables: @@raise_on_missing_translations @@raise_on_open_redirects
+class variables: @@raise_on_open_redirects
```
### Breakpoints
diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml
index 128378f1dc5f4..6f7c4f64a8ea8 100644
--- a/guides/source/documents.yaml
+++ b/guides/source/documents.yaml
@@ -219,11 +219,11 @@
Rails. It is an extremely in-depth guide and recommended for advanced
Rails developers.
-
- name: Autoloading and Reloading Constants
+ name: Autoloading and Reloading
url: autoloading_and_reloading_constants.html
- description: This guide documents how autoloading and reloading constants work (Zeitwerk mode).
+ description: This guide documents how autoloading and reloading constants work.
-
- name: "Classic to Zeitwerk HOWTO"
+ name: "Migrating from Classic to Zeitwerk"
url: "classic_to_zeitwerk_howto.html"
description: "This guide documents how to migrate Rails applications from `classic` to `zeitwerk` mode."
-
@@ -245,7 +245,7 @@
url: active_record_postgresql.html
description: This guide covers PostgreSQL specific usage of Active Record.
-
- name: Multiple Databases with Active Record
+ name: Multiple Databases
url: active_record_multiple_databases.html
description: This guide covers using multiple databases in your application.
-
@@ -253,6 +253,10 @@
work_in_progress: true
url: active_record_encryption.html
description: This guide covers encrypting your database information using Active Record.
+ -
+ name: Composite Primary Keys
+ url: active_record_composite_primary_keys.html
+ description: This guide is an introduction to composite primary keys for database tables.
-
name: Extending Rails
@@ -327,6 +331,11 @@
description: >
This guide provides steps to be followed when you upgrade your
applications to a newer version of Ruby on Rails.
+ -
+ name: Version 7.2 - ?
+ url: 7_2_release_notes.html
+ description: Release notes for Rails 7.1.
+ work_in_progress: true
-
name: Version 7.1 - ?
url: 7_1_release_notes.html
diff --git a/guides/source/engines.md b/guides/source/engines.md
index 895e25da03164..b016c09d24699 100644
--- a/guides/source/engines.md
+++ b/guides/source/engines.md
@@ -238,28 +238,6 @@ NOTE: The `ApplicationController` class inside an engine is named just like a
Rails application in order to make it easier for you to convert your
applications into engines.
-NOTE: If the parent application runs in `classic` mode, you may run into a
-situation where your engine controller is inheriting from the main application
-controller and not your engine's application controller. The best way to prevent
-this is to switch to `zeitwerk` mode in the parent application. Otherwise, use
-`require_dependency` to ensure that the engine's application controller is
-loaded. For example:
-
-```ruby
-# ONLY NEEDED IN `classic` MODE.
-require_dependency "blorgh/application_controller"
-
-module Blorgh
- class ArticlesController < ApplicationController
- # ...
- end
-end
-```
-
-WARNING: Don't use `require` because it will break the automatic reloading of
-classes in the development environment - using `require_dependency` ensures that
-classes are loaded and unloaded in the correct manner.
-
Just like for `app/controllers`, you will find a `blorgh` subdirectory under
the `app/helpers`, `app/jobs`, `app/mailers` and `app/models` directories
containing the associated `application_*.rb` file for gathering common
diff --git a/guides/source/form_helpers.md b/guides/source/form_helpers.md
index 9f069adc5601a..3f1bc8d3f2b83 100644
--- a/guides/source/form_helpers.md
+++ b/guides/source/form_helpers.md
@@ -236,6 +236,43 @@ There are several things to notice here:
TIP: Conventionally your inputs will mirror model attributes. However, they don't have to! If there is other information you need you can include it in your form just as with attributes and access it via `params[:article][:my_nifty_non_attribute_input]`.
+#### Composite primary key forms
+
+Forms may also be built with composite primary key models. In this case, the form
+building syntax is the same with slightly different output.
+
+Given a `@book` model object with a composite key `[:author_id, :id]`:
+
+```ruby
+@book = Book.find([2, 25])
+# => #
+```
+
+The following form:
+
+```erb
+<%= form_with model: @book do |form| %>
+ <%= form.text_field :title %>
+ <%= form.submit %>
+<% end %>
+```
+
+Outputs:
+
+```html
+
+```
+
+Note the generated URL contains the `author_id` and `id` delimited by an underscore.
+Once submitted, the controller can [extract each primary key value][] from parameters
+and update the record as it would with a singular primary key.
+
+[extract each primary key value]: action_controller_overview.html#composite-key-parameters
+
#### The `fields_for` Helper
The [`fields_for`][] helper creates a similar binding but without rendering a
diff --git a/guides/source/layouts_and_rendering.md b/guides/source/layouts_and_rendering.md
index 80acc6a4f9ffa..cb7d33736bf2a 100644
--- a/guides/source/layouts_and_rendering.md
+++ b/guides/source/layouts_and_rendering.md
@@ -1099,10 +1099,11 @@ To render a partial as part of a view, you use the [`render`][view.render] metho
This will render a file named `_menu.html.erb` at that point within the view being rendered. Note the leading underscore character: partials are named with a leading underscore to distinguish them from regular views, even though they are referred to without the underscore. This holds true even when you're pulling in a partial from another folder:
```html+erb
-<%= render "shared/menu" %>
+<%= render "application/menu" %>
```
-That code will pull in the partial from `app/views/shared/_menu.html.erb`.
+Since view partials rely on the same [Template Inheritance](#template-inheritance)
+as templates and layouts, that code will pull in the partial from `app/views/application/_menu.html.erb`.
[view.render]: https://api.rubyonrails.org/classes/ActionView/Helpers/RenderingHelper.html#method-i-render
@@ -1111,14 +1112,14 @@ That code will pull in the partial from `app/views/shared/_menu.html.erb`.
One way to use partials is to treat them as the equivalent of subroutines: as a way to move details out of a view so that you can grasp what's going on more easily. For example, you might have a view that looked like this:
```erb
-<%= render "shared/ad_banner" %>
+<%= render "application/ad_banner" %>
Products
Here are a few of our fine products:
-...
+<%# ... %>
-<%= render "shared/footer" %>
+<%= render "application/footer" %>
```
Here, the `_ad_banner.html.erb` and `_footer.html.erb` partials could contain
@@ -1133,7 +1134,7 @@ definitions for several similar resources:
* `users/index.html.erb`
```html+erb
- <%= render "shared/search_filters", search: @q do |form| %>
+ <%= render "application/search_filters", search: @q do |form| %>
Name contains: <%= form.text_field :name_contains %>
@@ -1143,14 +1144,14 @@ definitions for several similar resources:
* `roles/index.html.erb`
```html+erb
- <%= render "shared/search_filters", search: @q do |form| %>
+ <%= render "application/search_filters", search: @q do |form| %>
Title contains: <%= form.text_field :title_contains %>
<% end %>
```
-* `shared/_search_filters.html.erb`
+* `application/_search_filters.html.erb`
```html+erb
<%= form_with model: search do |form| %>
diff --git a/guides/source/maintenance_policy.md b/guides/source/maintenance_policy.md
index 3519adc8c16d7..4d1f87998f9a9 100644
--- a/guides/source/maintenance_policy.md
+++ b/guides/source/maintenance_policy.md
@@ -53,7 +53,7 @@ For unsupported series, bug fixes may coincidentally land in a stable branch,
but won't be released in an official version. It is recommended to point your
application at the stable branch using Git for unsupported versions.
-**Currently included series:** `7.1.Z`.
+**Currently included series:** `7.2.Z`.
Security Issues
---------------
@@ -78,7 +78,7 @@ there could be breaking changes in the security release. A security release
should only contain the changes needed to ensure the app is secure so that it's
easier for applications to remain upgraded.
-**Currently included series:** `7.1.Z`, `7.0.Z`, `6.1.Z`.
+**Currently included series:** `7.2.Z`, `7.1.Z`.
Severe Security Issues
----------------------
@@ -87,7 +87,7 @@ For severe security issues all releases in the current major series, and also th
last release in the previous major series will receive patches and new versions. The
classification of the security issue is judged by the core team.
-**Currently included series:** `7.1.Z`, `7.0.Z`, `6.1.Z`.
+**Currently included series:** `7.2.Z`, `7.1.Z`, `7.0.Z`, `6.1.Z`.
Unsupported Release Series
--------------------------
diff --git a/guides/source/ruby_on_rails_guides_guidelines.md b/guides/source/ruby_on_rails_guides_guidelines.md
index 91321cba27b28..24b5061ff3510 100644
--- a/guides/source/ruby_on_rails_guides_guidelines.md
+++ b/guides/source/ruby_on_rails_guides_guidelines.md
@@ -92,6 +92,10 @@ https://api.rubyonrails.org/v5.1.0/classes/ActionDispatch/Response.html
Please don't link to `edgeapi.rubyonrails.org` manually.
+Column Wrapping
+---------------
+
+Do not reformat old guides just to wrap columns. But new sections and guides should wrap at 80 columns.
API Documentation Guidelines
----------------------------
diff --git a/guides/source/security.md b/guides/source/security.md
index 41271e3ac587c..2a5e6b8ad7b33 100644
--- a/guides/source/security.md
+++ b/guides/source/security.md
@@ -527,7 +527,7 @@ INFO: _A common pitfall in Ruby's regular expressions is to match the string's b
Ruby uses a slightly different approach than many other languages to match the end and the beginning of a string. That is why even many Ruby and Rails books get this wrong. So how is this a security threat? Say you wanted to loosely validate a URL field and you used a simple regular expression like this:
```ruby
- /^https?:\/\/[^\n]+$/i
+/^https?:\/\/[^\n]+$/i
```
This may work fine in some languages. However, _in Ruby `^` and `$` match the **line** beginning and line end_. And thus a URL like this passes the filter without problems:
@@ -541,7 +541,7 @@ http://hi.com
This URL passes the filter because the regular expression matches - the second line, the rest does not matter. Now imagine we had a view that showed the URL like this:
```ruby
- link_to "Homepage", @user.homepage
+link_to "Homepage", @user.homepage
```
The link looks innocent to visitors, but when it's clicked, it will execute the JavaScript function "exploit_code" or any other JavaScript the attacker provides.
@@ -549,14 +549,14 @@ The link looks innocent to visitors, but when it's clicked, it will execute the
To fix the regular expression, `\A` and `\z` should be used instead of `^` and `$`, like so:
```ruby
- /\Ahttps?:\/\/[^\n]+\z/i
+/\Ahttps?:\/\/[^\n]+\z/i
```
Since this is a frequent mistake, the format validator (validates_format_of) now raises an exception if the provided regular expression starts with ^ or ends with $. If you do need to use ^ and $ instead of \A and \z (which is rare), you can set the :multiline option to true, like so:
```ruby
- # content should include a line "Meanwhile" anywhere in the string
- validates :content, format: { with: /^Meanwhile$/, multiline: true }
+# content should include a line "Meanwhile" anywhere in the string
+validates :content, format: { with: /^Meanwhile$/, multiline: true }
```
Note that this only protects you against the most common mistake when using the format validator - you always need to keep in mind that ^ and $ match the **line** beginning and line end in Ruby, and not the beginning and end of a string.
@@ -579,7 +579,7 @@ This is alright for some web applications, but certainly not if the user is not
Depending on your web application, there will be many more parameters the user can tamper with. As a rule of thumb, _no user input data is secure, until proven otherwise, and every parameter from the user is potentially manipulated_.
-Don't be fooled by security by obfuscation and JavaScript security. Developer tools let you review and change every form's hidden fields. _JavaScript can be used to validate user input data, but certainly not to prevent attackers from sending malicious requests with unexpected values_. The Firebug addon for Mozilla Firefox logs every request and may repeat and change them. That is an easy way to bypass any JavaScript validations. And there are even client-side proxies that allow you to intercept any request and response from and to the Internet.
+Don't be fooled by security by obfuscation and JavaScript security. Developer tools let you review and change every form's hidden fields. _JavaScript can be used to validate user input data, but certainly not to prevent attackers from sending malicious requests with unexpected values_. DevTools log every request and may repeat and change them. That is an easy way to bypass any JavaScript validations. And there are even client-side proxies that allow you to intercept any request and response from and to the Internet.
Injection
---------
@@ -1143,7 +1143,7 @@ browser automatically upgrades to HTTPS for current and future connections.
The header is added to the response when enabling the `force_ssl` option:
```ruby
- config.force_ssl = true
+config.force_ssl = true
```
[`Strict-Transport-Security`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
diff --git a/guides/source/testing.md b/guides/source/testing.md
index 94e90ed9ebdbc..a10ceb964ec30 100644
--- a/guides/source/testing.md
+++ b/guides/source/testing.md
@@ -1691,6 +1691,155 @@ assert_select_email do
end
```
+Testing View Partials
+---------------------
+
+Partial templates - usually called "partials" - are another device for breaking the rendering process into more manageable chunks. With partials, you can extract pieces of code from your templates to separate files and reuse them throughout your templates.
+
+View tests provide an opportunity to test that partials render content the way you expect. View partial tests reside in `test/views/` and inherit from `ActionView::TestCase`.
+
+To render a partial, call `render` like you would in a template. The content is
+available through the test-local `#rendered` method:
+
+```ruby
+class ArticlePartialTest < ActionView::TestCase
+ test "renders a link to itself" do
+ article = Article.create! title: "Hello, world"
+
+ render "articles/article", article: article
+
+ assert_includes rendered, article.title
+ end
+end
+```
+
+Tests that inherit from `ActionView::TestCase` also have access to [`assert_select`](#testing-views) and the [other additional view-based assertions](#additional-view-based-assertions) provided by [rails-dom-testing][]:
+
+```ruby
+test "renders a link to itself" do
+ article = Article.create! title: "Hello, world"
+
+ render "articles/article", article: article
+
+ assert_select "a[href=?]", article_url(article), text: article.title
+end
+```
+
+In order to integrate with [rails-dom-testing][], tests that inherit from
+`ActionView::TestCase` declare a `document_root_element` method that returns the
+rendered content as an instance of a
+[Nokogiri::XML::Node](https://www.rubydoc.info/github/sparklemotion/nokogiri/Nokogiri/XML/Node):
+
+```ruby
+test "renders a link to itself" do
+ article = Article.create! title: "Hello, world"
+
+ render "articles/article", article: article
+ anchor = document_root_element.at("a")
+
+ assert_equal article.name, anchor.text
+ assert_equal article_url(article), anchor["href"]
+end
+```
+
+If your application uses Ruby >= 3.0 or higher, depends on [Nokogiri >= 1.14.0](https://github.com/sparklemotion/nokogiri/releases/tag/v1.14.0) or
+higher, and depends on [Minitest >= >5.18.0](https://github.com/minitest/minitest/blob/v5.18.0/History.rdoc#5180--2023-03-04-),
+`document_root_element` supports [Ruby's Pattern Matching](https://docs.ruby-lang.org/en/master/syntax/pattern_matching_rdoc.html):
+
+```ruby
+test "renders a link to itself" do
+ article = Article.create! title: "Hello, world"
+
+ render "articles/article", article: article
+ anchor = document_root_element.at("a")
+
+ assert_pattern do
+ anchor => { content: article.title, attributes: [{ name: "href", value: article_url(article) }] }
+ end
+end
+```
+
+If you'd like to access the same [Capybara-powered Assertions](https://rubydoc.info/github/teamcapybara/capybara/master/Capybara/Minitest/Assertions)
+that your [Functional and System Testing](#functional-and-system-testing) tests
+utilize, you can define a base class that inherits from `ActionView::TestCase`
+and transforms the `document_root_element` into a `page` method:
+
+```ruby
+# test/view_partial_test_case.rb
+
+require "test_helper"
+require "capybara/minitest"
+
+class ViewPartialTestCase < ActionView::TestCase
+ include Capybara::Minitest::Assertions
+
+ def page
+ Capybara.string(document_root_element)
+ end
+end
+
+# test/views/article_partial_test.rb
+
+require "view_partial_test_case"
+
+class ArticlePartialTest < ViewPartialTestCase
+ test "renders a link to itself" do
+ article = Article.create! title: "Hello, world"
+
+ render "articles/article", article: article
+
+ assert_link article.title, href: article_url(article)
+ end
+end
+```
+
+Starting in Action View version 7.1, the `#rendered` helper method returns an
+object capable of parsing the view partial's rendered content.
+
+To transform the `String` content returned by the `#rendered` method into an
+object, define a parser by calling `.register_parser`. Calling
+`.register_parser :rss` defines a `#rendered.rss` helper method. For example,
+to parse rendered [RSS content][] into an object with `#rendered.rss`, register
+a call to `RSS::Parser.parse`:
+
+```ruby
+register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }
+
+test "renders RSS" do
+ article = Article.create!(title: "Hello, world")
+
+ render formats: :rss, partial: article
+
+ assert_equal "Hello, world", rendered.rss.items.last.title
+end
+```
+
+By default, `ActionView::TestCase` defines a parser for:
+
+* `:html` - returns an instance of [Nokogiri::XML::Node](https://nokogiri.org/rdoc/Nokogiri/XML/Node.html)
+* `:json` - returns an instance of [ActiveSupport::HashWithIndifferentAccess](https://edgeapi.rubyonrails.org/classes/ActiveSupport/HashWithIndifferentAccess.html)
+
+```ruby
+test "renders HTML" do
+ article = Article.create!(title: "Hello, world")
+
+ render partial: "articles/article", locals: { article: article }
+
+ assert_pattern { rendered.html.at("main h1") => { content: "Hello, world" } }
+end
+
+test "renders JSON" do
+ article = Article.create!(title: "Hello, world")
+
+ render formats: :json, partial: "articles/article", locals: { article: article }
+
+ assert_pattern { rendered.json => { title: "Hello, world" } }
+end
+```
+
+[rails-dom-testing]: https://github.com/rails/rails-dom-testing
+[RSS content]: https://www.rssboard.org/rss-specification
+
Testing Helpers
---------------
diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md
index 28911d93d2c31..20e86e90af42d 100644
--- a/guides/source/upgrading_ruby_on_rails.md
+++ b/guides/source/upgrading_ruby_on_rails.md
@@ -44,7 +44,7 @@ Repeat this process until you reach your target Rails version.
To move between versions:
1. Change the Rails version number in the `Gemfile` and run `bundle update`.
-2. Change the versions for Rails JavaScript packages in `package.json` and run `yarn install`, if running on Webpacker.
+2. Change the versions for Rails JavaScript packages in `package.json` and run `bin/rails javascript:install` if running jsbundling-rails
3. Run the [Update task](#the-update-task).
4. Run your tests.
@@ -63,7 +63,7 @@ $ bin/rails app:update
conflict config/application.rb
Overwrite /myapp/config/application.rb? (enter "h" for help) [Ynaqdh]
force config/application.rb
- create config/initializers/new_framework_defaults_7_0.rb
+ create config/initializers/new_framework_defaults_7_2.rb
...
```
@@ -75,18 +75,100 @@ The new Rails version might have different configuration defaults than the previ
To allow you to upgrade to new defaults one by one, the update task has created a file `config/initializers/new_framework_defaults_X.Y.rb` (with the desired Rails version in the filename). You should enable the new configuration defaults by uncommenting them in the file; this can be done gradually over several deployments. Once your application is ready to run with new defaults, you can remove this file and flip the `config.load_defaults` value.
+Upgrading from Rails 7.1 to Rails 7.2
+-------------------------------------
+
+For more information on changes made to Rails 7.2 please see the [release notes](7_2_release_notes.html).
+
Upgrading from Rails 7.0 to Rails 7.1
-------------------------------------
For more information on changes made to Rails 7.1 please see the [release notes](7_1_release_notes.html).
-### Autoloaded paths are no longer in load path
+### Autoloaded paths are no longer in $LOAD_PATH
+
+Starting from Rails 7.1, the directories managed by the autoloaders are no
+longer added to `$LOAD_PATH`. This means it won't be possible to load their
+files with a manual `require` call, which shouldn't be done anyway.
+
+Reducing the size of `$LOAD_PATH` speeds up `require` calls for apps not using
+`bootsnap`, and reduces the size of the `bootsnap` cache for the others.
+
+If you'd like to have these paths still in `$LOAD_PATH`, you can opt-in:
+
+```ruby
+config.add_autoload_paths_to_load_path = true
+```
+
+but we discourage doing so, classes and modules in the autoload paths are meant
+to be autoloaded. That is, just reference them.
+
+The `lib` directory is not affected by this flag, it is added to `$LOAD_PATH`
+always.
+
+### config.autoload_lib and config.autoload_lib_once
-Starting from Rails 7.1, all paths managed by the autoloader will no longer be added to `$LOAD_PATH`.
-This means it won't be possible to load them with a manual `require` call, the class or module can be referenced instead.
+If your application does not have `lib` in the autoload or autoload once paths,
+please skip this section. You can find that out by inspecting the output of
-Reducing the size of `$LOAD_PATH` speed-up `require` calls for apps not using `bootsnap`, and reduce the
-size of the `bootsnap` cache for the others.
+```bash
+# Print autoload paths.
+$ bin/rails runner 'pp Rails.autoloaders.main.dirs'
+
+# Print autoload once paths.
+$ bin/rails runner 'pp Rails.autoloaders.once.dirs'
+```
+
+If your application already has `lib` in the autoload paths, normally there is
+configuration in `config/application.rb` that looks something like
+
+```ruby
+# Autoload lib, but do not eager load it (maybe overlooked).
+config.autoload_paths << config.root.join("lib")
+```
+
+or
+
+```ruby
+# Autoload and also eager load lib.
+config.autoload_paths << config.root.join("lib")
+config.eager_load_paths << config.root.join("lib")
+```
+
+or
+
+```ruby
+# Same, because all eager load paths become autoload paths too.
+config.eager_load_paths << config.root.join("lib")
+```
+
+That still works, but it is recommended to replace those lines with the more
+concise
+
+```ruby
+config.autoload_lib(ignore: %w(assets tasks))
+```
+
+Please, add to the `ignore` list any other `lib` subdirectories that do not
+contain `.rb` files, or that should not be reloaded or eager loaded. For
+example, if your application has `lib/templates`, `lib/generators`, or
+`lib/middleware`, you'd add their name relative to `lib`:
+
+```ruby
+config.autoload_lib(ignore: %w(assets tasks templates generators middleware))
+```
+
+With that one-liner, the (non-ignored) code in `lib` will be also eager loaded
+if `config.eager_load` is `true` (the default in `production` mode). This is
+normally what you want, but if `lib` was not added to the eager load paths
+before and you still want it that way, please opt-out:
+
+```ruby
+Rails.autoloaders.main.do_not_eager_load(config.root.join("lib"))
+```
+
+The method `config.autoload_lib_once` is the analogous one if the application
+had `lib` in `config.autoload_once_paths`.
### `ActiveStorage::BaseController` no longer includes the streaming concern
@@ -162,6 +244,19 @@ I18n.t("missing.key") # didn't raise in 7.0, doesn't raise in 7.1
Alternatively, you can customise the `I18n.exception_handler`.
See the [i18n guide](https://guides.rubyonrails.org/v7.1/i18n.html#using-different-exception-handlers) for more information.
+`AbstractController::Translation.raise_on_missing_translations` has been removed. This was a private API, if you were
+relying on it you should migrate to `config.i18n.raise_on_missing_translations` or to a custom exception handler.
+
+### `bin/rails test` now runs `test:prepare` task
+
+When running tests via `bin/rails test`, the `rake test:prepare` task will run before tests run. If you've enhanced
+the `test:prepare` task, your enhancements will run before your tests. `tailwindcss-rails`, `jsbundling-rails`, and `cssbundling-rails`
+enhance this task, as do other third party gems.
+
+See the [Testing Rails Applications](https://edgeguides.rubyonrails.org/testing.html#running-tests-in-continuous-integration-ci) guide for more information.
+
+If you run a single file's tests (`bin/rails test test/models/user_test.rb`), `test:prepare` will not run before it.
+
Upgrading from Rails 6.1 to Rails 7.0
-------------------------------------
@@ -2292,10 +2387,10 @@ Rails 4.0 no longer supports loading plugins from `vendor/plugins`. You must rep
* Rails 4.0 requires that scopes use a callable object such as a Proc or lambda:
```ruby
- scope :active, where(active: true)
+ scope :active, where(active: true)
- # becomes
- scope :active, -> { where active: true }
+ # becomes
+ scope :active, -> { where active: true }
```
* Rails 4.0 has deprecated `ActiveRecord::Fixtures` in favor of `ActiveRecord::FixtureSet`.
@@ -2356,9 +2451,9 @@ Rails 4.0 extracted Active Resource to its own gem. If you still need the featur
* Rails 4.0 introduces `ActiveSupport::KeyGenerator` and uses this as a base from which to generate and verify signed cookies (among other things). Existing signed cookies generated with Rails 3.x will be transparently upgraded if you leave your existing `secret_token` in place and add the new `secret_key_base`.
```ruby
- # config/initializers/secret_token.rb
- Myapp::Application.config.secret_token = 'existing secret token'
- Myapp::Application.config.secret_key_base = 'new secret key base'
+ # config/initializers/secret_token.rb
+ Myapp::Application.config.secret_token = 'existing secret token'
+ Myapp::Application.config.secret_key_base = 'new secret key base'
```
Please note that you should wait to set `secret_key_base` until you have 100% of your userbase on Rails 4.x and are reasonably sure you will not need to rollback to Rails 3.x. This is because cookies signed based on the new `secret_key_base` in Rails 4.x are not backwards compatible with Rails 3.x. You are free to leave your existing `secret_token` in place, not set the new `secret_key_base`, and ignore the deprecation warnings until you are reasonably sure that your upgrade is otherwise complete.
@@ -2422,14 +2517,14 @@ Rails 4.0 extracted Active Resource to its own gem. If you still need the featur
* Rails 4.0 requires that routes using `match` must specify the request method. For example:
```ruby
- # Rails 3.x
- match '/' => 'root#index'
+ # Rails 3.x
+ match '/' => 'root#index'
- # becomes
- match '/' => 'root#index', via: :get
+ # becomes
+ match '/' => 'root#index', via: :get
- # or
- get '/' => 'root#index'
+ # or
+ get '/' => 'root#index'
```
* Rails 4.0 has removed `ActionDispatch::BestStandardsSupport` middleware, `` already triggers standards mode per https://msdn.microsoft.com/en-us/library/jj676915(v=vs.85).aspx and ChromeFrame header has been moved to `config.action_dispatch.default_headers`.
@@ -2446,10 +2541,10 @@ Rails 4.0 extracted Active Resource to its own gem. If you still need the featur
* Rails 4.0 allows configuration of HTTP headers by setting `config.action_dispatch.default_headers`. The defaults are as follows:
```ruby
- config.action_dispatch.default_headers = {
- 'X-Frame-Options' => 'SAMEORIGIN',
- 'X-XSS-Protection' => '1; mode=block'
- }
+ config.action_dispatch.default_headers = {
+ 'X-Frame-Options' => 'SAMEORIGIN',
+ 'X-XSS-Protection' => '1; mode=block'
+ }
```
Please note that if your application is dependent on loading certain pages in a `` or `